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 %}