eleven / messenger Public

web: reply to messages (iMessage-style quoted threads)

Holding a message (press-and-hold on desktop, double-tap on touch) now offers
a Reply action alongside the emoji reactions. Replying stages the parent in a
cancelable composer banner; the next message is sent as a reply and renders
like Messages on Mac — the reply appears as the latest message with a tappable
quote of the original above it, and the original gains an "N Replies" tag.
Tapping a quote smooth-scrolls to and flashes the original.

The reply link rides inside the encrypted payload (replyTo: {id, name, snippet})
so the server never learns the thread structure — no server, DB, or crypto
changes. The embedded snippet keeps each quote self-contained, so it still
renders when the parent has scrolled out of the loaded history; scroll-to and
the reply counter silently no-op in that case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam fd8f38e025cbf1de0ba35446e067ca262597d98c parent 638a529
3 files changed, +251 −11
  • web/pages/index.html +1 −0
  • web/static/chat.js +156 −11
  • web/static/style.css +94 −0
diff --git a/web/pages/index.html b/web/pages/index.html
index 5104572..362272c 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -32,6 +32,7 @@
<div id="feed"></div>
</div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
+ <div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-preview hidden" id="composer-preview"></div>
<form class="composer" id="composer" autocomplete="off">
<button type="button" class="attach-btn" id="attach-btn" aria-label="Attach a photo or file">+</button>
diff --git a/web/static/chat.js b/web/static/chat.js
index 16046f7..70c4f59 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -1,6 +1,6 @@
import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js";
import { shouldSend } from "/static/ui.js";
-import { tokenizeText, formatBytes } from "/static/format.js";
+import { tokenizeText, formatBytes, replySnippet } from "/static/format.js";
const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go)
const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
@@ -29,6 +29,13 @@ const ICON_X =
'<line x1="6" y1="6" x2="18" y2="18"></line>' +
'<line x1="18" y1="6" x2="6" y2="18"></line></svg>';
+// Curved reply arrow, used in the message action menu and the composer banner.
+const ICON_REPLY =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<polyline points="9 17 4 12 9 7"></polyline>' +
+ '<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>';
+
const app = document.getElementById("app");
const session = loadSession();
@@ -392,6 +399,54 @@ async function start(session) {
const myReactions = new Map();
const reactionEls = new Map();
+ // Reply threading, keyed by the parent's message id:
+ // replyCounts: how many replies a message has (drives the "N Replies" tag)
+ // replyCountEls: the per-message tag element to repaint
+ const replyCounts = new Map();
+ const replyCountEls = new Map();
+
+ function bumpReplyCount(parentId) {
+ const n = (replyCounts.get(parentId) || 0) + 1;
+ replyCounts.set(parentId, n);
+ paintReplyCount(parentId);
+ }
+
+ function paintReplyCount(parentId) {
+ const el = replyCountEls.get(parentId);
+ const n = replyCounts.get(parentId) || 0;
+ if (!el) return; // parent older than the loaded window — nothing to mark
+ el.textContent = n === 1 ? "1 Reply" : n + " Replies";
+ el.hidden = n === 0;
+ }
+
+ // Smooth-scroll to a quoted message and flash it. No-op if the parent has
+ // scrolled out of the loaded history.
+ function scrollToMessage(id) {
+ const target = feed.querySelector('[data-id="' + id + '"]');
+ if (!target) return;
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
+ target.classList.remove("flash");
+ void target.offsetWidth; // restart the animation if it's still mid-flash
+ target.classList.add("flash");
+ }
+
+ // A tappable quote of the message being replied to, shown above the reply.
+ function buildReplyQuote(rt) {
+ const q = document.createElement("button");
+ q.type = "button";
+ q.className = "reply-quote";
+ const who = document.createElement("div");
+ who.className = "reply-quote-name";
+ who.textContent = rt.name || "";
+ const txt = document.createElement("div");
+ txt.className = "reply-quote-text";
+ txt.textContent = rt.snippet || "";
+ q.appendChild(who);
+ q.appendChild(txt);
+ q.onclick = () => scrollToMessage(rt.id);
+ return q;
+ }
+
async function render(msg) {
if (seen.has(msg.id)) return;
seen.add(msg.id);
@@ -410,9 +465,14 @@ async function start(session) {
if (mineSet.size) myReactions.set(msg.id, mineSet);
}
+ // Count this reply against its parent (once per message, thanks to `seen`).
+ if (payload.replyTo && payload.replyTo.id) bumpReplyCount(payload.replyTo.id);
+
const mine = payload.name === session.name;
const el = document.createElement("div");
el.className = "msg " + (mine ? "me" : "them");
+ el.dataset.id = msg.id;
+ if (payload.replyTo && payload.replyTo.id) el.dataset.replyTo = payload.replyTo.id;
if (!mine) {
const n = document.createElement("div");
n.className = "name";
@@ -425,6 +485,7 @@ async function start(session) {
const content = document.createElement("div");
content.className = "content";
+ if (payload.replyTo) content.appendChild(buildReplyQuote(payload.replyTo));
if (payload.text) {
const bubble = document.createElement("div");
bubble.className = "bubble";
@@ -438,14 +499,26 @@ async function start(session) {
bubbleRow.appendChild(content);
el.appendChild(bubbleRow);
- // React: double-tap on touch, press-and-hold on desktop (no per-message button).
- attachReact(el, msg.id);
+ // React or reply: double-tap on touch, press-and-hold on desktop.
+ attachReact(el, msg.id, { id: msg.id, name: payload.name, snippet: replySnippet(payload) });
const reactions = document.createElement("div");
reactions.className = "reactions";
el.appendChild(reactions);
reactionEls.set(msg.id, reactions);
+ // "N Replies" tag (filled in as replies to this message arrive).
+ const replyCount = document.createElement("div");
+ replyCount.className = "reply-count";
+ replyCount.hidden = true;
+ replyCount.onclick = () => {
+ const first = feed.querySelector('[data-reply-to="' + msg.id + '"]');
+ if (first) scrollToMessage(first.dataset.id);
+ };
+ el.appendChild(replyCount);
+ replyCountEls.set(msg.id, replyCount);
+ paintReplyCount(msg.id); // reflect replies that may already have arrived
+
const t = document.createElement("div");
t.className = "time";
t.textContent = formatTime(payload.ts || msg.created_at * 1000);
@@ -517,7 +590,7 @@ async function start(session) {
pickerCloser = null;
}
}
- function openReactionPicker(x, y, onPick) {
+ function openReactionPicker(x, y, onPick, onReply) {
closeReactionPicker();
pickerEl = document.createElement("div");
pickerEl.className = "emoji-picker";
@@ -533,6 +606,19 @@ async function start(session) {
};
pickerEl.appendChild(b);
}
+ if (onReply) {
+ const r = document.createElement("button");
+ r.type = "button";
+ r.className = "picker-action";
+ r.innerHTML = ICON_REPLY;
+ r.setAttribute("aria-label", "Reply");
+ r.onclick = (ev) => {
+ ev.stopPropagation();
+ onReply();
+ closeReactionPicker();
+ };
+ pickerEl.appendChild(r);
+ }
document.body.appendChild(pickerEl);
// Centre the picker above the press point, clamped to the viewport.
const pw = pickerEl.offsetWidth;
@@ -553,13 +639,13 @@ async function start(session) {
// Touch reacts with a double-tap (leaving long-press free for text selection);
// desktop reacts with a press-and-hold (which doesn't clash with drag-select).
- function attachReact(el, msgId) {
- if (isCoarse) attachDoubleTap(el, msgId);
- else attachLongPress(el, msgId);
+ function attachReact(el, msgId, replyMeta) {
+ if (isCoarse) attachDoubleTap(el, msgId, replyMeta);
+ else attachLongPress(el, msgId, replyMeta);
}
// Double-tap to react (touch). Two taps within 320ms near the same spot.
- function attachDoubleTap(el, msgId) {
+ function attachDoubleTap(el, msgId, replyMeta) {
let lastT = 0;
let lx = 0;
let ly = 0;
@@ -569,7 +655,12 @@ async function start(session) {
if (t - lastT < 320 && Math.abs(e.clientX - lx) < 30 && Math.abs(e.clientY - ly) < 30) {
e.preventDefault();
lastT = 0;
- openReactionPicker(e.clientX, e.clientY, (emoji) => toggleReaction(msgId, emoji));
+ openReactionPicker(
+ e.clientX,
+ e.clientY,
+ (emoji) => toggleReaction(msgId, emoji),
+ () => startReply(replyMeta)
+ );
} else {
lastT = t;
lx = e.clientX;
@@ -579,7 +670,7 @@ async function start(session) {
}
// Press-and-hold (mouse) on a message opens the reaction picker.
- function attachLongPress(el, msgId) {
+ function attachLongPress(el, msgId, replyMeta) {
let timer = null;
let sx = 0;
let sy = 0;
@@ -599,7 +690,12 @@ async function start(session) {
timer = setTimeout(() => {
timer = null;
fired = true;
- openReactionPicker(e.clientX, e.clientY, (emoji) => toggleReaction(msgId, emoji));
+ openReactionPicker(
+ e.clientX,
+ e.clientY,
+ (emoji) => toggleReaction(msgId, emoji),
+ () => startReply(replyMeta)
+ );
}, 450);
});
el.addEventListener("pointermove", (e) => {
@@ -836,9 +932,54 @@ async function start(session) {
const attachBtn = document.getElementById("attach-btn");
const fileInput = document.getElementById("file-input");
const previewBox = document.getElementById("composer-preview");
+ const replyBox = document.getElementById("composer-reply");
// The attachment staged for the next send (uploaded already), or null.
let pendingAttachment = null;
+ // The message this composer is replying to ({id, name, snippet}), or null.
+ let replyingTo = null;
+
+ // Begin replying to a message: stage the parent and show a cancelable banner
+ // above the composer. Independent of any staged attachment.
+ function startReply(meta) {
+ if (!meta) return;
+ replyingTo = meta;
+ replyBox.classList.remove("hidden");
+ replyBox.innerHTML = "";
+ const bar = document.createElement("div");
+ bar.className = "reply-bar";
+ const icon = document.createElement("span");
+ icon.className = "reply-bar-icon";
+ icon.innerHTML = ICON_REPLY;
+ const body = document.createElement("div");
+ body.className = "reply-bar-body";
+ const who = document.createElement("div");
+ who.className = "reply-bar-name";
+ who.textContent = "Replying to " + (meta.name || "");
+ const txt = document.createElement("div");
+ txt.className = "reply-bar-text";
+ txt.textContent = meta.snippet || "";
+ body.appendChild(who);
+ body.appendChild(txt);
+ const x = document.createElement("button");
+ x.type = "button";
+ x.className = "reply-bar-cancel";
+ x.innerHTML = ICON_X;
+ x.setAttribute("aria-label", "Cancel reply");
+ x.onclick = cancelReply;
+ bar.appendChild(icon);
+ bar.appendChild(body);
+ bar.appendChild(x);
+ replyBox.appendChild(bar);
+ feed.scrollTop = feed.scrollHeight;
+ input.focus();
+ }
+
+ function cancelReply() {
+ replyingTo = null;
+ replyBox.classList.add("hidden");
+ replyBox.innerHTML = "";
+ }
const syncSendBtn = () => {
sendBtn.hidden = input.value.trim() === "" && !pendingAttachment;
@@ -1039,8 +1180,12 @@ async function start(session) {
if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload = { name: session.name, text, ts: Date.now() };
if (pendingAttachment) payload.attachments = [pendingAttachment];
+ // Carry the reply link inside the encrypted payload (the server never learns
+ // the thread structure). The snippet keeps the quote self-contained.
+ if (replyingTo) payload.replyTo = replyingTo;
input.value = "";
clearPreview();
+ cancelReply();
autosize();
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", blob }));
diff --git a/web/static/style.css b/web/static/style.css
index e7d8147..b25da3c 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -372,6 +372,100 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
line-height: 1;
}
.emoji-opt:hover { background: var(--bubble-them); }
+/* the "Reply" action in the message menu, set off from the emoji row */
+.picker-action {
+ background: transparent;
+ color: var(--accent);
+ padding: 4px 8px;
+ margin-left: 2px;
+ border-radius: 50%;
+ border-left: 1px solid var(--border);
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+}
+.picker-action svg { width: 22px; height: 22px; }
+.picker-action:hover { background: var(--bubble-them); }
+
+/* a reply: a tappable quote of the parent sits above the bubble (à la Messages),
+ and the parent gets a "N Replies" tag below it. */
+.reply-quote {
+ align-self: stretch;
+ text-align: left;
+ max-width: 100%;
+ background: transparent;
+ color: var(--muted);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 6px 11px;
+ margin-bottom: 1px;
+ font-weight: 400;
+ white-space: normal;
+}
+.msg.me .reply-quote { text-align: right; }
+.reply-quote-name { font-size: 11px; color: var(--muted); margin-bottom: 1px; }
+.reply-quote-text {
+ font-size: 13px;
+ color: var(--muted);
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.msg.me .reply-quote-text { margin-left: auto; }
+.reply-count {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--accent);
+ margin: 3px 6px 0;
+ cursor: pointer;
+}
+.reply-count[hidden] { display: none; }
+/* flash a message when you jump to it from a quote */
+.msg.flash { animation: flash-msg 1.2s ease; border-radius: var(--radius); }
+@keyframes flash-msg {
+ 0%, 100% { background: transparent; }
+ 25% { background: rgba(91, 108, 255, 0.14); }
+}
+
+/* composer "Replying to …" banner, above the composer (and any attachment preview) */
+.composer-reply {
+ padding: 8px 14px;
+ border-top: 1px solid var(--border);
+ background: var(--panel);
+}
+.composer-reply.hidden { display: none; }
+.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; }
+.reply-bar-body {
+ flex: 1;
+ min-width: 0;
+ border-left: 2px solid var(--accent);
+ padding-left: 10px;
+}
+.reply-bar-name { font-size: 12px; font-weight: 600; color: var(--ink); }
+.reply-bar-text {
+ font-size: 13px;
+ color: var(--muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.reply-bar-cancel {
+ flex: none;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.reply-bar-cancel svg { width: 15px; height: 15px; }
+.reply-bar-cancel:hover { background: var(--bubble-them); }
.composer {
display: flex;