eleven / messenger Public

web: time separators + hover times, integrated with editing

Reworks message timestamps onto the merged main (which added per-minute
collapse, history pagination, and message editing):

- Replaces the per-message timestamp / minute-collapse with centered time
  separators that open each burst — "Today 9:41 AM" / a weekday / a date when
  the day changes, and just the time for a >15-min same-day gap. Separators are
  computed from DOM neighbours (refreshSeparator) so they stay correct as older
  pages prepend above and live messages append below.
- Exact per-message time is revealed on hover (desktop), tucked just beyond the
  reply/edit icons so nothing overlaps.
- Edited messages keep a persistent "Edited" label under the bubble; the
  day/time context now lives in the separators rather than an inline line.
- Factors content building into decorateContent(), shared by render and
  applyEdit, so an edit rebuilds the bubble and re-adds the hover icons, hover
  time, and Edited label in place.
- Fixes loadOlder to anchor prepends on the oldest message, not feed.firstChild
  (which can be a separator that refreshSeparator removes mid-loop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam c151fe078e282c2f679f48d10064f538ed0b3ebd parent c9129f3
2 files changed, +169 −47
  • web/static/chat.js +137 −46
  • web/static/style.css +32 −1
diff --git a/web/static/chat.js b/web/static/chat.js
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.
diff --git a/web/static/style.css b/web/static/style.css
index d897a8c..6dec23b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -223,7 +223,38 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* a message of just a few emoji: large and bubble-less, no background or padding (see fillContent) */
.msg .bubble.jumbo-emoji,
.msg.me .bubble.jumbo-emoji { background: none; color: inherit; padding: 2px 0; font-size: 44px; line-height: 1.15; }
-.msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; }
+
+/* centered time separator that opens a burst (replaces per-message timestamps) */
+.time-sep {
+ align-self: center;
+ margin: 16px 0 6px;
+ font-size: 11px;
+ color: var(--muted);
+ text-align: center;
+}
+.time-sep strong { color: var(--ink); font-weight: 600; }
+
+/* persistent "Edited" label under an edited bubble */
+.edited-tag { font-size: 11px; color: var(--muted); margin: 1px 6px 0; }
+
+/* exact per-message time, revealed on hover (desktop) just beyond the hover
+ reply/edit icons so they don't overlap */
+.msg-time {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 11px;
+ color: var(--muted);
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s;
+}
+.msg.them .msg-time { left: 100%; margin-left: 34px; }
+.msg.me .msg-time { right: 100%; margin-right: 62px; }
+@media (hover: hover) {
+ .msg:hover .msg-time { opacity: 1; }
+}
/* desktop hover reply/edit icons — absolutely positioned just outside the bubble
so they reveal on hover without shifting any content. They abut the bubble (no