| index 023253d..b60edbd 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -98,6 +98,10 @@ const ICON_TRASH = |
| |
| let msgEls = {}; // message id -> its .msg element (for live reaction/edit updates) |
| let msgData = {}; // message id -> { mine, createdAt, data, replyMeta } (edit/reply) |
| +// Reply threading, keyed by the parent's message id: how many replies it has |
| +// (drives the "N Replies" tag) and the tag element to repaint as they arrive. |
| +let replyCounts = {}; |
| +let replyCountEls = {}; |
| let editing = null; // message id being edited, or null |
| let replyingTo = null; // { id, name, snippet } being replied to, or null |
| const myReactions = new Set(); // "<msgId>|<emoji>" the current user has reacted with |
| @@ -753,6 +757,8 @@ async function openThread(id) { |
| feed.innerHTML = ""; |
| msgEls = {}; |
| msgData = {}; |
| + replyCounts = {}; |
| + replyCountEls = {}; |
| myReactions.clear(); // per-thread highlight; repopulated from each message's `mine` |
| clearPending(); // don't carry a staged photo (encrypted to the old thread's key) over |
| cancelEdit(); |
| @@ -821,6 +827,7 @@ async function appendMessage(threadId, m, scroll = true) { |
| wrap.dataset.id = m.id; // lets a reply quote scroll back to this message |
| wrap.dataset.t = data.ts || m.created_at * 1000; // drives the time separators |
| wrap.dataset.sender = data.name || ""; // drives sender-name collapse |
| + if (data.replyTo && data.replyTo.id) wrap.dataset.replyTo = data.replyTo.id; |
| if (!mine && t && t.kind === "group") { |
| const nm = document.createElement("div"); |
| nm.className = "name"; |
| @@ -855,7 +862,6 @@ async function appendMessage(threadId, m, scroll = true) { |
| const bubble = document.createElement("div"); |
| bubble.className = "bubble"; |
| renderText(bubble, data.text); |
| - if (m.edited_at) markEdited(bubble); |
| content.appendChild(bubble); |
| const link = (richTokens(data.text).find((t) => t.href) || {}).href; |
| if (link) addLinkPreview(content, link); |
| @@ -894,6 +900,21 @@ async function appendMessage(threadId, m, scroll = true) { |
| } |
| renderReactions(wrap, m.id, m.reactions || []); |
| attachReactionGesture(wrap, m.id); |
| + // Count this reply against its parent, so the parent's tag can show "N Replies". |
| + if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id); |
| + // A persistent "N Replies" tag below the message, tappable to jump to the first |
| + // reply. Hidden until a reply arrives. |
| + const replyCount = document.createElement("div"); |
| + replyCount.className = "reply-count"; |
| + replyCount.hidden = true; |
| + replyCount.onclick = () => { |
| + const first = feed.querySelector(`.msg[data-reply-to="${m.id}"]`); |
| + if (first) scrollToMessage(first.dataset.id); |
| + }; |
| + wrap.appendChild(replyCount); |
| + replyCountEls[m.id] = replyCount; |
| + paintReplyCount(m.id); // reflect replies that may already have arrived |
| + if (m.edited_at) markEdited(wrap); // persistent "Edited" label below the bubble |
| msgEls[m.id] = wrap; |
| msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta }; |
| if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't |
| @@ -955,13 +976,27 @@ async function addLinkPreview(wrap, url) { |
| else wrap.appendChild(card); |
| } |
| |
| -// Append a muted "(edited)" marker to a bubble (once). |
| -function markEdited(bubble) { |
| - if (bubble.querySelector(".edited")) return; |
| - const e = document.createElement("span"); |
| - e.className = "edited"; |
| - e.textContent = " (edited)"; |
| - bubble.appendChild(e); |
| +// Append a persistent "Edited" label below a message (once). |
| +function markEdited(wrap) { |
| + if (wrap.querySelector(".edited-tag")) return; |
| + const e = document.createElement("div"); |
| + e.className = "edited-tag"; |
| + e.textContent = "Edited"; |
| + wrap.appendChild(e); |
| +} |
| + |
| +// Bump (and repaint) the reply tally for a parent message as replies to it arrive. |
| +function bumpReplyCount(parentId) { |
| + replyCounts[parentId] = (replyCounts[parentId] || 0) + 1; |
| + paintReplyCount(parentId); |
| +} |
| + |
| +function paintReplyCount(parentId) { |
| + const el = replyCountEls[parentId]; |
| + if (!el) return; // parent older than the loaded window — nothing to mark |
| + const n = replyCounts[parentId] || 0; |
| + el.textContent = n === 1 ? "1 Reply" : n + " Replies"; |
| + el.hidden = n === 0; |
| } |
| |
| // Render a message's reaction chips (rebuilds the row in place). "Mine" chips are |
| @@ -983,7 +1018,10 @@ function renderReactions(wrap, msgId, reactions) { |
| }; |
| row.appendChild(chip); |
| } |
| - wrap.appendChild(row); |
| + // Keep chips above the reply/edited tags even when re-rendered on a live update. |
| + const anchor = wrap.querySelector(".reply-count"); |
| + if (anchor) wrap.insertBefore(row, anchor); |
| + else wrap.appendChild(row); |
| } |
| |
| function toggleReaction(msgId, emoji) { |
| @@ -1556,7 +1594,7 @@ function connectWS() { |
| if (bubble) { |
| bubble.textContent = ""; |
| renderText(bubble, nd.text || ""); |
| - markEdited(bubble); |
| + markEdited(msgEls[msg.id]); |
| } |
| if (msgData[msg.id]) msgData[msg.id].data = nd; |
| } catch {} |
| @@ -1568,6 +1606,14 @@ function connectWS() { |
| // above it may briefly linger; harmless and gone on next thread open.) |
| if (msg.thread_id === activeId && msgEls[msg.id]) { |
| const next = nextMsgOf(msgEls[msg.id]); |
| + // Keep reply tallies honest: drop this message's tag, and if it was itself a |
| + // reply, decrement its parent's count. |
| + const parent = msgData[msg.id]?.data?.replyTo?.id; |
| + delete replyCountEls[msg.id]; |
| + if (parent && replyCounts[parent]) { |
| + replyCounts[parent]--; |
| + paintReplyCount(parent); |
| + } |
| msgEls[msg.id].remove(); |
| delete msgEls[msg.id]; |
| delete msgData[msg.id]; |