eleven / messenger Public

web: collapse repeated timestamps within a same-sender, same-minute run

A message hides its time when the next message is from the same sender in the same minute, so the time shows once (on the last of the run) instead of repeating. Edited messages always keep their time line so 'Edited ·' stays visible. Computed from DOM neighbours at insert time (handles live, history, and scroll-up pagination).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell e05a6e5a6a8306d7af59b375be732a603ea1b9da parent cfb56d7
1 file changed, +27 −0
  • web/static/chat.js +27 −0
diff --git a/web/static/chat.js b/web/static/chat.js
index aa66e19..a3f1750 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -510,6 +510,23 @@ async function start(session) {
t.textContent = (edited ? "Edited · " : "") + formatTime(payload.ts || createdAt * 1000);
}
+ // Collapse repeated times: a message shows its time unless the NEXT message is
+ // from the same sender within the same minute (the run's time shows on its last
+ // message). Edited messages always show their line so "Edited ·" stays visible.
+ // Reads el.dataset.{sender,minute,edited}, set in render().
+ function refreshTime(el) {
+ if (!el || !el.classList || !el.classList.contains("msg")) return;
+ const t = el.querySelector(":scope > .time");
+ if (!t) return;
+ const next = el.nextElementSibling;
+ const groupedWithNext =
+ !!next &&
+ next.classList.contains("msg") &&
+ next.dataset.sender === el.dataset.sender &&
+ next.dataset.minute === el.dataset.minute;
+ t.style.display = groupedWithNext && !el.dataset.edited ? "none" : "";
+ }
+
// Desktop-only: a reply icon that reveals beside the bubble on hover, so reply
// is discoverable without the press-and-hold. (Touch uses double-tap instead.)
function buildHoverReply(meta) {
@@ -568,6 +585,10 @@ 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 collapsing repeated times (see refreshTime).
+ 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";
if (!mine) {
const n = document.createElement("div");
n.className = "name";
@@ -631,6 +652,10 @@ async function start(session) {
if (opts.before) feed.insertBefore(el, opts.before);
else feed.appendChild(el);
renderReactionsFor(msg.id);
+ // Collapse repeated times: this message (vs its next), and its previous
+ // neighbour (now that this message follows it).
+ refreshTime(el);
+ refreshTime(el.previousElementSibling);
if (!opts.noScroll && (atBottom || mine)) feed.scrollTop = feed.scrollHeight;
}
@@ -651,6 +676,8 @@ async function start(session) {
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
+ refreshTime(el);
}
// Repaint the reaction chips for one message from current state.