505 lines
15 KiB
JavaScript
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,
|
|
};
|
|
})();
|