Files
devplacepy/devplacepy/static/js/components/AppChat.js
T
retoorandClaude Sonnet 5 afb4799869 Fix mobile chat composer glitches and add date separators/reconnect banner
Composer focus/blur no longer fights the on-screen keyboard: tapping
Send blurred the textarea first, which unconditionally zeroed the
visualViewport keyboard-inset compensation before the immediate
refocus could recompute it, so the composer could sit misplaced for
up to 600ms after every mobile send. Blur now only resets the inset
when focus is actually leaving the composer form, and focus
recomputes it immediately instead of waiting on the retry timers.
Emoji picker and @mention wiring, previously only ever applied once
at page load, are now re-run whenever AppChat rebuilds a composer for
a client-side conversation switch, so both survive tapping a
conversation from the list instead of only working after a full page
reload. Viewport meta gains interactive-widget=resizes-content for
native keyboard-aware layout. Chat scroll panes get
overscroll-behavior: contain plus momentum scrolling, matching the
pattern already used elsewhere in the app. The conversation-search
dropdown's literal z-index is replaced with the --z-popover token.

Adds sticky Today/Yesterday/date separators between message groups
(chat/DateSeparators.js, viewer-local-time day boundaries, normalized
on every thread mutation) and a debounced "Reconnecting..." banner
reflecting live WebSocket state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qsdit8UXhbUn9ZnfgjbXqt
2026-09-03 08:47:57 +02:00

1588 lines
62 KiB
JavaScript

