| index 1535a41..b8afc2a 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -47,6 +47,11 @@ const EDIT_WINDOW_MS = 15 * 60 * 1000; |
| const TYPING_INTERVAL_MS = 10000; |
| const TYPING_EXPIRY_S = 15; |
| |
| +// Open a new burst with a centered time separator when consecutive messages are |
| +// more than this far apart (or land on a different day) — rather than stamping |
| +// every message. An exact per-message time is still available on hover (desktop). |
| +const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; |
| + |
| // A crisp, optically-centered close icon (the "×" text glyph sits high in its box). |
| const ICON_X = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ' + |
| @@ -808,15 +813,16 @@ async function start(session) { |
| if (payload.text) addLinkPreview(content, payload.text); |
| } |
| |
| - // The time/"Edited" line under a message. |
| - function stampTime(el, payload, createdAt, edited) { |
| - let t = el.querySelector(":scope > .time"); |
| - if (!t) { |
| - t = document.createElement("div"); |
| - t.className = "time"; |
| - el.appendChild(t); |
| - } |
| - t.textContent = (edited ? "Edited · " : "") + formatTime(payload.ts || createdAt * 1000); |
| + // Nearest .msg sibling skipping any .time-sep elements between messages. |
| + function prevMsgOf(el) { |
| + let p = el && el.previousElementSibling; |
| + while (p && !p.classList.contains("msg")) p = p.previousElementSibling; |
| + return p; |
| + } |
| + function nextMsgOf(el) { |
| + let n = el && el.nextElementSibling; |
| + while (n && !n.classList.contains("msg")) n = n.nextElementSibling; |
| + return n; |
| } |
| |
| // Two messages belong to the same run if same sender within the same minute. |
| @@ -833,17 +839,41 @@ async function start(session) { |
| ); |
| } |
| |
| - // Group adjacent messages: a continuation (same run as the message above) gets |
| - // a tighter top gap (.cont), and a message hides its time unless it's the last |
| - // of its run (the run's time shows once). Edited messages always show their |
| - // line so "Edited ·" stays visible. Reads el.dataset.{sender,minute,edited}. |
| + // A continuation (same run as the message directly above) gets a tighter top |
| + // gap (.cont). A time separator between two messages breaks the run, so using |
| + // previousElementSibling (not prevMsgOf) is correct here. |
| function refreshGrouping(el) { |
| if (!el || !el.classList || !el.classList.contains("msg")) return; |
| el.classList.toggle("cont", sameGroup(el.previousElementSibling, el)); |
| - const t = el.querySelector(":scope > .time"); |
| - if (t) { |
| - t.style.display = |
| - sameGroup(el, el.nextElementSibling) && !el.dataset.edited ? "none" : ""; |
| + } |
| + |
| + // Ensure the right separator (or none) sits before `el`, based on the gap to the |
| + // previous message. Computed from DOM neighbours so it stays correct as history |
| + // pages prepend above and live messages append below. `el.dataset.t` is the |
| + // message time (ms). |
| + function refreshSeparator(el) { |
| + if (!el || !el.classList || !el.classList.contains("msg")) return; |
| + const prev = prevMsgOf(el); |
| + const existing = |
| + el.previousElementSibling && el.previousElementSibling.classList.contains("time-sep") |
| + ? el.previousElementSibling |
| + : null; |
| + const curT = Number(el.dataset.t); |
| + let show, withDay; |
| + if (!prev) { |
| + show = true; // the oldest loaded message always opens with a dated separator |
| + withDay = true; |
| + } else { |
| + const newDay = !sameDay(curT, Number(prev.dataset.t)); |
| + show = newDay || curT - Number(prev.dataset.t) > TIME_SEPARATOR_GAP_MS; |
| + withDay = newDay; |
| + } |
| + if (show) { |
| + const sep = buildTimeSeparator(curT, withDay); |
| + if (existing) existing.replaceWith(sep); |
| + else el.parentNode.insertBefore(sep, el); |
| + } else if (existing) { |
| + existing.remove(); |
| } |
| } |
| |
| @@ -877,6 +907,34 @@ async function start(session) { |
| return b; |
| } |
| |
| + // Build a message's content column: text bubble, hover reply/edit icons, the |
| + // hover-revealed exact time, and a persistent "Edited" label. Shared by render |
| + // and applyEdit (which rebuilds content in place), since fillContent clears it. |
| + function decorateContent(content, id, createdAt, payload, mine, edited) { |
| + fillContent(content, payload); |
| + if (!isCoarse) { |
| + content.appendChild(buildHoverReply({ id, name: payload.name, snippet: replySnippet(payload) })); |
| + const remaining = createdAt * 1000 + EDIT_WINDOW_MS - Date.now(); |
| + if (mine && remaining > 0) { |
| + const editBtn = buildHoverEdit(id); |
| + content.appendChild(editBtn); |
| + setTimeout(() => editBtn.remove(), remaining); |
| + } |
| + } |
| + // Exact time, revealed on hover (desktop), tucked beyond the hover icons. |
| + const t = document.createElement("div"); |
| + t.className = "msg-time"; |
| + t.textContent = formatTime(payload.ts || createdAt * 1000); |
| + content.appendChild(t); |
| + // Persistent "Edited" label (the day/time context lives in the separators). |
| + if (edited) { |
| + const tag = document.createElement("div"); |
| + tag.className = "edited-tag"; |
| + tag.textContent = "Edited"; |
| + content.appendChild(tag); |
| + } |
| + } |
| + |
| // opts.before: insert before this node (for prepending older history). |
| // opts.noScroll: don't auto-scroll (batch loads handle scrolling themselves). |
| async function render(msg, opts = {}) { |
| @@ -908,10 +966,11 @@ async function start(session) { |
| el.className = "msg " + (mine ? "me" : "them"); |
| el.dataset.id = msg.id; |
| if (payload.replyTo && payload.replyTo.id) el.dataset.replyTo = payload.replyTo.id; |
| - // Grouping keys for tighter gaps + collapsing repeated times (see refreshGrouping). |
| + // Grouping keys for the tighter same-run gap (.cont, see refreshGrouping). |
| el.dataset.sender = mine ? "me" : "them:" + payload.name; |
| el.dataset.minute = Math.floor((payload.ts || msg.created_at * 1000) / 60000); |
| - if (msg.edited_at) el.dataset.edited = "1"; |
| + // Message time (ms), used to place the time separators (see refreshSeparator). |
| + el.dataset.t = payload.ts || msg.created_at * 1000; |
| if (!mine) { |
| const n = document.createElement("div"); |
| n.className = "name"; |
| @@ -924,25 +983,12 @@ async function start(session) { |
| |
| const content = document.createElement("div"); |
| content.className = "content"; |
| - fillContent(content, payload); |
| - |
| - const replyMeta = { id: msg.id, name: payload.name, snippet: replySnippet(payload) }; |
| - // Desktop: hover-revealed icons inside .content — absolutely positioned, so |
| - // revealing them never shifts the bubble (touch uses the long-press menu). |
| - // Reply on every message; Edit only on your own, and only while still in the |
| - // window — drop the affordance the moment it lapses. |
| - if (!isCoarse) { |
| - content.appendChild(buildHoverReply(replyMeta)); |
| - const remaining = msg.created_at * 1000 + EDIT_WINDOW_MS - Date.now(); |
| - if (mine && remaining > 0) { |
| - const editBtn = buildHoverEdit(msg.id); |
| - content.appendChild(editBtn); |
| - setTimeout(() => editBtn.remove(), remaining); |
| - } |
| - } |
| + decorateContent(content, msg.id, msg.created_at, payload, mine, !!msg.edited_at); |
| bubbleRow.appendChild(content); |
| el.appendChild(bubbleRow); |
| |
| + const replyMeta = { id: msg.id, name: payload.name, snippet: replySnippet(payload) }; |
| + |
| // Keep the payload + send time so the menu can offer Edit (own, in-window |
| // messages) and an edit can re-encrypt preserving everything but the text. |
| msgPayloads.set(msg.id, payload); |
| @@ -968,18 +1014,19 @@ async function start(session) { |
| replyCountEls.set(msg.id, replyCount); |
| paintReplyCount(msg.id); // reflect replies that may already have arrived |
| |
| - stampTime(el, payload, msg.created_at, msg.edited_at); |
| - |
| const atBottom = |
| !opts.before && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| if (opts.before) feed.insertBefore(el, opts.before); |
| else feed.appendChild(el); |
| renderReactionsFor(msg.id); |
| - // Re-group around the insertion point: this message and both neighbours |
| - // (prev's time may now hide; next's continuation may now apply). |
| + // Place separators before grouping: a separator sits between two messages and |
| + // the .cont check reads the immediate previous sibling, so it must see the |
| + // separator first. Refresh this message and the one that now follows it. |
| + const nextMsg = nextMsgOf(el); |
| + refreshSeparator(el); |
| + refreshSeparator(nextMsg); |
| refreshGrouping(el); |
| - refreshGrouping(el.previousElementSibling); |
| - refreshGrouping(el.nextElementSibling); |
| + refreshGrouping(nextMsg); |
| if (!opts.noScroll && (atBottom || mine)) feed.scrollTop = feed.scrollHeight; |
| } |
| |
| @@ -998,10 +1045,11 @@ async function start(session) { |
| const el = feed.querySelector('[data-id="' + msg.id + '"]'); |
| if (!el) return; |
| const content = el.querySelector(".content"); |
| - if (content) fillContent(content, payload); |
| - stampTime(el, payload, msgCreatedAt.get(msg.id) || 0, msg.edited_at || 1); |
| - el.dataset.edited = "1"; // edited messages always show their time line |
| - refreshGrouping(el); |
| + // Rebuild content in place (fillContent clears it) and stamp the "Edited" |
| + // label. Time/separators/grouping are unchanged — an edit keeps the send time. |
| + if (content) { |
| + decorateContent(content, msg.id, msgCreatedAt.get(msg.id) || 0, payload, payload.name === session.name, true); |
| + } |
| } |
| |
| // Repaint the reaction chips for one message from current state. |
| @@ -1445,7 +1493,9 @@ async function start(session) { |
| if (page.length) { |
| const prevHeight = feed.scrollHeight; |
| const prevTop = feed.scrollTop; |
| - const anchor = feed.firstChild; // older messages go in order above this |
| + // Insert before the oldest loaded *message* (not feed.firstChild, which may |
| + // be a time separator that refreshSeparator removes mid-loop). |
| + const anchor = feed.querySelector(".msg"); |
| for (const m of page) await render(m, { before: anchor, noScroll: true }); |
| feed.scrollTop = prevTop + (feed.scrollHeight - prevHeight); // hold position |
| oldestId = page[0].id; |
| @@ -1938,6 +1988,47 @@ function formatTime(ms) { |
| return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| } |
| |
| +function sameDay(a, b) { |
| + const da = new Date(a); |
| + const db = new Date(b); |
| + return ( |
| + da.getFullYear() === db.getFullYear() && |
| + da.getMonth() === db.getMonth() && |
| + da.getDate() === db.getDate() |
| + ); |
| +} |
| + |
| +// "Today" / "Yesterday" / a weekday (within the past week) / a date, for the |
| +// time-separator day label. |
| +function dayLabel(ms) { |
| + const now = Date.now(); |
| + if (sameDay(ms, now)) return "Today"; |
| + if (sameDay(ms, now - 86400000)) return "Yesterday"; |
| + const d = new Date(ms); |
| + if (now - ms < 7 * 86400000) return d.toLocaleDateString([], { weekday: "long" }); |
| + const sameYear = new Date(now).getFullYear() === d.getFullYear(); |
| + return d.toLocaleDateString( |
| + [], |
| + sameYear ? { month: "short", day: "numeric" } : { month: "short", day: "numeric", year: "numeric" } |
| + ); |
| +} |
| + |
| +// A centered separator that opens a burst. `withDay` prepends a bold day label |
| +// ("Today 9:41 AM"); a within-day gap shows just the time ("2:15 PM"). |
| +function buildTimeSeparator(ms, withDay) { |
| + const sep = document.createElement("div"); |
| + sep.className = "time-sep"; |
| + if (withDay) { |
| + const day = document.createElement("strong"); |
| + day.textContent = dayLabel(ms); |
| + sep.appendChild(day); |
| + sep.appendChild(document.createTextNode(" " + formatTime(ms))); |
| + } else { |
| + sep.textContent = formatTime(ms); |
| + } |
| + return sep; |
| +} |
| + |
| // Append message text to an element, turning URLs into links. Builds DOM nodes |
| // only (text nodes + anchors with validated http(s) hrefs) — never innerHTML, so |
| // message content can't inject markup. |