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

505 lines
15 KiB
JavaScript

(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,
};
})();