From afb4799869255dac06a4464e79cb7d5851d9b77c Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 25 Aug 2026 17:18:43 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Qsdit8UXhbUn9ZnfgjbXqt --- devplacepy/static/css/chat.css | 105 +++- devplacepy/static/js/chat/DateSeparators.js | 23 + devplacepy/static/js/components/AppChat.js | 625 ++++++++++++++++++-- devplacepy/templates/base.html | 2 +- 4 files changed, 691 insertions(+), 64 deletions(-) create mode 100644 devplacepy/static/js/chat/DateSeparators.js diff --git a/devplacepy/static/css/chat.css b/devplacepy/static/css/chat.css index 1e5f92a..577c652 100644 --- a/devplacepy/static/css/chat.css +++ b/devplacepy/static/css/chat.css @@ -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; - } -} diff --git a/devplacepy/static/js/chat/DateSeparators.js b/devplacepy/static/js/chat/DateSeparators.js new file mode 100644 index 0000000..b6e3a5c --- /dev/null +++ b/devplacepy/static/js/chat/DateSeparators.js @@ -0,0 +1,23 @@ +// retoor + +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()}`; +} diff --git a/devplacepy/static/js/components/AppChat.js b/devplacepy/static/js/components/AppChat.js index 560405a..d086b82 100644 --- a/devplacepy/static/js/components/AppChat.js +++ b/devplacepy/static/js/components/AppChat.js @@ -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 = "✓✓"; 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._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 = "←"; + 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(); }); diff --git a/devplacepy/templates/base.html b/devplacepy/templates/base.html index 8ad265b..0fd7b81 100644 --- a/devplacepy/templates/base.html +++ b/devplacepy/templates/base.html @@ -2,7 +2,7 @@ - + {% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}