Files
GIA/core/static/js/compose-panel.js

1196 lines
37 KiB
JavaScript

(function () {
if (window.GIAComposePanel) {
window.GIAComposePanel.initAll(document);
return;
}
const PANEL_SELECTOR = ".compose-shell[data-compose-panel='1']";
window.giaComposePanels = window.giaComposePanels || {};
const collectPanels = function (root) {
const panels = [];
if (!root) {
return panels;
}
if (root.matches && root.matches(PANEL_SELECTOR)) {
panels.push(root);
}
if (root.querySelectorAll) {
root.querySelectorAll(PANEL_SELECTOR).forEach(function (panel) {
panels.push(panel);
});
}
return panels;
};
const toInt = function (value) {
const parsed = parseInt(value || "0", 10);
return Number.isFinite(parsed) ? parsed : 0;
};
const parseJsonSafe = function (value, fallback) {
try {
return JSON.parse(String(value || ""));
} catch (_err) {
return fallback;
}
};
const createNode = function (tagName, className, text) {
const node = document.createElement(tagName);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = String(text);
}
return node;
};
const normalizeSnippet = function (value) {
const compact = String(value || "").replace(/\s+/g, " ").trim();
if (!compact) {
return "(no text)";
}
if (compact.length <= 120) {
return compact;
}
return compact.slice(0, 117).trimEnd() + "...";
};
const titleCase = function (value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw) {
return "";
}
if (raw === "whatsapp") {
return "WhatsApp";
}
if (raw === "xmpp") {
return "XMPP";
}
return raw.charAt(0).toUpperCase() + raw.slice(1);
};
const destroyPanelState = function (state) {
if (!state) {
return;
}
if (state.pollTimer) {
clearInterval(state.pollTimer);
}
if (state.pendingCommandPoll) {
clearInterval(state.pendingCommandPoll);
}
if (state.socket) {
try {
state.socket.close();
} catch (_err) {
// Ignore socket close failures.
}
}
if (state.eventHandler) {
document.body.removeEventListener("composeMessageSent", state.eventHandler);
}
if (state.sendResultHandler) {
document.body.removeEventListener(
"composeSendResult",
state.sendResultHandler
);
}
if (state.commandIdHandler) {
document.body.removeEventListener(
"composeSendCommandId",
state.commandIdHandler
);
}
delete window.giaComposePanels[state.panelId];
};
const initPanel = function (panel) {
if (!panel) {
return;
}
const panelId = String(panel.id || "").trim();
if (!panelId) {
return;
}
if (panel.dataset.composeInitialized === "1" && window.giaComposePanels[panelId]) {
return;
}
destroyPanelState(window.giaComposePanels[panelId]);
const thread = document.getElementById(panelId + "-thread");
const form = document.getElementById(panelId + "-form");
const textarea = document.getElementById(panelId + "-textarea");
if (!thread || !form || !textarea) {
return;
}
const platformSelect = document.getElementById(panelId + "-platform-select");
const contactSelect = document.getElementById(panelId + "-contact-select");
const metaLine = document.getElementById(panelId + "-meta-line");
const hiddenService = document.getElementById(panelId + "-input-service");
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
const hiddenPerson = document.getElementById(panelId + "-input-person");
const replyBanner = document.getElementById(panelId + "-reply-banner");
const replyBannerText = document.getElementById(panelId + "-reply-text");
const replyClearBtn = document.getElementById(panelId + "-reply-clear");
const statusBox = document.getElementById(panelId + "-status");
const typingNode = document.getElementById(panelId + "-typing");
const hiddenReplyTo = form.querySelector('input[name="reply_to_message_id"]');
const manualConfirm = form.querySelector(".manual-confirm");
const armInput = form.querySelector('input[name="failsafe_arm"]');
const confirmInput = form.querySelector('input[name="failsafe_confirm"]');
const sendButton = form.querySelector(".compose-send-btn");
const renderMode = String(panel.dataset.renderMode || "page");
const csrfToken = String(panel.dataset.csrfToken || "");
const sendCapable = String(panel.dataset.capabilitySend || "").toLowerCase() === "true";
panel.dataset.composeInitialized = "1";
const state = {
panelId: panelId,
pollTimer: null,
polling: false,
socket: null,
websocketReady: false,
pendingCommandPoll: null,
pendingCommandId: "",
pendingCommandAttempts: 0,
pendingCommandStartedAt: 0,
pendingCommandInFlight: false,
eventHandler: null,
sendResultHandler: null,
commandIdHandler: null,
};
window.giaComposePanels[panelId] = state;
let lastTs = toInt(thread.dataset.lastTs);
let transientCancelButton = null;
let persistentCancelWrap = null;
const setStatus = function (message, level) {
if (!statusBox) {
return;
}
statusBox.innerHTML = "";
if (!message) {
return;
}
statusBox.appendChild(
createNode(
"p",
"compose-status-line is-" + String(level || "info"),
String(message)
)
);
};
const flashCompose = function (className) {
panel.classList.remove("is-send-success", "is-send-fail");
void panel.offsetWidth;
panel.classList.add(className);
window.setTimeout(function () {
panel.classList.remove(className);
}, 420);
};
const autosize = function () {
textarea.style.height = "auto";
textarea.style.height = Math.min(Math.max(textarea.scrollHeight, 44), 128) + "px";
};
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 normalizeIdentifierForService = function (service, identifier) {
const serviceKey = String(service || "").trim().toLowerCase();
const raw = String(identifier || "").trim();
if (serviceKey === "whatsapp" && raw.includes("@")) {
return raw.split("@", 1)[0].trim();
}
return raw;
};
const buildComposeUrl = function (service, identifier, personId) {
const serviceKey = String(service || "").trim().toLowerCase();
const identifierValue = normalizeIdentifierForService(serviceKey, identifier);
if (!serviceKey || !identifierValue) {
return "";
}
const params = new URLSearchParams();
params.set("service", serviceKey);
params.set("identifier", identifierValue);
if (personId) {
params.set("person", String(personId || "").trim());
}
return (renderMode === "page" ? "/compose/page/" : "/compose/widget/")
+ "?"
+ params.toString();
};
const parseServiceMap = function (optionNode) {
const fallbackService = String(
(optionNode && optionNode.dataset && optionNode.dataset.service) || ""
).trim().toLowerCase();
const fallbackIdentifier = String(
(optionNode && optionNode.value) || ""
).trim();
const fallback = {};
if (fallbackService && fallbackIdentifier) {
fallback[fallbackService] = fallbackIdentifier;
}
if (!optionNode || !optionNode.dataset) {
return fallback;
}
const parsed = parseJsonSafe(optionNode.dataset.serviceMap || "{}", fallback);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return fallback;
}
const normalized = {};
Object.keys(parsed).forEach(function (key) {
const serviceKey = String(key || "").trim().toLowerCase();
const identifierValue = String(parsed[key] || "").trim();
if (serviceKey && identifierValue) {
normalized[serviceKey] = identifierValue;
}
});
return Object.keys(normalized).length ? normalized : fallback;
};
const postFormJson = async function (url, params) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrfToken,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
if (!response.ok) {
throw new Error("Request failed");
}
return response.json();
};
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 = 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 = 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 bindReplyReferences = function (root) {
const scope = root || thread;
scope.querySelectorAll(".compose-row").forEach(function (row) {
if (!row.dataset.replySnippet) {
const body = row.querySelector(".compose-body");
row.dataset.replySnippet = normalizeSnippet(body ? body.textContent : "");
}
});
scope.querySelectorAll(".compose-reply-ref").forEach(function (ref) {
const button = ref.querySelector(".compose-reply-link");
const targetId = String(ref.dataset.replyTargetId || "").trim();
if (!button || !targetId) {
return;
}
const targetRow = rowByMessageId(targetId);
const preview = normalizeSnippet(
(targetRow && targetRow.dataset ? targetRow.dataset.replySnippet : "")
|| ref.dataset.replyPreview
|| ""
);
button.textContent = "Reply to: " + preview;
if (button.dataset.bound === "1") {
return;
}
button.dataset.bound = "1";
button.addEventListener("click", function () {
const row = rowByMessageId(targetId);
if (!row) {
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
flashReplyTarget(row);
});
});
};
const appendMetaNode = function (meta, node) {
if (!meta || !node) {
return;
}
if (meta.childNodes.length) {
meta.appendChild(document.createTextNode(" "));
}
meta.appendChild(node);
};
const renderEditHistoryDetails = function (bubble, msg) {
const rows = Array.isArray(msg && msg.edit_history) ? msg.edit_history : [];
if (!rows.length) {
return;
}
const details = createNode("details", "compose-edit-history");
const summary = createNode(
"summary",
"",
"Edited " + rows.length + (rows.length === 1 ? " time" : " times")
);
const list = createNode("ul");
details.appendChild(summary);
rows.forEach(function (entry) {
const item = createNode("li");
const editedDisplay =
String((entry && entry.edited_display) || "").trim() || "Unknown time";
const actor = String((entry && entry.actor) || "").trim();
const source = String((entry && entry.source_service) || "").trim();
item.appendChild(
document.createTextNode(
editedDisplay
+ (actor ? " · " + actor : "")
+ (source ? " · " + source.toUpperCase() : "")
)
);
const diff = createNode("div", "compose-edit-diff");
diff.appendChild(
createNode(
"span",
"compose-edit-old",
String((entry && entry.previous_text) || "(empty)")
)
);
diff.appendChild(createNode("span", "compose-edit-arrow", "->"));
diff.appendChild(
createNode(
"span",
"compose-edit-new",
String((entry && entry.new_text) || "(empty)")
)
);
item.appendChild(diff);
list.appendChild(item);
});
details.appendChild(list);
bubble.appendChild(details);
};
const renderBubbleReactions = function (bubble, reactions) {
const rows = Array.isArray(reactions) ? reactions : [];
if (!rows.length) {
return;
}
const wrap = createNode("div", "compose-reactions");
wrap.setAttribute("aria-label", "Message reactions");
rows.forEach(function (reaction) {
const emoji = String((reaction && reaction.emoji) || "").trim();
if (!emoji) {
return;
}
const actor = String((reaction && reaction.actor) || "").trim();
const source = String((reaction && reaction.source_service) || "")
.trim()
.toLowerCase();
const chip = createNode("span", "tag is-light compose-reaction-chip", emoji);
chip.dataset.emoji = emoji;
chip.dataset.actor = actor;
chip.dataset.sourceService = source;
chip.title = (actor || "Unknown") + " via " + (source || "unknown").toUpperCase();
wrap.appendChild(chip);
});
if (wrap.children.length) {
bubble.appendChild(wrap);
}
};
const appendImageNodes = function (bubble, msg) {
const imageUrls = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
imageUrls.forEach(function (imageUrl) {
const figure = createNode("figure", "compose-media");
const image = createNode("img", "compose-image");
image.src = String(imageUrl);
image.alt = "Attachment";
image.referrerPolicy = "no-referrer";
image.loading = "lazy";
image.decoding = "async";
figure.appendChild(image);
bubble.appendChild(figure);
});
};
const buildMessageRow = function (msg) {
const row = createNode(
"div",
"compose-row " + (msg.outgoing ? "is-out" : "is-in")
);
if (msg.is_deleted) {
row.classList.add("is-deleted");
}
row.dataset.ts = String(msg.ts || 0);
row.dataset.messageId = String(msg.id || "");
row.dataset.replySnippet = normalizeSnippet(
msg.display_text || msg.text || ""
);
if (msg.reply_to_id) {
row.dataset.replyToId = String(msg.reply_to_id || "");
}
const bubble = createNode(
"article",
"compose-bubble " + (msg.outgoing ? "is-out" : "is-in")
);
if (msg.reply_to_id) {
const replyRef = createNode("div", "compose-reply-ref");
replyRef.dataset.replyTargetId = String(msg.reply_to_id || "");
replyRef.dataset.replyPreview = String(msg.reply_preview || "");
const replyLink = createNode(
"button",
"compose-reply-link"
);
replyLink.type = "button";
replyLink.title = "Jump to referenced message";
replyRef.appendChild(replyLink);
bubble.appendChild(replyRef);
}
if (msg.source_label) {
const wrap = createNode("div", "compose-source-badge-wrap");
const badge = createNode(
"span",
"tag is-light compose-source-badge source-"
+ String(msg.source_service || "web").toLowerCase(),
String(msg.source_label || "")
);
wrap.appendChild(badge);
bubble.appendChild(wrap);
}
appendImageNodes(bubble, msg);
if (!msg.hide_text) {
bubble.appendChild(
createNode(
"p",
"compose-body",
String(msg.display_text || msg.text || "(no text)")
)
);
} else {
bubble.appendChild(
createNode(
"p",
"compose-body compose-image-fallback is-hidden",
"(no text)"
)
);
}
renderEditHistoryDetails(bubble, msg);
renderBubbleReactions(bubble, msg.reactions);
const meta = createNode("p", "compose-msg-meta");
meta.appendChild(
document.createTextNode(
String(msg.display_ts || msg.ts || "")
+ (msg.author ? " · " + String(msg.author) : "")
)
);
if (msg.is_edited) {
appendMetaNode(
meta,
createNode("span", "tag is-light compose-msg-flag is-edited", "edited")
);
}
if (msg.is_deleted) {
appendMetaNode(
meta,
createNode("span", "tag is-light compose-msg-flag is-deleted", "deleted")
);
}
bubble.appendChild(meta);
const replyButton = createNode(
"button",
"button is-white is-small compose-reply-btn"
);
replyButton.type = "button";
replyButton.title = "Reply to this message";
replyButton.setAttribute("aria-label", "Reply to this message");
replyButton.innerHTML =
'<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>'
+ '<span class="compose-reply-btn-label">Reply</span>';
bubble.appendChild(replyButton);
row.appendChild(bubble);
return row;
};
const insertRowByTs = function (row) {
const newTs = 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 (toInt(existing.dataset.ts) <= newTs) {
if (existing.nextSibling) {
thread.insertBefore(row, existing.nextSibling);
} else {
thread.appendChild(row);
}
return;
}
}
thread.insertBefore(row, rows[0]);
};
const appendMessage = function (msg) {
const messageId = String((msg && msg.id) || "").trim();
if (messageId) {
const existing = rowByMessageId(messageId);
if (existing) {
existing.remove();
}
}
const empty = thread.querySelector(".compose-empty");
if (empty) {
empty.remove();
}
insertRowByTs(buildMessageRow(msg));
lastTs = Math.max(lastTs, toInt(msg && msg.ts));
thread.dataset.lastTs = String(lastTs);
};
const appendMessages = function (messages, forceScroll) {
const rows = Array.isArray(messages) ? messages : [];
const shouldStick = forceScroll || nearBottom();
rows.forEach(function (msg) {
appendMessage(msg);
});
if (rows.length) {
bindReplyReferences(thread);
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 cancelSendRequest = function (commandId) {
return postFormJson(
String(panel.dataset.cancelSendUrl || ""),
queryParams({ command_id: String(commandId || "") })
);
};
const hideTransientCancelButton = function () {
if (!transientCancelButton) {
return;
}
transientCancelButton.remove();
transientCancelButton = null;
};
const showTransientCancelButton = function () {
if (!statusBox || transientCancelButton) {
return;
}
transientCancelButton = createNode(
"button",
"button is-danger is-light is-small compose-cancel-send-btn",
"Cancel Send"
);
transientCancelButton.type = "button";
transientCancelButton.addEventListener("click", async function () {
try {
await cancelSendRequest("");
} catch (_err) {
// Ignore cancel failures.
} finally {
hideTransientCancelButton();
}
});
statusBox.appendChild(transientCancelButton);
};
const hidePersistentCancelButton = function () {
if (!persistentCancelWrap) {
return;
}
persistentCancelWrap.remove();
persistentCancelWrap = null;
};
const stopPendingCommandPolling = function () {
if (state.pendingCommandPoll) {
clearInterval(state.pendingCommandPoll);
state.pendingCommandPoll = null;
}
state.pendingCommandId = "";
state.pendingCommandAttempts = 0;
state.pendingCommandStartedAt = 0;
state.pendingCommandInFlight = false;
};
const showPersistentCancelButton = function (commandId) {
hidePersistentCancelButton();
if (!statusBox) {
return;
}
persistentCancelWrap = createNode("div", "compose-persistent-cancel");
const button = createNode(
"button",
"button is-danger is-light is-small compose-persistent-cancel-btn",
"Cancel Queued Send"
);
button.type = "button";
button.addEventListener("click", async function () {
try {
await cancelSendRequest(commandId);
stopPendingCommandPolling();
hidePersistentCancelButton();
setStatus("Send cancelled.", "warning");
await poll(true);
} catch (_err) {
hidePersistentCancelButton();
}
});
persistentCancelWrap.appendChild(button);
statusBox.appendChild(persistentCancelWrap);
};
const pollPendingCommandResult = async function (commandId) {
const url = new URL(
String(panel.dataset.commandResultUrl || ""),
window.location.origin
);
url.searchParams.set("service", thread.dataset.service || "");
url.searchParams.set("command_id", commandId);
url.searchParams.set("format", "json");
const response = await fetch(url.toString(), {
credentials: "same-origin",
headers: { "HX-Request": "true" },
});
if (!response.ok || response.status === 204) {
return null;
}
return response.json();
};
const startPendingCommandPolling = function (commandId) {
if (!commandId) {
return;
}
stopPendingCommandPolling();
state.pendingCommandId = commandId;
state.pendingCommandStartedAt = Date.now();
showPersistentCancelButton(commandId);
state.pendingCommandPoll = setInterval(async function () {
if (state.pendingCommandInFlight) {
return;
}
state.pendingCommandAttempts += 1;
if (
state.pendingCommandAttempts > 14
|| (Date.now() - state.pendingCommandStartedAt) > 45000
) {
stopPendingCommandPolling();
hidePersistentCancelButton();
setStatus(
"Send timed out waiting for runtime result. Please retry.",
"warning"
);
return;
}
try {
state.pendingCommandInFlight = true;
const payload = await pollPendingCommandResult(commandId);
if (!payload || payload.pending !== false) {
return;
}
const result = payload.result || {};
stopPendingCommandPolling();
hidePersistentCancelButton();
if (result.ok) {
setStatus("", "success");
textarea.value = "";
clearReplyTarget();
autosize();
flashCompose("is-send-success");
await poll(true);
return;
}
setStatus(String(result.error || "Send failed."), "danger");
flashCompose("is-send-fail");
await poll(true);
} catch (_err) {
// Ignore transient failures; the next poll can recover.
} finally {
state.pendingCommandInFlight = false;
}
}, 3500);
};
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();
appendMessages(payload.messages || [], forceScroll);
applyTyping(payload.typing);
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, 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 = parseJsonSafe(event.data || "{}", {});
appendMessages(payload.messages || [], false);
applyTyping(payload.typing);
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, 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 = 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;
}
thread.dataset.service = service;
thread.dataset.identifier = normalizeIdentifierForService(service, identifier);
if (personId) {
thread.dataset.person = personId;
} else {
delete thread.dataset.person;
}
if (hiddenService) {
hiddenService.value = service;
}
if (hiddenIdentifier) {
hiddenIdentifier.value = normalizeIdentifierForService(service, identifier);
}
if (hiddenPerson) {
hiddenPerson.value = personId;
}
if (metaLine) {
metaLine.textContent = titleCase(service) + " · " + normalizeIdentifierForService(service, identifier);
}
clearReplyTarget();
stopPendingCommandPolling();
hidePersistentCancelButton();
hideTransientCancelButton();
if (state.socket) {
try {
state.socket.close();
} catch (_err) {
// Ignore close failures.
}
state.socket = null;
}
state.websocketReady = false;
lastTs = 0;
thread.dataset.lastTs = "0";
thread.innerHTML = "";
ensureEmptyState("Loading messages...");
applyTyping({ typing: false });
poll(true);
setupWebSocket();
};
autosize();
textarea.addEventListener("input", autosize);
if (manualConfirm) {
manualConfirm.addEventListener("change", function () {
const confirmed = !!manualConfirm.checked;
if (armInput) {
armInput.value = confirmed ? "1" : "0";
}
if (confirmInput) {
confirmInput.value = confirmed ? "1" : "0";
}
if (sendButton) {
sendButton.disabled = !sendCapable || !confirmed;
}
});
manualConfirm.dispatchEvent(new Event("change"));
}
if (replyClearBtn) {
replyClearBtn.addEventListener("click", function () {
clearReplyTarget();
textarea.focus();
});
}
if (platformSelect) {
platformSelect.addEventListener("change", function () {
const selected = platformSelect.options[platformSelect.selectedIndex];
if (!selected) {
return;
}
const targetUrl = buildComposeUrl(
selected.value || "",
selected.dataset.identifier || "",
selected.dataset.person || ""
);
switchThreadContext(
selected.value || "",
normalizeIdentifierForService(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 = 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 = buildComposeUrl(
selectedService,
selectedIdentifier,
selected.dataset.person || ""
);
switchThreadContext(
selectedService,
normalizeIdentifierForService(selectedService, selectedIdentifier),
selected.dataset.person || "",
targetUrl
);
});
}
thread.addEventListener("click", function (event) {
const replyButton = event.target.closest && event.target.closest(".compose-reply-btn");
if (replyButton) {
const row = replyButton.closest(".compose-row");
if (row) {
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
textarea.focus();
}
}
});
textarea.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || event.shiftKey) {
return;
}
event.preventDefault();
if (sendButton && sendButton.disabled) {
setStatus("Enable send confirmation before sending.", "warning");
return;
}
form.requestSubmit();
});
form.addEventListener("submit", function () {
if (sendButton && sendButton.disabled) {
return;
}
showTransientCancelButton();
});
form.addEventListener("htmx:afterRequest", function () {
hideTransientCancelButton();
textarea.focus();
});
state.eventHandler = function (event) {
const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || "");
if (sourcePanelId && sourcePanelId !== panelId) {
return;
}
poll(true);
};
document.body.addEventListener("composeMessageSent", state.eventHandler);
state.sendResultHandler = function (event) {
const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || "");
if (sourcePanelId && sourcePanelId !== panelId) {
return;
}
hideTransientCancelButton();
if (detail.ok) {
flashCompose("is-send-success");
textarea.value = "";
clearReplyTarget();
autosize();
poll(true);
} else {
flashCompose("is-send-fail");
if (detail.message) {
setStatus(detail.message, detail.level || "danger");
}
}
textarea.focus();
};
document.body.addEventListener("composeSendResult", state.sendResultHandler);
state.commandIdHandler = function (event) {
const detail = (event && event.detail) || {};
const commandId = String(
detail.command_id
|| (detail.composeSendCommandId && detail.composeSendCommandId.command_id)
|| ""
).trim();
if (commandId) {
startPendingCommandPolling(commandId);
}
};
document.body.addEventListener("composeSendCommandId", state.commandIdHandler);
bindReplyReferences(thread);
applyTyping(parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
ensureEmptyState();
scrollToBottom(true);
setupWebSocket();
state.pollTimer = setInterval(function () {
if (!document.getElementById(panelId)) {
destroyPanelState(state);
return;
}
poll(false);
}, 4000);
};
const initAll = function (root) {
collectPanels(root).forEach(function (panel) {
initPanel(panel);
});
};
window.GIAComposePanel = {
initAll: initAll,
initPanel: initPanel,
destroyPanel: function (panelId) {
destroyPanelState(window.giaComposePanels[panelId]);
},
};
document.addEventListener("DOMContentLoaded", function () {
initAll(document);
});
if (document.body) {
document.body.addEventListener("htmx:afterSwap", function (event) {
initAll((event && event.target) || document);
});
}
initAll(document);
})();