| 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 })); |