Files
devplacepy/devplacepy/static/js/components/AppChat.js
T
blindxfishandClaude Opus 5 076f55f380 Attach images pasted into the composer, comments and chat
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.

The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.

It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.

Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:23:25 +02:00

1057 lines
40 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 { 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 = 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._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;
}
_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._presence) this._presence.stop();
}
_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) {
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");
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);
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") return;
if (window.app && window.app.presence) return;
this._presence = new PresenceManager(new PubSubClient(), this);
}
}
customElements.define("dp-chat", AppChat);