1196 lines
37 KiB
JavaScript
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);
|
|
})();
|