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
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent 3ca9285646
commit afb4799869
4 changed files with 691 additions and 64 deletions
+89 -16
View File
@@ -69,7 +69,7 @@ dp-chat[mode="embed"] {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
z-index: 100;
z-index: var(--z-popover);
max-height: 240px;
overflow-y: auto;
}
@@ -93,6 +93,8 @@ dp-chat[mode="embed"] {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -148,6 +150,42 @@ dp-chat[mode="embed"] {
flex-direction: column;
min-width: 0;
min-height: 0;
position: relative;
}
.messages-jump-new {
position: absolute;
left: 50%;
bottom: 72px;
transform: translateX(-50%);
z-index: var(--z-popover);
padding: var(--space-xs) var(--space-md);
border: none;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.messages-jump-new[hidden] {
display: none;
}
.messages-connection-banner {
flex-shrink: 0;
padding: var(--space-xs) var(--space-lg);
background: var(--warning);
color: var(--white);
font-size: 0.75rem;
font-weight: 600;
text-align: center;
}
.messages-connection-banner[hidden] {
display: none;
}
.messages-main-header {
@@ -206,6 +244,8 @@ dp-chat[mode="embed"] {
.messages-thread {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: var(--space-lg);
padding-bottom: var(--space-sm);
display: flex;
@@ -322,6 +362,25 @@ dp-chat[mode="embed"] {
color: var(--text-secondary);
}
.date-separator {
position: sticky;
top: 0;
z-index: var(--z-popover);
display: flex;
justify-content: center;
pointer-events: none;
}
.date-separator span {
background: var(--bg-card-hover);
color: var(--text-muted);
font-size: 0.6875rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
box-shadow: var(--shadow-sm);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
@@ -376,6 +435,16 @@ dp-chat[mode="embed"] {
background: var(--bg-card);
}
.messages-input-area .mention-wrapper {
flex: 1;
min-width: 0;
width: auto;
}
.messages-input-area .mention-wrapper textarea {
width: 100%;
}
.messages-input-area textarea {
flex: 1;
min-width: 0;
@@ -507,13 +576,19 @@ dp-chat[mode="embed"] {
/* ── Mobile (<= 768px) ─────────────────────────────────── */
@media (max-width: 768px) {
.page:has(.page-messages) {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
.page-messages {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
padding-bottom: 0;
}
.messages-layout,
dp-chat[mode="embed"] {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
padding-bottom: var(--kb-inset, 0px);
}
.messages-layout {
@@ -531,6 +606,7 @@ dp-chat[mode="embed"] {
min-height: 0;
}
dp-chat[with-uid]:not(.show-list) .messages-list,
.messages-list.hide {
display: none;
}
@@ -540,6 +616,8 @@ dp-chat[mode="embed"] {
min-height: 0;
}
dp-chat.show-list .messages-main,
dp-chat:not([with-uid]) .messages-main,
.messages-main.hide {
display: none;
}
@@ -602,6 +680,11 @@ dp-chat[mode="embed"] {
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
@media (hover: none) and (pointer: coarse) {
.messages-input-area .emoji-toggle-btn {
min-width: 44px;
min-height: 44px;
}
.messages-send-btn {
width: 44px;
height: 44px;
@@ -637,21 +720,11 @@ dp-chat[mode="embed"] {
border: none;
color: var(--text-muted);
font-size: 0.7rem;
opacity: 0;
opacity: 1;
cursor: pointer;
}
.message-bubble:hover .message-report-btn,
.message-bubble:focus-within .message-report-btn {
opacity: 1;
}
.message-report-btn:hover {
.message-report-btn:hover,
.message-report-btn:focus {
color: var(--danger);
}
@media (hover: none) and (pointer: coarse) {
.message-report-btn {
opacity: 1;
}
}
@@ -0,0 +1,23 @@
// retoor <retoor@molodetz.nl>
export function dateKey(iso) {
if (!iso) return null;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return null;
const d = new Date(ms);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
export function dateLabel(iso) {
if (!iso) return "";
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return "";
const d = new Date(ms);
const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diffDays = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
return `${dd}/${mm}/${d.getFullYear()}`;
}
+577 -46
View File
@@ -4,6 +4,7 @@ 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";
@@ -17,11 +18,12 @@ 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 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",
@@ -54,6 +56,14 @@ export class AppChat extends Component {
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() {
@@ -73,18 +83,23 @@ export class AppChat extends Component {
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();
if (this.input) this.input.focus({ preventScroll: true });
this._hasOlder = !!(this.thread && this.thread.querySelectorAll(".message-bubble").length >= CONVERSATION_PAGE_SIZE);
if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
}
disconnectedCallback() {
@@ -97,11 +112,13 @@ export class AppChat extends Component {
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() {
@@ -263,7 +280,7 @@ export class AppChat extends Component {
});
}
this.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key === "Enter" && !event.shiftKey && !this._isCoarse()) {
event.preventDefault();
this.form.requestSubmit();
}
@@ -319,7 +336,7 @@ export class AppChat extends Component {
_refreshSendButton() {
if (!this.sendBtn) return;
const busy = this._uploading || this._pendingSends.size > 0;
const busy = this._uploading;
this.sendBtn.disabled = busy;
this.sendBtn.classList.toggle("is-sending", busy);
}
@@ -346,6 +363,7 @@ export class AppChat extends Component {
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({
@@ -428,6 +446,7 @@ export class AppChat extends Component {
const el = document.createElement("dp-content");
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
el.dataset.source = content || "";
el.textContent = content || "";
bubble.appendChild(el);
@@ -461,27 +480,61 @@ export class AppChat extends Component {
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";
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 = att.thumbnail_url || att.url;
img.alt = att.original_filename || "";
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;
if (att.mime_type) img.dataset.mime = att.mime_type;
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");
@@ -490,7 +543,7 @@ export class AppChat extends Component {
video.preload = "metadata";
video.className = "gallery-video";
item.appendChild(video);
} else if (att.is_audio) {
} else if (isAudio) {
const audio = document.createElement("audio");
audio.src = att.url;
audio.controls = true;
@@ -503,8 +556,8 @@ export class AppChat extends Component {
link.target = "_blank";
link.rel = "noopener";
link.className = "non-image";
link.download = att.original_filename || "file";
link.textContent = att.original_filename || "file";
link.download = filename;
link.textContent = filename;
item.appendChild(link);
}
gallery.appendChild(item);
@@ -522,9 +575,34 @@ export class AppChat extends Component {
} 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;
@@ -533,6 +611,7 @@ export class AppChat extends Component {
onMessage: (frame) => this.onFrame(frame),
onClose: () => {
this._socketReady = false;
this._noteDisconnected();
},
});
this.socket.connect();
@@ -568,7 +647,69 @@ export class AppChat extends Component {
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) {
@@ -629,21 +770,24 @@ export class AppChat extends Component {
senderRole: frame.sender_role,
attachments: frame.attachments,
});
if (frame.ai_pending) this._showAiPending(bubble);
this._insertBubble(bubble);
if (!mine && this._socketReady) {
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 = oldBody ? oldBody.textContent : "";
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;
@@ -659,8 +803,16 @@ export class AppChat extends Component {
}
}
if (this.aiIndicatorEnabled && frame.ai_processed && changed) {
this._showAiIndicator(bubble);
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;
}
@@ -673,6 +825,7 @@ export class AppChat extends Component {
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");
@@ -687,10 +840,25 @@ export class AppChat extends Component {
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");
@@ -711,7 +879,7 @@ export class AppChat extends Component {
}
_markRead() {
if (this._socketReady && this.withUid && this.socket) {
if (this._socketReady && this.withUid && this.socket && this._threadIsActive()) {
this.socket.send({ type: "read", with_uid: this.withUid });
}
}
@@ -734,9 +902,16 @@ export class AppChat extends Component {
return;
}
const preview = item.querySelector(".conversation-preview");
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
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;
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);
@@ -807,12 +982,7 @@ export class AppChat extends Component {
}
item.appendChild(time);
item.addEventListener("click", () => {
if (!this._isMobile()) return;
this._activePane = "thread";
this._applyPane();
});
this._bindConversationClick(item);
return item;
}
@@ -868,7 +1038,12 @@ export class AppChat extends Component {
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();
if (atBottom) {
this._newCount = 0;
this._updateJumpButton();
this._stabilizeScroll();
}
if (this.thread.scrollTop < 48) this._loadOlder();
}, { passive: true });
}
@@ -877,10 +1052,12 @@ export class AppChat extends Component {
const viewport = window.visualViewport;
const ensureInputVisible = () => {
const vh = viewport ? viewport.height : window.innerHeight;
const visibleBottom = viewport
? viewport.offsetTop + viewport.height
: window.innerHeight;
const formRect = this.form.getBoundingClientRect();
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset")) || 0;
const delta = formRect.bottom - vh;
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`);
@@ -916,9 +1093,10 @@ export class AppChat extends Component {
this.input.addEventListener("focus", () => {
this._userAtBottom = true;
ensureInputVisible();
const doScroll = () => {
this._scrollThreadToEnd();
if (window.innerWidth < MOBILE_BREAKPOINT_PX) {
if (this._isMobile()) {
const retry = (delay) => window.setTimeout(() => {
ensureInputVisible();
this._scrollThreadToEnd();
@@ -931,7 +1109,9 @@ export class AppChat extends Component {
requestAnimationFrame(doScroll);
});
this.input.addEventListener("blur", () => {
this.input.addEventListener("blur", (event) => {
const next = event.relatedTarget;
if (next && this.form && this.form.contains(next)) return;
this.style.setProperty("--kb-inset", "0px");
});
@@ -942,8 +1122,21 @@ export class AppChat extends Component {
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._isMobile() || !this.list || !this.main) return;
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");
}
@@ -956,32 +1149,358 @@ export class AppChat extends Component {
if (this.backBtn) {
this.backBtn.addEventListener("click", () => {
if (!this._isMobile()) return;
this._activePane = "list";
this._applyPane();
window.history.replaceState(null, "", "/messages");
this._showList();
});
}
this.list.querySelectorAll(".conversation-item").forEach((item) => {
item.addEventListener("click", () => {
if (!this._isMobile()) return;
this._activePane = "thread";
this._applyPane();
});
this._bindConversationClick(item);
});
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);
}
_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;
@@ -1001,7 +1520,10 @@ export class AppChat extends Component {
chooseOnTab: false,
isOpen: () => DomUtils.isShown(dropdown),
onChoose: (item) => {
window.location.href = item.href;
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,
});
@@ -1041,6 +1563,15 @@ export class AppChat extends Component {
}, 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();
});
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#271b5b">
<meta name="asset-version" content="{{ static_version }}">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>