| index 25c3c5e..c71c5af 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -22,6 +22,10 @@ const SESSION_KEY = "lchat_session"; |
| // The fixed reaction set, matching the server allowlist (hub.go). |
| const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; |
| |
| +// How long after sending you may still edit a message. Mirrors editWindow on the |
| +// server (hub.go), which is the real enforcement — this only gates the UI. |
| +const EDIT_WINDOW_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" ' + |
| @@ -36,6 +40,13 @@ const ICON_REPLY = |
| '<polyline points="9 17 4 12 9 7"></polyline>' + |
| '<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>'; |
| |
| +// Pencil, used for the Edit action in the message menu and the composer banner. |
| +const ICON_EDIT = |
| + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
| + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| + '<path d="M12 20h9"></path>' + |
| + '<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>'; |
| + |
| const app = document.getElementById("app"); |
| const session = loadSession(); |
| |
| @@ -399,6 +410,12 @@ async function start(session) { |
| const myReactions = new Map(); |
| const reactionEls = new Map(); |
| |
| + // Editing state, keyed by message id: |
| + // msgPayloads: the decrypted payload (so editing can preserve name/ts/etc.) |
| + // msgCreatedAt: server created_at (seconds) for the client-side edit-window check |
| + const msgPayloads = new Map(); |
| + const msgCreatedAt = 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 |
| @@ -447,6 +464,35 @@ async function start(session) { |
| return q; |
| } |
| |
| + // (Re)build a message's content column from its decrypted payload: reply quote, |
| + // text bubble, attachments, link preview. Shared by initial render and by edits |
| + // (which replace the text in place), so it always reflects the current payload. |
| + function fillContent(content, payload) { |
| + content.innerHTML = ""; |
| + if (payload.replyTo) content.appendChild(buildReplyQuote(payload.replyTo)); |
| + if (payload.text) { |
| + const bubble = document.createElement("div"); |
| + bubble.className = "bubble"; |
| + appendText(bubble, payload.text); |
| + content.appendChild(bubble); |
| + } |
| + if (Array.isArray(payload.attachments)) { |
| + for (const att of payload.attachments) content.appendChild(renderAttachment(att)); |
| + } |
| + 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); |
| + } |
| + |
| // 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) { |
| @@ -462,6 +508,21 @@ async function start(session) { |
| return b; |
| } |
| |
| + // Desktop-only edit icon, mirroring buildHoverReply. Only built for your own |
| + // messages that are still in the edit window (see render). |
| + function buildHoverEdit(id) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "edit-hover"; |
| + b.innerHTML = ICON_EDIT; |
| + b.setAttribute("aria-label", "Edit"); |
| + b.onclick = (e) => { |
| + e.stopPropagation(); |
| + startEdit(id); |
| + }; |
| + return b; |
| + } |
| + |
| async function render(msg) { |
| if (seen.has(msg.id)) return; |
| seen.add(msg.id); |
| @@ -500,28 +561,32 @@ 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"; |
| - appendText(bubble, payload.text); |
| - content.appendChild(bubble); |
| - } |
| - if (Array.isArray(payload.attachments)) { |
| - for (const att of payload.attachments) content.appendChild(renderAttachment(att)); |
| - } |
| - if (payload.text) addLinkPreview(content, payload.text); |
| + fillContent(content, payload); |
| |
| const replyMeta = { id: msg.id, name: payload.name, snippet: replySnippet(payload) }; |
| - // Desktop: a reply icon that reveals beside the bubble on hover. It's absolutely |
| - // positioned inside .content, so revealing it never shifts the bubble (touch uses |
| - // double-tap instead). |
| - if (!isCoarse) content.appendChild(buildHoverReply(replyMeta)); |
| + // 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); |
| + } |
| + } |
| bubbleRow.appendChild(content); |
| el.appendChild(bubbleRow); |
| |
| - // React or reply: double-tap on touch, press-and-hold on desktop. |
| - attachReact(el, msg.id, replyMeta); |
| + // 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); |
| + msgCreatedAt.set(msg.id, msg.created_at); |
| + |
| + // React, reply, or edit: double-tap on touch, press-and-hold on desktop. |
| + attachReact(el, msg.id, { replyMeta, mine, createdAt: msg.created_at }); |
| |
| const reactions = document.createElement("div"); |
| reactions.className = "reactions"; |
| @@ -540,10 +605,7 @@ async function start(session) { |
| 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); |
| - el.appendChild(t); |
| + stampTime(el, payload, msg.created_at, msg.edited_at); |
| |
| const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| feed.appendChild(el); |
| @@ -551,6 +613,25 @@ async function start(session) { |
| if (atBottom || mine) feed.scrollTop = feed.scrollHeight; |
| } |
| |
| + // Apply an authoritative edit broadcast: re-decrypt and rebuild that message's |
| + // text in place, and mark it "Edited". The editor sees their own change this |
| + // way too (same echo-driven flow as sending). No-op if the message isn't in the |
| + // loaded window. |
| + async function applyEdit(msg) { |
| + let payload; |
| + try { |
| + payload = await decrypt(key, msg.blob); |
| + } catch { |
| + return; |
| + } |
| + msgPayloads.set(msg.id, payload); |
| + 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); |
| + } |
| + |
| // Repaint the reaction chips for one message from current state. |
| function renderReactionsFor(msgId) { |
| const container = reactionEls.get(msgId); |
| @@ -611,7 +692,7 @@ async function start(session) { |
| pickerCloser = null; |
| } |
| } |
| - function openReactionPicker(x, y, onPick, onReply) { |
| + function openReactionPicker(x, y, onPick, onReply, onEdit) { |
| closeReactionPicker(); |
| pickerEl = document.createElement("div"); |
| pickerEl.className = "emoji-picker"; |
| @@ -627,19 +708,21 @@ 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) => { |
| + const addAction = (icon, label, fn) => { |
| + const a = document.createElement("button"); |
| + a.type = "button"; |
| + a.className = "picker-action"; |
| + a.innerHTML = icon; |
| + a.setAttribute("aria-label", label); |
| + a.onclick = (ev) => { |
| ev.stopPropagation(); |
| - onReply(); |
| + fn(); |
| closeReactionPicker(); |
| }; |
| - pickerEl.appendChild(r); |
| - } |
| + pickerEl.appendChild(a); |
| + }; |
| + if (onReply) addAction(ICON_REPLY, "Reply", onReply); |
| + if (onEdit) addAction(ICON_EDIT, "Edit", onEdit); |
| document.body.appendChild(pickerEl); |
| // Centre the picker above the press point, clamped to the viewport. |
| const pw = pickerEl.offsetWidth; |
| @@ -660,13 +743,28 @@ 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, replyMeta) { |
| - if (isCoarse) attachDoubleTap(el, msgId, replyMeta); |
| - else attachLongPress(el, msgId, replyMeta); |
| + function attachReact(el, msgId, ctx) { |
| + if (isCoarse) attachDoubleTap(el, msgId, ctx); |
| + else attachLongPress(el, msgId, ctx); |
| + } |
| + |
| + // Open the message menu at (x, y). Edit only appears on your own messages that |
| + // are still within the edit window — checked here (at open time), so it falls |
| + // away as the window lapses. |
| + function openMessageMenu(x, y, msgId, ctx) { |
| + const canEdit = |
| + ctx.mine && Date.now() - ctx.createdAt * 1000 < EDIT_WINDOW_MS; |
| + openReactionPicker( |
| + x, |
| + y, |
| + (emoji) => toggleReaction(msgId, emoji), |
| + () => startReply(ctx.replyMeta), |
| + canEdit ? () => startEdit(msgId) : null |
| + ); |
| } |
| |
| // Double-tap to react (touch). Two taps within 320ms near the same spot. |
| - function attachDoubleTap(el, msgId, replyMeta) { |
| + function attachDoubleTap(el, msgId, ctx) { |
| let lastT = 0; |
| let lx = 0; |
| let ly = 0; |
| @@ -676,12 +774,7 @@ 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), |
| - () => startReply(replyMeta) |
| - ); |
| + openMessageMenu(e.clientX, e.clientY, msgId, ctx); |
| } else { |
| lastT = t; |
| lx = e.clientX; |
| @@ -691,7 +784,7 @@ async function start(session) { |
| } |
| |
| // Press-and-hold (mouse) on a message opens the reaction picker. |
| - function attachLongPress(el, msgId, replyMeta) { |
| + function attachLongPress(el, msgId, ctx) { |
| let timer = null; |
| let sx = 0; |
| let sy = 0; |
| @@ -711,12 +804,7 @@ async function start(session) { |
| timer = setTimeout(() => { |
| timer = null; |
| fired = true; |
| - openReactionPicker( |
| - e.clientX, |
| - e.clientY, |
| - (emoji) => toggleReaction(msgId, emoji), |
| - () => startReply(replyMeta) |
| - ); |
| + openMessageMenu(e.clientX, e.clientY, msgId, ctx); |
| }, 450); |
| }); |
| el.addEventListener("pointermove", (e) => { |
| @@ -927,6 +1015,8 @@ async function start(session) { |
| const msg = JSON.parse(ev.data); |
| if (msg.type === "msg") { |
| render(msg); |
| + } else if (msg.type === "edit") { |
| + applyEdit(msg); |
| } else if (msg.type === "reaction") { |
| reactionState.set(msg.message_id, msg.reactions || []); |
| renderReactionsFor(msg.message_id); |
| @@ -954,11 +1044,15 @@ async function start(session) { |
| const fileInput = document.getElementById("file-input"); |
| const previewBox = document.getElementById("composer-preview"); |
| const replyBox = document.getElementById("composer-reply"); |
| + const editBox = document.getElementById("composer-edit"); |
| |
| // 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; |
| + // The id of the message being edited, or null. Editing and replying are |
| + // mutually exclusive — starting one cancels the other. |
| + let editingId = null; |
| |
| // Begin replying to a message: stage the parent and show a cancelable banner |
| // above the composer. Independent of any staged attachment. |
| @@ -1005,6 +1099,56 @@ async function start(session) { |
| replyBox.innerHTML = ""; |
| } |
| |
| + // Begin editing your own (in-window) message: load its text into the composer |
| + // and show a cancelable banner. Sending replaces the message in place. |
| + function startEdit(id) { |
| + const payload = msgPayloads.get(id); |
| + if (!payload) return; |
| + cancelReply(); |
| + editingId = id; |
| + input.value = payload.text || ""; |
| + autosize(); |
| + syncSendBtn(); |
| + editBox.classList.remove("hidden"); |
| + editBox.innerHTML = ""; |
| + const bar = document.createElement("div"); |
| + bar.className = "reply-bar"; |
| + const icon = document.createElement("span"); |
| + icon.className = "reply-bar-icon"; |
| + icon.innerHTML = ICON_EDIT; |
| + const body = document.createElement("div"); |
| + body.className = "reply-bar-body"; |
| + const who = document.createElement("div"); |
| + who.className = "reply-bar-name"; |
| + who.textContent = "Editing message"; |
| + const txt = document.createElement("div"); |
| + txt.className = "reply-bar-text"; |
| + txt.textContent = replySnippet(payload); |
| + 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 edit"); |
| + x.onclick = cancelEdit; |
| + bar.appendChild(icon); |
| + bar.appendChild(body); |
| + bar.appendChild(x); |
| + editBox.appendChild(bar); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| + } |
| + |
| + function cancelEdit() { |
| + editingId = null; |
| + editBox.classList.add("hidden"); |
| + editBox.innerHTML = ""; |
| + input.value = ""; |
| + autosize(); |
| + syncSendBtn(); |
| + } |
| + |
| const syncSendBtn = () => { |
| sendBtn.hidden = input.value.trim() === "" && !pendingAttachment; |
| }; |
| @@ -1201,7 +1345,26 @@ async function start(session) { |
| |
| const send = async () => { |
| const text = input.value.trim(); |
| - if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| + |
| + // Edit path: replace an existing message's text in place. Re-check the window |
| + // here (it may have lapsed while typing) so we don't silently drop the edit — |
| + // the server would reject it and no echo would come back. |
| + if (editingId != null) { |
| + const orig = msgPayloads.get(editingId); |
| + if (!text || !orig) return; // editing can't blank a message (text-only v1) |
| + if (Date.now() - (msgCreatedAt.get(editingId) || 0) * 1000 >= EDIT_WINDOW_MS) { |
| + toast("Too late to edit this message"); |
| + return; // keep the composer populated so nothing is lost |
| + } |
| + const id = editingId; |
| + const blob = await encrypt(key, { ...orig, text }); |
| + cancelEdit(); |
| + ws.send(JSON.stringify({ type: "edit", message_id: id, blob })); |
| + return; |
| + } |
| + |
| + if (!text && !pendingAttachment) 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 |
| @@ -1269,3 +1432,13 @@ function showBanner(text, warn) { |
| b.classList.toggle("hidden", !text); |
| b.classList.toggle("warn", !!warn); |
| } |
| + |
| +let toastTimer; |
| +function toast(msg) { |
| + const t = document.getElementById("toast"); |
| + if (!t) return; |
| + t.textContent = msg; |
| + t.classList.add("show"); |
| + clearTimeout(toastTimer); |
| + toastTimer = setTimeout(() => t.classList.remove("show"), 1800); |
| +} |