eleven / ocho Public

web: restore the "N Replies" tag and move "Edited" below the bubble (#20)

Two regressions from the multi-thread rebuild (#17): the per-message reply
tally disappeared, and edits started rendering as an inline "(edited)" inside
the bubble instead of a label beneath it.

Restore the "N Replies" tag below a message (tappable to jump to the first
reply, hidden until a reply arrives), and render edits as a muted "Edited"
label below the bubble. The .reply-count and .edited-tag styles were already
present in the CSS — only the JS that used them was lost — so this reuses
them and drops the now-dead .edited rule. Reply tallies are also decremented
when a reply is unsent.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 7c0d81c9e519c68b6ea98bc18413e13af415e23d parent a2ee70f
2 files changed, +56 −11
  • web/static/chat.js +56 −10
  • web/static/style.css +0 −1
diff --git a/web/static/chat.js b/web/static/chat.js
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];
diff --git a/web/static/style.css b/web/static/style.css
index 3efdfca..4f915da 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -601,7 +601,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.composer-edit.hidden { display: none; }
.composer-edit { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 14px; }
.banner-x { background: transparent; color: var(--muted); font-size: 20px; line-height: 1; padding: 0 6px; }
-.edited { font-size: 11px; opacity: 0.6; margin-left: 6px; }
.reply-bar { display: flex; align-items: center; gap: 10px; }
.reply-bar-icon { flex: none; color: var(--accent); display: inline-flex; }
.reply-bar-icon svg { width: 18px; height: 18px; }