// 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 = "&#x2713;&#x2713;";
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);