// 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 { dateKey, dateLabel } from "../chat/DateSeparators.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";
import { PresenceManager } from "../PresenceManager.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 = 20000;
const MOBILE_BREAKPOINT_PX = 768;
const AI_INDICATOR_MS = 4000;
const SEARCH_DEBOUNCE_MS = 200;
const AUTO_GROW_MAX_HEIGHT_PX = 160;
const CONVERSATION_PAGE_SIZE = 500;
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._presence = 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;
this._hasOlder = false;
this._loadingOlder = false;
this._opening = false;
this._newCount = 0;
this._lastSyncAt = null;
this._coarseQuery = null;
this._connBanner = null;
this._disconnectTimer = 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._ensureConnectionBanner();
this._normalizeDateSeparators();
this._capAttachments();
this._initComposer();
this._initRetry();
this._initSearch();
this._initMobilePane();
this._bindHistory();
this._bindViewport();
this._bindAutoScroll();
this._startScrollWatcher();
this._ensureJumpButton();
this._connect();
this._initPresence();
this._scrollThreadToEnd();
this._hasOlder = !!(this.thread && this.thread.querySelectorAll(".message-bubble").length >= CONVERSATION_PAGE_SIZE);
if (this.input && !this._isCoarse()) 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);
clearTimeout(this._disconnectTimer);
cancelAnimationFrame(this._autoGrowFrame);
for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
this._pendingSends.clear();
this._failedSends.clear();
if (this._presence) this._presence.stop();
if (this._onPopState) window.removeEventListener("popstate", this._onPopState);
}
_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");
}
_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.setAttribute("data-presence-uid", this.withUid || "");
presence.textContent = "offline";
headerInfo.appendChild(presence);
header.appendChild(headerInfo);
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("paste", "");
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 && !this._isCoarse()) {
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.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);
if (/@ai\s+\S/i.test(content)) this._showAiPending(bubble);
}
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.dataset.source = content || "";
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);
} else if (uid) {
bubble.appendChild(this._reportButton(uid));
}
return bubble;
}
_reportButton(uid) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "message-report-btn report-btn";
btn.dataset.reportType = "message";
btn.dataset.reportUid = uid;
btn.setAttribute("aria-label", "Report this message");
btn.title = "Report";
const icon = document.createElement("span");
icon.className = "icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "\u{1F6A9}";
const label = document.createElement("span");
label.className = "label";
label.textContent = " Report";
btn.append(icon, label);
return btn;
}
_contentSource(el) {
if (!el) return "";
if (el.dataset && Object.prototype.hasOwnProperty.call(el.dataset, "source")) {
return el.dataset.source;
}
if (el._source != null) return el._source;
return el.textContent || "";
}
_renderAttachments(attachments) {
if (!attachments || !attachments.length) return null;
const single = attachments.length === 1;
const gallery = document.createElement("div");
gallery.className = `attachment-gallery${single ? " single" : ""}`;
for (const att of attachments) {
const item = document.createElement("div");
item.className = "attachment-gallery-item";
const filename = att.original_filename || att.filename || "file";
const mime = att.mime_type || "";
const isAudio = att.is_audio || mime.startsWith("audio/");
if (att.is_image) {
const img = document.createElement("img");
img.src = (single || !att.thumbnail_url) ? (att.url || att.thumbnail_url) : att.thumbnail_url;
img.alt = filename;
img.loading = "lazy";
img.className = "gallery-thumb";
img.dataset.lightbox = "";
img.dataset.full = att.url || att.thumbnail_url || "";
if (mime) img.dataset.mime = mime;
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 (isAudio) {
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 = filename;
link.textContent = filename;
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._normalizeDateSeparators();
this._scrollThreadToEnd();
}
_buildDateSeparator(iso) {
const sep = document.createElement("div");
sep.className = "date-separator";
const pill = document.createElement("span");
pill.textContent = dateLabel(iso);
sep.appendChild(pill);
return sep;
}
_normalizeDateSeparators() {
if (!this.thread) return;
this.thread.querySelectorAll(".date-separator").forEach((el) => el.remove());
let previousKey = null;
this.thread.querySelectorAll(".message-bubble").forEach((bubble) => {
const time = bubble.querySelector(".message-time[datetime], .message-time time[datetime]");
const iso = time ? time.getAttribute("datetime") : null;
const key = dateKey(iso);
if (key && key !== previousKey) {
this.thread.insertBefore(this._buildDateSeparator(iso), bubble);
previousKey = key;
}
});
}
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._noteDisconnected();
},
});
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._noteConnected();
this._markRead();
this._syncMissed();
}
_ensureConnectionBanner() {
if (!this.main) return;
if (!this._connBanner) {
const banner = document.createElement("div");
banner.className = "messages-connection-banner";
banner.hidden = true;
banner.textContent = "Reconnecting\u2026";
this._connBanner = banner;
}
if (this._connBanner.parentElement !== this.main || this.main.firstChild !== this._connBanner) {
this.main.insertBefore(this._connBanner, this.main.firstChild || null);
}
}
_noteDisconnected() {
if (this._disconnectTimer) return;
this._disconnectTimer = window.setTimeout(() => {
this._disconnectTimer = null;
if (!this._socketReady && this._connBanner) this._connBanner.hidden = false;
}, 900);
}
_noteConnected() {
clearTimeout(this._disconnectTimer);
this._disconnectTimer = null;
if (this._connBanner) this._connBanner.hidden = true;
}
_syncMissed() {
if (!this._socketReady || !this.socket) return;
const since = this._latestStamp() || this._lastSyncAt;
if (!since) {
this._lastSyncAt = new Date().toISOString();
return;
}
this.socket.send({
type: "sync",
since,
with_uid: this.withUid || "",
});
this._lastSyncAt = new Date().toISOString();
}
_latestStamp() {
if (!this.thread) return null;
const times = [...this.thread.querySelectorAll(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]")];
let latest = null;
for (const el of times) {
const value = el.getAttribute("datetime");
if (value && (!latest || value > latest)) latest = value;
}
return latest;
}
_threadIsActive() {
if (!this.withUid) return false;
if (!this._isMobile()) return true;
return this._activePane === "thread";
}
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,
});
if (frame.ai_pending) this._showAiPending(bubble);
this._insertBubble(bubble);
if (!mine && this._socketReady && this._threadIsActive()) {
this.socket.send({ type: "read", with_uid: this.withUid });
}
if (!mine && !this._userAtBottom) this._noteNewMessage();
}
this._bumpConversation(frame, frame.sender_uid === this.selfUid);
}
_applyFrameContent(bubble, frame) {
const oldBody = bubble.querySelector("dp-content");
const previousContent = this._contentSource(oldBody);
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.dataset.source = frame.content || "";
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 (frame.uid && !bubble.dataset.msgUid) bubble.dataset.msgUid = frame.uid;
if (frame.uid && !bubble.classList.contains("mine") && !bubble.querySelector(".message-report-btn")) {
bubble.appendChild(this._reportButton(frame.uid));
}
if (frame.ai_pending && !frame.ai_processed) {
this._showAiPending(bubble);
} else if (frame.ai_processed) {
this._clearAiPending(bubble);
if (this.aiIndicatorEnabled && 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();
if (frame.ai_pending) this._showAiPending(bubble);
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._normalizeDateSeparators();
this._bumpConversation(frame, true);
}
_showAiPending(bubble) {
this._clearAiPending(bubble);
const note = document.createElement("div");
note.className = "ai-adjusted-note ai-pending-note";
note.textContent = "Adjusting\u2026";
bubble.appendChild(note);
}
_clearAiPending(bubble) {
const pending = bubble.querySelector(".ai-pending-note");
if (pending) pending.remove();
}
_showAiIndicator(bubble) {
this._clearAiPending(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._threadIsActive()) {
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) || (frame.attachments && frame.attachments.length ? "Attachment" : "");
const dot = item.querySelector(".conversation-unread-dot");
if (dot) dot.hidden = mine || (partnerUid === this.withUid && this._threadIsActive());
const time = item.querySelector(".conversation-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);
}
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");
item.appendChild(Avatar.badgeElement(user));
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);
this._bindConversationClick(item);
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._newCount = 0;
this._updateJumpButton();
this._stabilizeScroll();
}
if (this.thread.scrollTop < 48) this._loadOlder();
}, { passive: true });
}
_bindViewport() {
if (!this.input || !this.form) return;
const viewport = window.visualViewport;
const ensureInputVisible = () => {
const visibleBottom = viewport
? viewport.offsetTop + viewport.height
: window.innerHeight;
const formRect = this.form.getBoundingClientRect();
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset"), 10) || 0;
const delta = formRect.bottom - visibleBottom;
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;
ensureInputVisible();
const doScroll = () => {
this._scrollThreadToEnd();
if (this._isMobile()) {
const retry = (delay) => window.setTimeout(() => {
ensureInputVisible();
this._scrollThreadToEnd();
}, delay);
retry(100);
retry(350);
retry(600);
}
};
requestAnimationFrame(doScroll);
});
this.input.addEventListener("blur", (event) => {
const next = event.relatedTarget;
if (next && this.form && this.form.contains(next)) return;
this.style.setProperty("--kb-inset", "0px");
});
ensureInputVisible();
}
_isMobile() {
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
_isCoarse() {
if (!this._coarseQuery) {
this._coarseQuery = window.matchMedia("(hover: none) and (pointer: coarse)");
}
return this._coarseQuery.matches;
}
_applyPane() {
if (!this.list || !this.main) return;
this.classList.toggle("show-list", this._isMobile() && this._activePane === "list");
if (!this._isMobile()) {
this.list.classList.remove("hide");
this.main.classList.remove("hide");
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._showList();
});
}
this.list.querySelectorAll(".conversation-item").forEach((item) => {
this._bindConversationClick(item);
});
this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
this._onMobileChange = () => {
this._applyPane();
};
this._mobileQuery.addEventListener("change", this._onMobileChange);
}
_showList() {
this._activePane = "list";
this._applyPane();
const url = "/messages";
if (window.location.pathname + window.location.search !== url) {
window.history.replaceState({ withUid: null }, "", url);
}
}
_bindConversationClick(item) {
if (item.dataset.chatBound) return;
item.dataset.chatBound = "1";
item.addEventListener("click", (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const uid = item.dataset.convUid;
if (!uid) return;
event.preventDefault();
this._openConversation(uid, true);
});
}
_bindHistory() {
if (this.mode === "embed" || this._onPopState) return;
this._onPopState = () => {
const params = new URLSearchParams(window.location.search);
const uid = params.get("with_uid");
if (uid) this._openConversation(uid, false);
else this._showList();
};
window.addEventListener("popstate", this._onPopState);
}
async _openConversation(uid, push) {
if (!uid || this._opening) return;
if (uid === this.withUid && this._activePane === "thread" && this.thread) {
this._activePane = "thread";
this._applyPane();
return;
}
if (uid === this.withUid && this.thread && this.thread.querySelector(".message-bubble, .messages-empty")) {
this._activePane = "thread";
this._applyPane();
this._markRead();
const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
if (push && window.location.pathname + window.location.search !== url) {
window.history.pushState({ withUid: uid }, "", url);
}
if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
return;
}
this._opening = true;
try {
const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(uid)}`);
this._renderConversation(uid, data);
const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
if (push) window.history.pushState({ withUid: uid }, "", url);
else window.history.replaceState({ withUid: uid }, "", url);
this._activePane = "thread";
this._applyPane();
this._markRead();
this._scrollThreadToEnd();
} catch {
window.location.href = `/messages?with_uid=${encodeURIComponent(uid)}`;
} finally {
this._opening = false;
}
}
_renderConversation(uid, data) {
this.withUid = uid;
this.setAttribute("with-uid", uid);
this._ensureMainShell(data.other_user || {});
const receiver = this.form ? this.form.querySelector('input[name="receiver_uid"]') : null;
if (receiver) receiver.value = uid;
this._fillHeader(data.other_user || {});
this._fillThread(data.messages || []);
this.querySelectorAll(".conversation-item").forEach((item) => {
const active = item.dataset.convUid === uid;
item.classList.toggle("active", active);
if (active) item.setAttribute("aria-current", "true");
else item.removeAttribute("aria-current");
const dot = item.querySelector(".conversation-unread-dot");
if (dot && active) dot.hidden = true;
});
this._newCount = 0;
this._updateJumpButton();
}
_ensureMainShell(otherUser) {
if (this.thread && this.form) return;
const empty = this.main ? this.main.querySelector(".messages-empty") : null;
if (empty) empty.remove();
if (!this.main) {
this._ensureSkeleton();
this._adopt();
this._initComposer();
this._ensureConnectionBanner();
return;
}
this.main.innerHTML = "";
this.main.appendChild(this._buildHeader(otherUser));
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 = this._buildComposer(otherUser.uid || this.withUid);
this.main.append(this.thread, this.form);
this._adopt();
this._initComposer();
this._wireContentEnhancer();
this._bindAutoScroll();
this._startScrollWatcher();
this._ensureJumpButton();
this._capAttachments();
this._ensureConnectionBanner();
}
_buildHeader(user) {
const header = document.createElement("div");
header.className = "messages-main-header";
const back = document.createElement("button");
back.type = "button";
back.className = "messages-back-btn";
back.id = "messages-back-btn";
back.setAttribute("aria-label", "Back to conversations");
back.innerHTML = "&#x2190;";
back.addEventListener("click", () => {
if (!this._isMobile()) return;
this._showList();
});
this.backBtn = back;
header.appendChild(back);
const info = document.createElement("div");
info.className = "messages-header-info";
const title = document.createElement("h3");
const link = document.createElement("a");
link.href = `/profile/${encodeURIComponent(user.username || "")}`;
link.textContent = user.username || "";
title.appendChild(link);
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.setAttribute("data-presence-uid", user.uid || "");
presence.setAttribute("data-presence-last-seen", user.last_seen || "");
presence.textContent = "offline";
info.append(title, presence);
header.appendChild(info);
return header;
}
_fillHeader(user) {
const header = this.querySelector(".messages-main-header");
if (!header) return;
const avatarHost = header.querySelector(".user-avatar-link, .avatar-badge");
if (avatarHost && user.uid) {
const badge = Avatar.badgeElement(user);
const link = document.createElement("a");
link.href = `/profile/${encodeURIComponent(user.username || "")}`;
link.className = "user-avatar-link";
link.appendChild(badge);
avatarHost.replaceWith(link);
}
const nameLink = header.querySelector(".messages-header-info h3 a, .messages-header-info a");
if (nameLink) {
nameLink.href = `/profile/${encodeURIComponent(user.username || "")}`;
nameLink.textContent = user.username || "";
}
const presence = header.querySelector("#messages-presence, .messages-presence");
if (presence) {
presence.setAttribute("data-presence-uid", user.uid || "");
presence.setAttribute("data-presence-last-seen", user.last_seen || "");
}
}
_wireContentEnhancer() {
if (!window.app || !window.app.content) return;
window.app.content.initEmojiPickers();
window.app.content.initMentionInputs();
}
_buildComposer(receiverUid) {
const form = document.createElement("form");
form.className = "messages-input-area";
form.setAttribute("method", "POST");
form.setAttribute("action", this.sendUrl);
form.setAttribute("data-live-form", "");
const receiverInput = document.createElement("input");
receiverInput.type = "hidden";
receiverInput.name = "receiver_uid";
receiverInput.value = receiverUid || "";
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.input.className = "emoji-picker-target";
this.input.setAttribute("data-mention", "");
this.upload = document.createElement("dp-upload");
this.upload.setAttribute("multiple", "");
this.upload.setAttribute("paste", "");
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 = "\u27A4";
const sendSpinner = document.createElement("span");
sendSpinner.className = "send-spinner";
sendSpinner.setAttribute("aria-hidden", "true");
this.sendBtn.append(sendIcon, sendSpinner);
form.append(receiverInput, this.input, this.upload, this.sendBtn);
return form;
}
_fillThread(items) {
if (!this.thread) return;
this.thread.querySelectorAll(".message-bubble").forEach((el) => el.remove());
for (const item of items) {
const message = item.message || item;
const bubble = this._buildBubble({
content: message.content || "",
mine: !!item.is_mine,
iso: message.created_at,
uid: message.uid,
senderRole: (item.sender && item.sender.role) || "",
attachments: item.attachments || [],
});
if (item.grouped) bubble.classList.add("grouped");
if (item.is_mine && message.read) {
const receipt = bubble.querySelector(".message-receipt");
if (receipt) receipt.hidden = false;
}
if (this.typingEl && this.typingEl.parentElement === this.thread) {
this.thread.insertBefore(bubble, this.typingEl);
} else {
this.thread.appendChild(bubble);
}
}
this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
this._userAtBottom = true;
this._normalizeDateSeparators();
}
_ensureJumpButton() {
if (!this.main || this._jumpBtn) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "messages-jump-new";
btn.hidden = true;
btn.addEventListener("click", () => {
this._userAtBottom = true;
this._newCount = 0;
this._updateJumpButton();
this._scrollThreadToEnd();
});
this.main.appendChild(btn);
this._jumpBtn = btn;
this._updateJumpButton();
}
_noteNewMessage() {
this._newCount += 1;
this._updateJumpButton();
}
_updateJumpButton() {
if (!this._jumpBtn) return;
if (!this._newCount) {
this._jumpBtn.hidden = true;
return;
}
this._jumpBtn.hidden = false;
this._jumpBtn.textContent = this._newCount === 1 ? "New message" : `${this._newCount} new messages`;
}
async _loadOlder() {
if (!this.thread || !this.withUid || this._loadingOlder || !this._hasOlder) return;
const oldest = this.thread.querySelector(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]");
const before = oldest ? oldest.getAttribute("datetime") : "";
if (!before) {
this._hasOlder = false;
return;
}
this._loadingOlder = true;
const previousHeight = this.thread.scrollHeight;
const previousTop = this.thread.scrollTop;
try {
const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(this.withUid)}&before=${encodeURIComponent(before)}`);
const items = data.messages || [];
if (!items.length) {
this._hasOlder = false;
return;
}
const first = this.thread.querySelector(".message-bubble");
const fragment = document.createDocumentFragment();
for (const item of items) {
const message = item.message || item;
if (message.uid && this.thread.querySelector(`.message-bubble[data-msg-uid="${message.uid}"]`)) continue;
const bubble = this._buildBubble({
content: message.content || "",
mine: !!item.is_mine,
iso: message.created_at,
uid: message.uid,
senderRole: (item.sender && item.sender.role) || "",
attachments: item.attachments || [],
});
if (item.grouped) bubble.classList.add("grouped");
fragment.appendChild(bubble);
}
if (fragment.childNodes.length) {
this.thread.insertBefore(fragment, first || this.typingEl || null);
}
this._normalizeDateSeparators();
this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
this.thread.scrollTop = previousTop + (this.thread.scrollHeight - previousHeight);
} catch {
this._hasOlder = false;
} finally {
this._loadingOlder = false;
}
}
_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) => {
hide();
const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
if (uid) this._openConversation(uid, true);
else 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);
});
dropdown.addEventListener("click", (event) => {
const item = event.target.closest(".search-dropdown-item");
if (!item) return;
event.preventDefault();
const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
hide();
if (uid) this._openConversation(uid, true);
});
document.addEventListener("click", (event) => {
if (!wrap.contains(event.target)) hide();
});
}
_initPresence() {
if (this.mode !== "embed") return;
if (window.app && window.app.presence) return;
this._presence = new PresenceManager(new PubSubClient(), this);
}
}
customElements.define("dp-chat", AppChat);