|
// retoor <retoor@molodetz.nl>
|
|
|
|
import { Component } from "./Component.js";
|
|
import { assetUrl } from "../assetVersion.js";
|
|
import { ChatSocket } from "../chat/ChatSocket.js";
|
|
import { shouldGroup } from "../chat/MessageGrouping.js";
|
|
import { Http } from "../Http.js";
|
|
import { Avatar } from "../Avatar.js";
|
|
import { DomUtils } from "../DomUtils.js";
|
|
import { ListNav } from "../ListNav.js";
|
|
import { contentRenderer } from "../ContentRenderer.js";
|
|
import { PubSubClient } from "../PubSubClient.js";
|
|
|
|
const CHAT_CSS_ID = "chat-css";
|
|
const TYPING_THROTTLE_MS = 1500;
|
|
const TYPING_HIDE_MS = 4000;
|
|
const AUTO_SCROLL_MARGIN_PX = 100;
|
|
const STABILIZE_MAX_FRAMES = 300;
|
|
const SEND_TIMEOUT_MS = 8000;
|
|
const MOBILE_BREAKPOINT_PX = 768;
|
|
const AI_INDICATOR_MS = 4000;
|
|
const SEARCH_DEBOUNCE_MS = 200;
|
|
const AUTO_GROW_MAX_HEIGHT_PX = 160;
|
|
|
|
const LIGHT_THEME_OVERRIDES = {
|
|
"--bg-primary": "#f5f3fa",
|
|
"--bg-secondary": "#ffffff",
|
|
"--bg-card": "#ffffff",
|
|
"--bg-card-hover": "#f0edf7",
|
|
"--text-primary": "#1a1030",
|
|
"--text-secondary": "#4a3f5c",
|
|
"--text-muted": "#8b7fa0",
|
|
"--border": "rgba(0, 0, 0, 0.1)",
|
|
"--border-light": "rgba(0, 0, 0, 0.16)",
|
|
};
|
|
|
|
export class AppChat extends Component {
|
|
constructor() {
|
|
super();
|
|
this._built = false;
|
|
this.socket = null;
|
|
this._pubsub = null;
|
|
this._presenceUnsubs = null;
|
|
this._pendingSends = new Map();
|
|
this._failedSends = new Map();
|
|
this._uploading = false;
|
|
this._userAtBottom = true;
|
|
this._stabilizeFrames = 0;
|
|
this._stabilizePending = false;
|
|
this._lastTypingSent = 0;
|
|
this._typingHideTimer = null;
|
|
this._socketReady = false;
|
|
this._activePane = "list";
|
|
this._supportsFieldSizing = typeof CSS !== "undefined" && !!CSS.supports && CSS.supports("field-sizing", "content");
|
|
this._autoGrowFrame = null;
|
|
this._lastAutoGrowHeight = null;
|
|
}
|
|
|
|
_ensureCss() {
|
|
if (document.getElementById(CHAT_CSS_ID)) return;
|
|
const link = document.createElement("link");
|
|
link.id = CHAT_CSS_ID;
|
|
link.rel = "stylesheet";
|
|
link.href = assetUrl("/static/css/chat.css");
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
connectedCallback() {
|
|
if (this._built) return;
|
|
this._built = true;
|
|
this._ensureCss();
|
|
this._readConfig();
|
|
this._applyThemeOverrides();
|
|
this._adopt();
|
|
this._ensureSkeleton();
|
|
this._capAttachments();
|
|
this._initComposer();
|
|
this._initRetry();
|
|
this._initSearch();
|
|
this._initMobilePane();
|
|
this._bindViewport();
|
|
this._bindAutoScroll();
|
|
this._startScrollWatcher();
|
|
this._connect();
|
|
this._initPresence();
|
|
this._scrollThreadToEnd();
|
|
if (this.input) this.input.focus({ preventScroll: true });
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.socket) this.socket.close();
|
|
if (this._scrollWatcher) this._scrollWatcher.disconnect();
|
|
if (this._viewportIO) this._viewportIO.disconnect();
|
|
if (this._resizeObserver) this._resizeObserver.disconnect();
|
|
if (this._viewportListenerCleanup) this._viewportListenerCleanup();
|
|
if (this._mobileQuery && this._onMobileChange) {
|
|
this._mobileQuery.removeEventListener("change", this._onMobileChange);
|
|
}
|
|
clearTimeout(this._typingHideTimer);
|
|
cancelAnimationFrame(this._autoGrowFrame);
|
|
for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
|
|
this._pendingSends.clear();
|
|
this._failedSends.clear();
|
|
if (this._presenceUnsubs) {
|
|
this._presenceUnsubs.forEach((unsub) => {
|
|
try {
|
|
unsub();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
_readConfig() {
|
|
this.mode = this.attr("mode", "page");
|
|
this.selfUid = this.attr("self-uid");
|
|
this.withUid = this.attr("with-uid") || null;
|
|
this.conversationsUrl = this.attr("conversations-url", "/messages/conversations");
|
|
this.searchUrl = this.attr("search-url", "/messages/search");
|
|
this.sendUrl = this.attr("send-url", "/messages/send");
|
|
this.wsPath = this.attr("ws-url", "/messages/ws");
|
|
this.apiKey = this.attr("api-key", "");
|
|
this.wsTicketUrl = this.attr("ws-ticket-url", "");
|
|
this.aiIndicatorEnabled = this.attr("ai-indicator", "false") === "true";
|
|
this.maxAttachments = this.intAttr("max-attachments", 5);
|
|
}
|
|
|
|
_applyThemeOverrides() {
|
|
const accent = this.attr("accent", "");
|
|
if (accent) this.style.setProperty("--accent", accent);
|
|
const theme = this.attr("theme", "dark");
|
|
if (theme === "light") {
|
|
for (const [prop, value] of Object.entries(LIGHT_THEME_OVERRIDES)) {
|
|
this.style.setProperty(prop, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
_adopt() {
|
|
this.list = this.querySelector(".messages-list");
|
|
this.main = this.querySelector(".messages-main");
|
|
this.thread = this.querySelector(".messages-thread");
|
|
this.form = this.querySelector(".messages-input-area");
|
|
this.input = this.form ? this.form.querySelector('textarea[name="content"], input[name="content"]') : null;
|
|
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
|
|
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
|
|
this.typingEl = this.querySelector("#typing-indicator");
|
|
this.backBtn = this.querySelector("#messages-back-btn");
|
|
this.searchInput = this.querySelector("#message-search");
|
|
this.presenceEl = this.querySelector("#messages-presence");
|
|
}
|
|
|
|
_ensureSkeleton() {
|
|
if (this.main) return;
|
|
|
|
this.main = document.createElement("div");
|
|
this.main.className = "messages-main";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "messages-main-header";
|
|
const headerInfo = document.createElement("div");
|
|
headerInfo.className = "messages-header-info";
|
|
const presence = document.createElement("span");
|
|
presence.className = "messages-presence";
|
|
presence.id = "messages-presence";
|
|
presence.setAttribute("role", "status");
|
|
presence.setAttribute("aria-live", "polite");
|
|
presence.setAttribute("data-presence-label", "");
|
|
presence.textContent = "offline";
|
|
headerInfo.appendChild(presence);
|
|
header.appendChild(headerInfo);
|
|
this.presenceEl = presence;
|
|
|
|
this.thread = document.createElement("div");
|
|
this.thread.className = "messages-thread";
|
|
this.thread.setAttribute("role", "log");
|
|
this.thread.setAttribute("aria-label", "Message transcript");
|
|
this.thread.setAttribute("aria-live", "polite");
|
|
this.thread.setAttribute("aria-relevant", "additions");
|
|
|
|
this.typingEl = document.createElement("div");
|
|
this.typingEl.className = "typing-indicator";
|
|
this.typingEl.id = "typing-indicator";
|
|
this.typingEl.hidden = true;
|
|
this.typingEl.append(document.createElement("span"), document.createElement("span"), document.createElement("span"));
|
|
this.thread.appendChild(this.typingEl);
|
|
|
|
this.form = document.createElement("form");
|
|
this.form.className = "messages-input-area";
|
|
this.form.setAttribute("method", "POST");
|
|
this.form.setAttribute("action", this.sendUrl);
|
|
this.form.setAttribute("data-live-form", "");
|
|
|
|
const receiverInput = document.createElement("input");
|
|
receiverInput.type = "hidden";
|
|
receiverInput.name = "receiver_uid";
|
|
receiverInput.value = this.withUid || "";
|
|
this.form.appendChild(receiverInput);
|
|
|
|
this.input = document.createElement("textarea");
|
|
this.input.name = "content";
|
|
this.input.rows = 1;
|
|
this.input.maxLength = 2000;
|
|
this.input.autocomplete = "off";
|
|
this.input.placeholder = "Type a message...";
|
|
this.input.setAttribute("aria-label", "Type a message");
|
|
|
|
this.upload = document.createElement("dp-upload");
|
|
this.upload.setAttribute("multiple", "");
|
|
this.upload.setAttribute("max-files", String(this.maxAttachments));
|
|
|
|
this.sendBtn = document.createElement("button");
|
|
this.sendBtn.type = "submit";
|
|
this.sendBtn.className = "messages-send-btn";
|
|
this.sendBtn.setAttribute("aria-label", "Send");
|
|
const sendIcon = document.createElement("span");
|
|
sendIcon.className = "send-icon";
|
|
sendIcon.textContent = "➤";
|
|
const sendSpinner = document.createElement("span");
|
|
sendSpinner.className = "send-spinner";
|
|
sendSpinner.setAttribute("aria-hidden", "true");
|
|
this.sendBtn.append(sendIcon, sendSpinner);
|
|
|
|
this.form.append(this.input, this.upload, this.sendBtn);
|
|
this.main.append(header, this.thread, this.form);
|
|
this.appendChild(this.main);
|
|
}
|
|
|
|
_capAttachments() {
|
|
if (!this.upload) return;
|
|
const capped = this.maxAttachments;
|
|
const current = parseInt(this.upload.getAttribute("max-files") || "", 10);
|
|
if (!Number.isFinite(current) || current > capped) {
|
|
this.upload.setAttribute("max-files", String(capped));
|
|
}
|
|
}
|
|
|
|
_bubbleMeta(bubble) {
|
|
const timeNode = bubble.querySelector(".message-time[datetime], .message-time time[datetime]");
|
|
const createdAt = timeNode ? timeNode.getAttribute("datetime") : null;
|
|
const senderUid = bubble.classList.contains("mine") ? this.selfUid : this.withUid;
|
|
return { senderUid, createdAt };
|
|
}
|
|
|
|
_lastBubbleMeta() {
|
|
if (!this.thread) return null;
|
|
const bubbles = this.thread.querySelectorAll(".message-bubble");
|
|
if (!bubbles.length) return null;
|
|
return this._bubbleMeta(bubbles[bubbles.length - 1]);
|
|
}
|
|
|
|
_previousMetaBefore(bubble) {
|
|
let sibling = bubble.previousElementSibling;
|
|
while (sibling && !sibling.classList.contains("message-bubble")) {
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
return sibling ? this._bubbleMeta(sibling) : null;
|
|
}
|
|
|
|
_initComposer() {
|
|
if (!this.form || !this.input) return;
|
|
this.form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
this._sendViaSocket();
|
|
});
|
|
if (this.upload) {
|
|
this.upload.addEventListener("dp-upload:busy", (event) => {
|
|
this._uploading = !!(event.detail && event.detail.busy);
|
|
this._refreshSendButton();
|
|
});
|
|
}
|
|
this.input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
this.form.requestSubmit();
|
|
}
|
|
});
|
|
this.input.addEventListener("input", () => {
|
|
if (!this._supportsFieldSizing) this._scheduleAutoGrow();
|
|
this._sendTyping();
|
|
});
|
|
}
|
|
|
|
_scheduleAutoGrow() {
|
|
if (this._autoGrowFrame) return;
|
|
this._autoGrowFrame = requestAnimationFrame(() => {
|
|
this._autoGrowFrame = null;
|
|
this.input.style.height = "auto";
|
|
const next = Math.min(this.input.scrollHeight, AUTO_GROW_MAX_HEIGHT_PX);
|
|
if (next !== this._lastAutoGrowHeight) {
|
|
this.input.style.height = `${next}px`;
|
|
this._lastAutoGrowHeight = next;
|
|
}
|
|
});
|
|
}
|
|
|
|
_resetAutoGrow() {
|
|
if (this._supportsFieldSizing) return;
|
|
cancelAnimationFrame(this._autoGrowFrame);
|
|
this._autoGrowFrame = null;
|
|
this.input.style.height = "auto";
|
|
this._lastAutoGrowHeight = null;
|
|
}
|
|
|
|
_initRetry() {
|
|
if (!this.thread) return;
|
|
this.thread.addEventListener("click", (event) => {
|
|
if (event.target.closest(".content-copy-btn")) return;
|
|
const bubble = event.target.closest(".message-bubble.failed");
|
|
if (!bubble) return;
|
|
const clientId = bubble.dataset.clientId;
|
|
const stored = clientId ? this._failedSends.get(clientId) : null;
|
|
if (!stored || !this._socketReady || !this.socket || !this.socket.isOpen()) return;
|
|
this._failedSends.delete(clientId);
|
|
this._sendViaSocket(clientId, stored.content, stored.attachmentUids);
|
|
});
|
|
}
|
|
|
|
_sendTyping() {
|
|
if (!this._socketReady || !this.withUid || !this.socket) return;
|
|
const now = Date.now();
|
|
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
|
|
this._lastTypingSent = now;
|
|
this.socket.send({ type: "typing", receiver_uid: this.withUid });
|
|
}
|
|
|
|
_refreshSendButton() {
|
|
if (!this.sendBtn) return;
|
|
const busy = this._uploading || this._pendingSends.size > 0;
|
|
this.sendBtn.disabled = busy;
|
|
this.sendBtn.classList.toggle("is-sending", busy);
|
|
}
|
|
|
|
_collectAttachments() {
|
|
const hidden = this.form.querySelector('input[name="attachment_uids"]');
|
|
if (!hidden || !hidden.value) return [];
|
|
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
}
|
|
|
|
_sendViaSocket(retryClientId, retryContent, retryAttachments) {
|
|
const content = retryClientId ? retryContent : (this.input.value || "").trim();
|
|
const attachmentUids = retryClientId ? retryAttachments : this._collectAttachments();
|
|
if (!content && attachmentUids.length === 0) return;
|
|
const clientId = retryClientId || `c${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
let bubble = retryClientId
|
|
? this.thread.querySelector(`.message-bubble[data-client-id="${clientId}"]`)
|
|
: null;
|
|
if (bubble) {
|
|
bubble.classList.remove("failed");
|
|
bubble.classList.add("pending");
|
|
const hint = bubble.querySelector(".retry-hint");
|
|
if (hint) hint.remove();
|
|
} else {
|
|
bubble = this._appendOptimisticBubble(content, clientId, attachmentUids.length);
|
|
}
|
|
|
|
const ok = !!(this.socket && this.socket.send({
|
|
type: "send",
|
|
receiver_uid: this.withUid,
|
|
content,
|
|
attachment_uids: attachmentUids,
|
|
client_id: clientId,
|
|
}));
|
|
|
|
const timeoutId = window.setTimeout(() => this._failPendingSend(clientId), SEND_TIMEOUT_MS);
|
|
this._pendingSends.set(clientId, { content, attachmentUids, timeoutId });
|
|
this._refreshSendButton();
|
|
|
|
if (!retryClientId) {
|
|
this.input.value = "";
|
|
this._resetAutoGrow();
|
|
if (this.upload && typeof this.upload.clear === "function") this.upload.clear();
|
|
this.input.focus({ preventScroll: true });
|
|
}
|
|
|
|
if (!ok) this._sendViaHttp(clientId, content, attachmentUids);
|
|
}
|
|
|
|
async _sendViaHttp(clientId, content, attachmentUids) {
|
|
try {
|
|
const response = await Http.sendForm(this.sendUrl, {
|
|
receiver_uid: this.withUid,
|
|
content,
|
|
attachment_uids: attachmentUids.join(","),
|
|
client_id: clientId,
|
|
}, { silent: true });
|
|
if (response && response.data) this._handleIncoming(response.data);
|
|
} catch {
|
|
this._failPendingSend(clientId);
|
|
}
|
|
}
|
|
|
|
_appendOptimisticBubble(content, clientId, attachmentCount) {
|
|
const bubble = this._buildBubble({ content, mine: true, clientId, iso: null, attachmentCount });
|
|
bubble.classList.add("pending");
|
|
this._insertBubble(bubble);
|
|
return bubble;
|
|
}
|
|
|
|
_failPendingSend(clientId) {
|
|
const entry = this._pendingSends.get(clientId);
|
|
if (!entry) return;
|
|
clearTimeout(entry.timeoutId);
|
|
this._pendingSends.delete(clientId);
|
|
this._refreshSendButton();
|
|
const bubble = this.thread ? this.thread.querySelector(`.message-bubble[data-client-id="${clientId}"]`) : null;
|
|
if (bubble && !bubble.dataset.msgUid) {
|
|
bubble.classList.remove("pending");
|
|
bubble.classList.add("failed");
|
|
this._failedSends.set(clientId, { content: entry.content, attachmentUids: entry.attachmentUids });
|
|
if (!bubble.querySelector(".retry-hint")) {
|
|
const hint = document.createElement("span");
|
|
hint.className = "retry-hint";
|
|
hint.textContent = "Tap to retry";
|
|
bubble.appendChild(hint);
|
|
}
|
|
}
|
|
}
|
|
|
|
_clearPendingSend(clientId) {
|
|
const entry = clientId ? this._pendingSends.get(clientId) : null;
|
|
if (!entry) return;
|
|
clearTimeout(entry.timeoutId);
|
|
this._pendingSends.delete(clientId);
|
|
this._refreshSendButton();
|
|
}
|
|
|
|
_buildBubble({ content, mine, clientId, uid, iso, senderRole, attachments, attachmentCount }) {
|
|
const bubble = document.createElement("div");
|
|
bubble.className = `message-bubble ${mine ? "mine" : "theirs"}`;
|
|
bubble.setAttribute("tabindex", "0");
|
|
if (uid) bubble.dataset.msgUid = uid;
|
|
if (clientId) bubble.dataset.clientId = clientId;
|
|
|
|
const el = document.createElement("dp-content");
|
|
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
|
el.textContent = content || "";
|
|
bubble.appendChild(el);
|
|
|
|
const gallery = this._renderAttachments(attachments);
|
|
if (gallery) {
|
|
bubble.appendChild(gallery);
|
|
} else if (attachmentCount > 0) {
|
|
const placeholder = document.createElement("div");
|
|
placeholder.className = "attachment-pending";
|
|
placeholder.textContent = attachmentCount === 1
|
|
? "Uploading attachment..."
|
|
: `Uploading ${attachmentCount} attachments...`;
|
|
bubble.appendChild(placeholder);
|
|
}
|
|
|
|
const timeEl = document.createElement(iso ? "time" : "span");
|
|
timeEl.className = "message-time";
|
|
if (iso) {
|
|
timeEl.setAttribute("datetime", iso);
|
|
timeEl.dataset.dt = "";
|
|
timeEl.dataset.dtMode = "ago";
|
|
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
|
|
} else {
|
|
timeEl.textContent = "sending...";
|
|
}
|
|
bubble.appendChild(timeEl);
|
|
|
|
if (mine) {
|
|
const receipt = document.createElement("span");
|
|
receipt.className = "message-receipt";
|
|
receipt.hidden = true;
|
|
receipt.innerHTML = "✓✓";
|
|
bubble.appendChild(receipt);
|
|
}
|
|
|
|
return bubble;
|
|
}
|
|
|
|
_renderAttachments(attachments) {
|
|
if (!attachments || !attachments.length) return null;
|
|
const gallery = document.createElement("div");
|
|
gallery.className = "attachment-gallery";
|
|
for (const att of attachments) {
|
|
const item = document.createElement("div");
|
|
item.className = "attachment-gallery-item";
|
|
if (att.is_image) {
|
|
const img = document.createElement("img");
|
|
img.src = att.thumbnail_url || att.url;
|
|
img.alt = att.original_filename || "";
|
|
img.loading = "lazy";
|
|
img.className = "gallery-thumb";
|
|
img.dataset.lightbox = "";
|
|
img.dataset.full = att.url;
|
|
if (att.mime_type) img.dataset.mime = att.mime_type;
|
|
item.appendChild(img);
|
|
} else if (att.is_video) {
|
|
const video = document.createElement("video");
|
|
video.src = att.url;
|
|
video.controls = true;
|
|
video.preload = "metadata";
|
|
video.className = "gallery-video";
|
|
item.appendChild(video);
|
|
} else if (att.is_audio) {
|
|
const audio = document.createElement("audio");
|
|
audio.src = att.url;
|
|
audio.controls = true;
|
|
audio.preload = "metadata";
|
|
audio.className = "gallery-audio";
|
|
item.appendChild(audio);
|
|
} else {
|
|
const link = document.createElement("a");
|
|
link.href = att.url;
|
|
link.target = "_blank";
|
|
link.rel = "noopener";
|
|
link.className = "non-image";
|
|
link.download = att.original_filename || "file";
|
|
link.textContent = att.original_filename || "file";
|
|
item.appendChild(link);
|
|
}
|
|
gallery.appendChild(item);
|
|
}
|
|
return gallery;
|
|
}
|
|
|
|
_insertBubble(bubble) {
|
|
if (!this.thread) return;
|
|
const previous = this._lastBubbleMeta();
|
|
const current = this._bubbleMeta(bubble);
|
|
bubble.classList.toggle("grouped", shouldGroup(previous, current));
|
|
if (this.typingEl && this.typingEl.parentElement === this.thread) {
|
|
this.thread.insertBefore(bubble, this.typingEl);
|
|
} else {
|
|
this.thread.appendChild(bubble);
|
|
}
|
|
this._scrollThreadToEnd();
|
|
}
|
|
|
|
async _connect() {
|
|
const url = await this._resolveWsUrl();
|
|
if (!url) return;
|
|
this.socket = new ChatSocket(url, {
|
|
onReady: (payload) => this.onReady(payload),
|
|
onMessage: (frame) => this.onFrame(frame),
|
|
onClose: () => {
|
|
this._socketReady = false;
|
|
},
|
|
});
|
|
this.socket.connect();
|
|
}
|
|
|
|
_wsBaseUrl() {
|
|
if (/^wss?:\/\//i.test(this.wsPath)) return this.wsPath;
|
|
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const path = this.wsPath.startsWith("/") ? this.wsPath : `/${this.wsPath}`;
|
|
return `${scheme}//${location.host}${path}`;
|
|
}
|
|
|
|
async _resolveWsUrl() {
|
|
const base = this._wsBaseUrl();
|
|
if (this.mode !== "embed" || !this.apiKey || !this.wsTicketUrl) {
|
|
return base;
|
|
}
|
|
try {
|
|
const response = await fetch(this.wsTicketUrl, {
|
|
method: "POST",
|
|
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
|
});
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
if (!data || !data.ticket) return null;
|
|
const separator = base.includes("?") ? "&" : "?";
|
|
return `${base}${separator}ticket=${encodeURIComponent(data.ticket)}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
onReady(payload) {
|
|
this._socketReady = true;
|
|
if (payload && payload.user_uid) this.selfUid = payload.user_uid;
|
|
this._markRead();
|
|
}
|
|
|
|
onFrame(frame) {
|
|
switch (frame.type) {
|
|
case "message":
|
|
this._handleIncoming(frame);
|
|
break;
|
|
case "typing":
|
|
if (frame.from_uid === this.withUid) this._showTyping();
|
|
break;
|
|
case "read":
|
|
if (frame.by_uid === this.withUid) this._markReceipts();
|
|
break;
|
|
case "error":
|
|
this._handleError(frame);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
_handleError(frame) {
|
|
if (frame.client_id) this._failPendingSend(frame.client_id);
|
|
if (window.app && window.app.toast) {
|
|
window.app.toast.show(frame.text || "Message not sent.", { type: "error" });
|
|
}
|
|
}
|
|
|
|
_handleIncoming(frame) {
|
|
if (frame.sender_uid === this.selfUid && frame.client_id) {
|
|
const pending = this.thread
|
|
? this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`)
|
|
: null;
|
|
if (pending && !pending.dataset.msgUid) {
|
|
this._reconcilePending(pending, frame);
|
|
return;
|
|
}
|
|
}
|
|
if (frame.uid && this.thread) {
|
|
const existing = this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`);
|
|
if (existing) {
|
|
this._clearPendingSend(frame.client_id);
|
|
const changed = this._applyFrameContent(existing, frame);
|
|
if (changed) this._bumpConversation(frame, frame.sender_uid === this.selfUid);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
|
|
const inThisThread = this.withUid && partnerUid === this.withUid;
|
|
if (inThisThread) {
|
|
const mine = frame.sender_uid === this.selfUid;
|
|
const bubble = this._buildBubble({
|
|
content: frame.content,
|
|
mine,
|
|
iso: frame.created_at,
|
|
uid: frame.uid,
|
|
senderRole: frame.sender_role,
|
|
attachments: frame.attachments,
|
|
});
|
|
this._insertBubble(bubble);
|
|
if (!mine && this._socketReady) {
|
|
this.socket.send({ type: "read", with_uid: this.withUid });
|
|
}
|
|
}
|
|
this._bumpConversation(frame, frame.sender_uid === this.selfUid);
|
|
}
|
|
|
|
_applyFrameContent(bubble, frame) {
|
|
const oldBody = bubble.querySelector("dp-content");
|
|
const previousContent = oldBody ? oldBody.textContent : "";
|
|
let changed = false;
|
|
if (oldBody && frame.content !== undefined && frame.content !== previousContent) {
|
|
const content = document.createElement("dp-content");
|
|
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
|
content.textContent = frame.content || "";
|
|
oldBody.replaceWith(content);
|
|
changed = true;
|
|
}
|
|
|
|
const placeholder = bubble.querySelector(".attachment-pending");
|
|
if (placeholder) placeholder.remove();
|
|
if (!bubble.querySelector(".attachment-gallery")) {
|
|
const gallery = this._renderAttachments(frame.attachments);
|
|
if (gallery) {
|
|
const time = bubble.querySelector(".message-time");
|
|
bubble.insertBefore(gallery, time || null);
|
|
}
|
|
}
|
|
|
|
if (this.aiIndicatorEnabled && frame.ai_processed && changed) {
|
|
this._showAiIndicator(bubble);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
_reconcilePending(bubble, frame) {
|
|
this._clearPendingSend(frame.client_id);
|
|
this._failedSends.delete(frame.client_id);
|
|
bubble.classList.remove("pending", "failed");
|
|
bubble.dataset.msgUid = frame.uid;
|
|
const hint = bubble.querySelector(".retry-hint");
|
|
if (hint) hint.remove();
|
|
|
|
this._applyFrameContent(bubble, frame);
|
|
|
|
const time = bubble.querySelector(".message-time");
|
|
if (time && frame.created_at) {
|
|
time.setAttribute("datetime", frame.created_at);
|
|
time.dataset.dt = "";
|
|
time.dataset.dtMode = "ago";
|
|
if (window.app && window.app.localTime) window.app.localTime.apply(time);
|
|
else time.textContent = frame.time_ago || "";
|
|
}
|
|
|
|
const previousMeta = this._previousMetaBefore(bubble);
|
|
bubble.classList.toggle("grouped", shouldGroup(previousMeta, this._bubbleMeta(bubble)));
|
|
|
|
this._bumpConversation(frame, true);
|
|
}
|
|
|
|
_showAiIndicator(bubble) {
|
|
const existing = bubble.querySelector(".ai-adjusted-note");
|
|
if (existing) existing.remove();
|
|
const note = document.createElement("div");
|
|
note.className = "ai-adjusted-note";
|
|
note.textContent = "Adjusted by AI";
|
|
bubble.appendChild(note);
|
|
window.setTimeout(() => {
|
|
note.classList.add("fading");
|
|
window.setTimeout(() => note.remove(), 400);
|
|
}, AI_INDICATOR_MS);
|
|
}
|
|
|
|
_markReceipts() {
|
|
if (!this.thread) return;
|
|
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
|
|
el.hidden = false;
|
|
});
|
|
}
|
|
|
|
_markRead() {
|
|
if (this._socketReady && this.withUid && this.socket) {
|
|
this.socket.send({ type: "read", with_uid: this.withUid });
|
|
}
|
|
}
|
|
|
|
_showTyping() {
|
|
if (!this.typingEl) return;
|
|
this.typingEl.hidden = false;
|
|
this._scrollThreadToEnd();
|
|
clearTimeout(this._typingHideTimer);
|
|
this._typingHideTimer = window.setTimeout(() => {
|
|
this.typingEl.hidden = true;
|
|
}, TYPING_HIDE_MS);
|
|
}
|
|
|
|
_bumpConversation(frame, mine) {
|
|
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
|
|
const item = this.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
|
|
if (!item) {
|
|
this._refreshConversations();
|
|
return;
|
|
}
|
|
const preview = item.querySelector(".conversation-preview");
|
|
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
|
|
const dot = item.querySelector(".conversation-unread-dot");
|
|
if (dot) dot.hidden = mine || partnerUid === this.withUid;
|
|
const list = item.parentElement;
|
|
if (list && list.firstElementChild !== item) {
|
|
list.insertBefore(item, list.firstElementChild);
|
|
}
|
|
}
|
|
|
|
async _refreshConversations() {
|
|
if (this.mode === "embed" || !this.list) return;
|
|
const container = this.list.querySelector(".messages-conversations");
|
|
if (!container) return;
|
|
try {
|
|
const data = await Http.getJson(this.conversationsUrl);
|
|
const conversations = data.conversations || [];
|
|
container.innerHTML = "";
|
|
if (!conversations.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "empty-state empty-state-borderless";
|
|
empty.textContent = "No conversations yet";
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
for (const conv of conversations) {
|
|
container.appendChild(this._buildConversationItem(conv));
|
|
}
|
|
} catch {
|
|
/* keep the current list on failure */
|
|
}
|
|
}
|
|
|
|
_buildConversationItem(conv) {
|
|
const user = conv.other_user || {};
|
|
const item = document.createElement("a");
|
|
item.href = `/messages?with_uid=${user.uid || ""}`;
|
|
item.className = `conversation-item${user.uid && user.uid === this.withUid ? " active" : ""}`;
|
|
item.dataset.convUid = user.uid || "";
|
|
item.setAttribute("role", "listitem");
|
|
if (user.uid && user.uid === this.withUid) item.setAttribute("aria-current", "true");
|
|
|
|
const badge = document.createElement("span");
|
|
badge.className = "avatar-badge";
|
|
const img = Avatar.imgElement(user.avatar_seed || user.username || "", 32);
|
|
img.alt = user.username || "";
|
|
badge.appendChild(img);
|
|
const dot = document.createElement("span");
|
|
dot.className = "presence-dot";
|
|
dot.setAttribute("data-presence-uid", user.uid || "");
|
|
dot.setAttribute("data-presence-last-seen", user.last_seen || "");
|
|
dot.setAttribute("aria-hidden", "true");
|
|
badge.appendChild(dot);
|
|
item.appendChild(badge);
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "conversation-info";
|
|
const name = document.createElement("div");
|
|
name.className = "conversation-name";
|
|
name.textContent = user.username || "";
|
|
const preview = document.createElement("div");
|
|
preview.className = "conversation-preview";
|
|
preview.textContent = contentRenderer.preview(conv.last_message, 60);
|
|
info.append(name, preview);
|
|
item.appendChild(info);
|
|
|
|
const unread = document.createElement("span");
|
|
unread.className = "conversation-unread-dot";
|
|
unread.hidden = !conv.unread;
|
|
const sr = document.createElement("span");
|
|
sr.className = "sr-only";
|
|
sr.textContent = "Unread messages";
|
|
unread.appendChild(sr);
|
|
item.appendChild(unread);
|
|
|
|
const time = document.createElement("span");
|
|
time.className = "conversation-time";
|
|
if (conv.last_message_at) {
|
|
time.setAttribute("datetime", conv.last_message_at);
|
|
time.dataset.dt = "";
|
|
time.dataset.dtMode = "ago";
|
|
if (window.app && window.app.localTime) window.app.localTime.apply(time);
|
|
}
|
|
item.appendChild(time);
|
|
|
|
item.addEventListener("click", () => {
|
|
if (!this._isMobile()) return;
|
|
this._activePane = "thread";
|
|
this._applyPane();
|
|
});
|
|
|
|
return item;
|
|
}
|
|
|
|
_scrollThreadToEnd() {
|
|
if (!this.thread || !this._userAtBottom) return;
|
|
this.thread.scrollTop = this.thread.scrollHeight;
|
|
}
|
|
|
|
_startScrollWatcher() {
|
|
if (!this.thread) return;
|
|
const onAnyChange = () => {
|
|
if (!this._userAtBottom || this._stabilizePending) return;
|
|
this._stabilizePending = true;
|
|
requestAnimationFrame(() => {
|
|
this._stabilizePending = false;
|
|
this._stabilizeScroll();
|
|
});
|
|
};
|
|
this._scrollWatcher = new MutationObserver(onAnyChange);
|
|
this._scrollWatcher.observe(this.thread, { childList: true, subtree: true });
|
|
this.thread.addEventListener("load", (event) => {
|
|
if (event.target.tagName === "IMG" && this._userAtBottom) onAnyChange();
|
|
}, true);
|
|
onAnyChange();
|
|
}
|
|
|
|
_stabilizeScroll() {
|
|
if (!this.thread || !this._userAtBottom) {
|
|
this._stabilizeFrames = 0;
|
|
return;
|
|
}
|
|
if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
|
|
this._stabilizeFrames = 0;
|
|
return;
|
|
}
|
|
this._stabilizeFrames++;
|
|
this.thread.scrollTop = this.thread.scrollHeight;
|
|
const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < 10;
|
|
if (!atBottom) {
|
|
this._stabilizePending = true;
|
|
requestAnimationFrame(() => {
|
|
this._stabilizePending = false;
|
|
this._stabilizeScroll();
|
|
});
|
|
} else {
|
|
this._stabilizeFrames = 0;
|
|
}
|
|
}
|
|
|
|
_bindAutoScroll() {
|
|
if (!this.thread) return;
|
|
this.thread.addEventListener("scroll", () => {
|
|
if (this._stabilizePending) return;
|
|
const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
|
this._userAtBottom = atBottom;
|
|
if (atBottom) this._stabilizeScroll();
|
|
}, { passive: true });
|
|
}
|
|
|
|
_bindViewport() {
|
|
if (!this.input || !this.form) return;
|
|
const viewport = window.visualViewport;
|
|
|
|
const ensureInputVisible = () => {
|
|
const vh = viewport ? viewport.height : window.innerHeight;
|
|
const formRect = this.form.getBoundingClientRect();
|
|
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset")) || 0;
|
|
const delta = formRect.bottom - vh;
|
|
const newInset = Math.max(0, currentInset + delta);
|
|
if (newInset !== currentInset) {
|
|
this.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
|
this._userAtBottom = true;
|
|
this._scrollThreadToEnd();
|
|
}
|
|
};
|
|
|
|
if (viewport) {
|
|
let insetTimer = null;
|
|
const onViewportChange = () => {
|
|
cancelAnimationFrame(insetTimer);
|
|
insetTimer = requestAnimationFrame(ensureInputVisible);
|
|
};
|
|
viewport.addEventListener("resize", onViewportChange);
|
|
viewport.addEventListener("scroll", onViewportChange);
|
|
this._viewportListenerCleanup = () => {
|
|
viewport.removeEventListener("resize", onViewportChange);
|
|
viewport.removeEventListener("scroll", onViewportChange);
|
|
};
|
|
}
|
|
|
|
this._resizeObserver = new ResizeObserver(() => ensureInputVisible());
|
|
this._resizeObserver.observe(this);
|
|
|
|
const io = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) ensureInputVisible();
|
|
}
|
|
}, { root: null, threshold: [0, 0.5, 1] });
|
|
io.observe(this.form);
|
|
this._viewportIO = io;
|
|
|
|
this.input.addEventListener("focus", () => {
|
|
this._userAtBottom = true;
|
|
const doScroll = () => {
|
|
this._scrollThreadToEnd();
|
|
if (window.innerWidth < MOBILE_BREAKPOINT_PX) {
|
|
const retry = (delay) => window.setTimeout(() => {
|
|
ensureInputVisible();
|
|
this._scrollThreadToEnd();
|
|
}, delay);
|
|
retry(100);
|
|
retry(350);
|
|
retry(600);
|
|
}
|
|
};
|
|
requestAnimationFrame(doScroll);
|
|
});
|
|
|
|
this.input.addEventListener("blur", () => {
|
|
this.style.setProperty("--kb-inset", "0px");
|
|
});
|
|
|
|
ensureInputVisible();
|
|
}
|
|
|
|
_isMobile() {
|
|
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
|
}
|
|
|
|
_applyPane() {
|
|
if (!this._isMobile() || !this.list || !this.main) return;
|
|
this.list.classList.toggle("hide", this._activePane !== "list");
|
|
this.main.classList.toggle("hide", this._activePane !== "thread");
|
|
}
|
|
|
|
_initMobilePane() {
|
|
if (this.mode === "embed" || !this.list || !this.main) return;
|
|
this._activePane = this.withUid ? "thread" : "list";
|
|
this._applyPane();
|
|
|
|
if (this.backBtn) {
|
|
this.backBtn.addEventListener("click", () => {
|
|
if (!this._isMobile()) return;
|
|
this._activePane = "list";
|
|
this._applyPane();
|
|
window.history.replaceState(null, "", "/messages");
|
|
});
|
|
}
|
|
|
|
this.list.querySelectorAll(".conversation-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
if (!this._isMobile()) return;
|
|
this._activePane = "thread";
|
|
this._applyPane();
|
|
});
|
|
});
|
|
|
|
this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
|
|
this._onMobileChange = () => {
|
|
if (!this._isMobile()) {
|
|
this.list.classList.remove("hide");
|
|
this.main.classList.remove("hide");
|
|
} else {
|
|
this._applyPane();
|
|
}
|
|
};
|
|
this._mobileQuery.addEventListener("change", this._onMobileChange);
|
|
}
|
|
|
|
_initSearch() {
|
|
if (this.mode === "embed" || !this.searchInput) return;
|
|
const wrap = this.searchInput.parentElement;
|
|
const dropdown = document.createElement("div");
|
|
dropdown.className = "search-dropdown";
|
|
dropdown.setAttribute("role", "listbox");
|
|
wrap.appendChild(dropdown);
|
|
|
|
const hide = () => {
|
|
DomUtils.hide(dropdown);
|
|
nav.closed();
|
|
};
|
|
|
|
const nav = new ListNav(this.searchInput, dropdown, {
|
|
itemSelector: ".search-dropdown-item",
|
|
activeClass: "active",
|
|
chooseOnTab: false,
|
|
isOpen: () => DomUtils.isShown(dropdown),
|
|
onChoose: (item) => {
|
|
window.location.href = item.href;
|
|
},
|
|
onEscape: hide,
|
|
});
|
|
|
|
let debounceTimer = null;
|
|
this.searchInput.addEventListener("input", () => {
|
|
clearTimeout(debounceTimer);
|
|
const q = this.searchInput.value.trim();
|
|
if (q.length < 1) {
|
|
dropdown.innerHTML = "";
|
|
hide();
|
|
return;
|
|
}
|
|
debounceTimer = window.setTimeout(async () => {
|
|
try {
|
|
const data = await Http.getJson(`${this.searchUrl}?q=${encodeURIComponent(q)}`);
|
|
const results = data.results || [];
|
|
if (!results.length) {
|
|
hide();
|
|
return;
|
|
}
|
|
dropdown.innerHTML = "";
|
|
for (const r of results) {
|
|
const item = document.createElement("a");
|
|
item.className = "search-dropdown-item";
|
|
item.href = `/messages?with_uid=${r.uid}`;
|
|
const label = document.createElement("span");
|
|
label.textContent = r.username;
|
|
item.append(Avatar.imgElement(r.username), label);
|
|
dropdown.appendChild(item);
|
|
}
|
|
DomUtils.show(dropdown);
|
|
nav.opened();
|
|
} catch {
|
|
hide();
|
|
}
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (!wrap.contains(event.target)) hide();
|
|
});
|
|
}
|
|
|
|
_initPresence() {
|
|
if (this.mode !== "embed" || !this.withUid) return;
|
|
this._presenceUnsubs = [];
|
|
try {
|
|
this._pubsub = new PubSubClient();
|
|
const unsub = this._pubsub.subscribe(`public.presence.${this.withUid}`, (data) => this._onPresence(data));
|
|
this._presenceUnsubs.push(unsub);
|
|
} catch {
|
|
this._pubsub = null;
|
|
}
|
|
}
|
|
|
|
_onPresence(data) {
|
|
if (!data || !this.presenceEl) return;
|
|
const online = !!data.online;
|
|
this.presenceEl.classList.toggle("online", online);
|
|
if (this.presenceEl.hasAttribute("data-presence-label")) {
|
|
this.presenceEl.textContent = online ? "online" : "offline";
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define("dp-chat", AppChat);
|