(function () { if (window.GIAComposePanelThread) { return; } const core = window.GIAComposePanelCore; if (!core) { return; } const createController = function (config) { const panel = config.panel; const state = config.state; const thread = config.thread; const textarea = config.textarea; const typingNode = config.typingNode; const hiddenReplyTo = config.hiddenReplyTo; const replyBanner = config.replyBanner; const replyBannerText = config.replyBannerText; const replyClearBtn = config.replyClearBtn; const platformSelect = config.platformSelect; const contactSelect = config.contactSelect; const hiddenService = config.hiddenService; const hiddenIdentifier = config.hiddenIdentifier; const hiddenPerson = config.hiddenPerson; const metaLine = config.metaLine; const renderMode = config.renderMode; let lastTs = core.toInt(thread.dataset.lastTs); let beforeContextReset = null; const nearBottom = function () { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120; }; const scrollToBottom = function (force) { if (force || nearBottom()) { thread.scrollTop = thread.scrollHeight; } }; const queryParams = function (extraParams) { const params = new URLSearchParams(); params.set("service", thread.dataset.service || ""); params.set("identifier", thread.dataset.identifier || ""); if (thread.dataset.person) { params.set("person", thread.dataset.person); } params.set("limit", thread.dataset.limit || "60"); const extras = extraParams && typeof extraParams === "object" ? extraParams : {}; Object.keys(extras).forEach(function (key) { const value = extras[key]; if (value === undefined || value === null || value === "") { return; } params.set(String(key), String(value)); }); return params; }; const ensureEmptyState = function (messageText) { if (thread.querySelector(".compose-row")) { const empty = thread.querySelector(".compose-empty"); if (empty) { empty.remove(); } return; } let empty = thread.querySelector(".compose-empty"); if (!empty) { empty = core.createNode("p", "compose-empty"); thread.appendChild(empty); } empty.textContent = String( messageText || "No stored messages for this contact yet." ); }; const rowByMessageId = function (messageId) { const targetId = String(messageId || "").trim(); if (!targetId) { return null; } return thread.querySelector( '.compose-row[data-message-id="' + targetId + '"]' ); }; const clearReplySelectionClass = function () { thread .querySelectorAll(".compose-row.compose-reply-selected") .forEach(function (row) { row.classList.remove("compose-reply-selected"); }); }; const flashReplyTarget = function (row) { if (!row) { return; } row.classList.remove("is-target-flash"); void row.offsetWidth; row.classList.add("is-target-flash"); window.setTimeout(function () { row.classList.remove("is-target-flash"); }, 1000); }; const clearReplyTarget = function () { if (hiddenReplyTo) { hiddenReplyTo.value = ""; } if (replyBanner) { replyBanner.classList.add("is-hidden"); } if (replyBannerText) { replyBannerText.textContent = ""; } clearReplySelectionClass(); }; const setReplyTarget = function (messageId, preview) { const targetId = String(messageId || "").trim(); if (!targetId) { clearReplyTarget(); return; } const row = rowByMessageId(targetId); const snippet = core.normalizeSnippet( (row && row.dataset ? row.dataset.replySnippet : "") || preview || "" ); if (hiddenReplyTo) { hiddenReplyTo.value = targetId; } if (replyBannerText) { replyBannerText.textContent = snippet; } if (replyBanner) { replyBanner.classList.remove("is-hidden"); } clearReplySelectionClass(); if (row) { row.classList.add("compose-reply-selected"); } }; const parseMessageRows = function (html) { const markup = String(html || "").trim(); if (!markup) { return []; } const template = document.createElement("template"); template.innerHTML = markup; return Array.from(template.content.querySelectorAll(".compose-row")); }; const insertRowByTs = function (row) { const newTs = core.toInt(row.dataset.ts); const rows = Array.from(thread.querySelectorAll(".compose-row")); if (!rows.length) { thread.appendChild(row); return; } for (let index = rows.length - 1; index >= 0; index -= 1) { const existing = rows[index]; if (core.toInt(existing.dataset.ts) <= newTs) { if (existing.nextSibling) { thread.insertBefore(row, existing.nextSibling); } else { thread.appendChild(row); } return; } } thread.insertBefore(row, rows[0]); }; const upsertMessageRow = function (row) { if (!row || !row.classList || !row.classList.contains("compose-row")) { return; } const messageId = String((row.dataset && row.dataset.messageId) || "").trim(); if (messageId) { const existing = rowByMessageId(messageId); if (existing) { existing.remove(); } } const empty = thread.querySelector(".compose-empty"); if (empty) { empty.remove(); } insertRowByTs(row); lastTs = Math.max(lastTs, core.toInt(row.dataset.ts)); thread.dataset.lastTs = String(lastTs); }; const appendMessageHtml = function (html, forceScroll) { const rows = parseMessageRows(html); const shouldStick = forceScroll || nearBottom(); rows.forEach(function (msg) { upsertMessageRow(msg); }); if (rows.length) { scrollToBottom(shouldStick); } ensureEmptyState(); }; const applyTyping = function (payload) { if (!typingNode) { return; } const typingPayload = payload && typeof payload === "object" ? payload : {}; if (!typingPayload.typing) { typingNode.classList.add("is-hidden"); return; } const displayName = String(typingPayload.display_name || "").trim(); typingNode.textContent = (displayName || "Contact") + " is typing..."; typingNode.classList.remove("is-hidden"); }; const closeSocket = function () { if (!state.socket) { return; } try { state.socket.close(); } catch (_err) { // Ignore close failures. } state.socket = null; state.websocketReady = false; }; const poll = async function (forceScroll) { if (state.polling || state.websocketReady) { return; } state.polling = true; try { const response = await fetch( thread.dataset.pollUrl + "?" + queryParams({ after_ts: String(lastTs) }), { method: "GET", credentials: "same-origin", headers: { Accept: "application/json" }, } ); if (!response.ok) { return; } const payload = await response.json(); appendMessageHtml(payload.messages_html || "", forceScroll); applyTyping(payload.typing); if (payload.last_ts !== undefined && payload.last_ts !== null) { lastTs = Math.max(lastTs, core.toInt(payload.last_ts)); thread.dataset.lastTs = String(lastTs); } } catch (err) { console.debug("compose poll error", err); } finally { state.polling = false; } }; const setupWebSocket = function () { const wsPath = String(thread.dataset.wsUrl || "").trim(); if (!wsPath || !window.WebSocket) { return; } try { const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; const socket = new WebSocket(protocol + window.location.host + wsPath); state.socket = socket; socket.onopen = function () { state.websocketReady = true; try { socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs })); } catch (_err) { // Ignore sync send errors. } }; socket.onmessage = function (event) { const payload = core.parseJsonSafe(event.data || "{}", {}); appendMessageHtml(payload.messages_html || "", false); applyTyping(payload.typing); if (payload.last_ts !== undefined && payload.last_ts !== null) { lastTs = Math.max(lastTs, core.toInt(payload.last_ts)); thread.dataset.lastTs = String(lastTs); } }; socket.onclose = function () { state.websocketReady = false; if (state.socket === socket) { state.socket = null; } }; socket.onerror = function () { state.websocketReady = false; }; } catch (_err) { state.websocketReady = false; state.socket = null; } }; const switchThreadContext = function ( nextService, nextIdentifier, nextPersonId, nextUrl ) { const service = String(nextService || "").trim().toLowerCase(); const identifier = core.normalizeIdentifierForService( service, nextIdentifier ); const personId = String(nextPersonId || "").trim(); if (!service || !identifier) { return; } if (renderMode === "page" && nextUrl) { window.location.assign(String(nextUrl)); return; } if ( String(thread.dataset.service || "").toLowerCase() === service && String(thread.dataset.identifier || "") === identifier && String(thread.dataset.person || "") === personId ) { return; } if (typeof beforeContextReset === "function") { beforeContextReset(); } thread.dataset.service = service; thread.dataset.identifier = identifier; if (personId) { thread.dataset.person = personId; } else { delete thread.dataset.person; } if (hiddenService) { hiddenService.value = service; } if (hiddenIdentifier) { hiddenIdentifier.value = identifier; } if (hiddenPerson) { hiddenPerson.value = personId; } if (metaLine) { metaLine.textContent = core.titleCase(service) + " ยท " + identifier; } clearReplyTarget(); closeSocket(); lastTs = 0; thread.dataset.lastTs = "0"; thread.innerHTML = ""; ensureEmptyState("Loading messages..."); applyTyping({ typing: false }); poll(true); setupWebSocket(); }; const bindContextSelectors = function () { if (platformSelect) { platformSelect.addEventListener("change", function () { const selected = platformSelect.options[platformSelect.selectedIndex]; if (!selected) { return; } const targetUrl = core.buildComposeUrl( renderMode, selected.value || "", selected.dataset.identifier || "", selected.dataset.person || "" ); switchThreadContext( selected.value || "", selected.dataset.identifier || "", selected.dataset.person || "", targetUrl ); }); } if (contactSelect) { contactSelect.addEventListener("change", function () { const selected = contactSelect.options[contactSelect.selectedIndex]; if (!selected) { return; } const serviceMap = core.parseServiceMap(selected); const currentService = String(thread.dataset.service || "").toLowerCase(); const availableServices = Object.keys(serviceMap); let selectedService = currentService || String(selected.dataset.service || ""); let selectedIdentifier = String(serviceMap[selectedService] || "").trim(); if (!selectedIdentifier) { selectedService = String( selected.dataset.service || selectedService ).trim().toLowerCase(); selectedIdentifier = String(serviceMap[selectedService] || "").trim(); } if (!selectedIdentifier && availableServices.length) { selectedService = availableServices[0]; selectedIdentifier = String(serviceMap[selectedService] || "").trim(); } const targetUrl = core.buildComposeUrl( renderMode, selectedService, selectedIdentifier, selected.dataset.person || "" ); switchThreadContext( selectedService, selectedIdentifier, selected.dataset.person || "", targetUrl ); }); } }; const bindThreadEvents = function () { if (replyClearBtn) { replyClearBtn.addEventListener("click", function () { clearReplyTarget(); textarea.focus(); }); } thread.addEventListener("click", function (event) { const replyLink = event.target.closest && event.target.closest(".compose-reply-link"); if (replyLink) { const replyRef = replyLink.closest(".compose-reply-ref"); const targetId = String( (replyRef && replyRef.dataset && replyRef.dataset.replyTargetId) || "" ).trim(); if (!targetId) { return; } const targetRow = rowByMessageId(targetId); if (!targetRow) { return; } targetRow.scrollIntoView({ behavior: "smooth", block: "center" }); flashReplyTarget(targetRow); return; } const replyButton = event.target.closest && event.target.closest(".compose-reply-btn"); if (!replyButton) { return; } const row = replyButton.closest(".compose-row"); if (!row) { return; } setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || ""); textarea.focus(); }); }; const init = function () { bindThreadEvents(); bindContextSelectors(); applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {})); ensureEmptyState(); scrollToBottom(true); setupWebSocket(); state.pollTimer = setInterval(function () { if (!document.getElementById(config.panelId)) { if (window.GIAComposePanel) { window.GIAComposePanel.destroyPanel(config.panelId); } return; } poll(false); }, 4000); }; return { clearReplyTarget: clearReplyTarget, init: init, poll: poll, queryParams: queryParams, setBeforeContextReset: function (callback) { beforeContextReset = callback; }, }; }; window.GIAComposePanelThread = { createController: createController, }; })();