| index 9f96200..e7f43d9 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,4 +1,8 @@ |
| -import { importKey, encrypt, decrypt } from "/static/crypto.js"; |
| +import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js"; |
| +import { shouldSend } from "/static/ui.js"; |
| +import { tokenizeText, formatBytes } from "/static/format.js"; |
| + |
| +const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go) |
| import { |
| needsHomeScreen, |
| pushSupported, |
| @@ -13,6 +17,9 @@ import { |
| |
| const SESSION_KEY = "lchat_session"; |
| |
| +// The fixed reaction set, matching the server allowlist (hub.go). |
| +const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; |
| + |
| const app = document.getElementById("app"); |
| const session = loadSession(); |
| |
| @@ -91,11 +98,25 @@ function onboardPush(session) { |
| resolve(); |
| }; |
| const enable = document.getElementById("push-enable"); |
| + const note = document.getElementById("push-note"); |
| enable.onclick = async () => { |
| enable.disabled = true; |
| enable.textContent = "Enabling…"; |
| - await subscribePush(session); |
| - finish(); |
| + const result = await subscribePush(session); |
| + if (result === "on") { |
| + finish(); |
| + return; |
| + } |
| + // Didn't actually turn on — don't pretend it did. Let them retry or skip. |
| + enable.disabled = false; |
| + enable.textContent = "Try again"; |
| + if (note) { |
| + note.textContent = |
| + result === "denied" |
| + ? "Notifications are blocked. You can turn them on later from the ⋮ menu." |
| + : "Couldn't turn on notifications just now. Try again, or enable them later from the ⋮ menu."; |
| + note.classList.remove("hidden"); |
| + } |
| }; |
| document.getElementById("push-skip").onclick = finish; |
| }); |
| @@ -311,6 +332,14 @@ async function start(session) { |
| const seen = new Set(); |
| let lastId = 0; |
| |
| + // Reaction state, keyed by message id: |
| + // reactionState: latest [{emoji, count}] from the server |
| + // myReactions: Set of emojis *this* user has applied (drives the toggle UI) |
| + // reactionEls: the per-message DOM container to repaint |
| + const reactionState = new Map(); |
| + const myReactions = new Map(); |
| + const reactionEls = new Map(); |
| + |
| async function render(msg) { |
| if (seen.has(msg.id)) return; |
| seen.add(msg.id); |
| @@ -321,6 +350,14 @@ async function start(session) { |
| } catch { |
| return; // wrong key or corrupt — skip |
| } |
| + |
| + // Seed reaction state from history (live "msg" frames carry none yet). |
| + if (Array.isArray(msg.reactions)) { |
| + reactionState.set(msg.id, msg.reactions.map((r) => ({ emoji: r.emoji, count: r.count }))); |
| + const mineSet = new Set(msg.reactions.filter((r) => r.mine).map((r) => r.emoji)); |
| + if (mineSet.size) myReactions.set(msg.id, mineSet); |
| + } |
| + |
| const mine = payload.name === session.name; |
| const el = document.createElement("div"); |
| el.className = "msg " + (mine ? "me" : "them"); |
| @@ -330,10 +367,40 @@ async function start(session) { |
| n.textContent = payload.name; |
| el.appendChild(n); |
| } |
| - const bubble = document.createElement("div"); |
| - bubble.className = "bubble"; |
| - bubble.textContent = payload.text; |
| - el.appendChild(bubble); |
| + |
| + const bubbleRow = document.createElement("div"); |
| + bubbleRow.className = "bubble-row"; |
| + |
| + const content = document.createElement("div"); |
| + content.className = "content"; |
| + 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)); |
| + } |
| + bubbleRow.appendChild(content); |
| + |
| + const reactBtn = document.createElement("button"); |
| + reactBtn.type = "button"; |
| + reactBtn.className = "react-btn"; |
| + reactBtn.setAttribute("aria-label", "Add reaction"); |
| + reactBtn.textContent = "🙂"; |
| + reactBtn.onclick = (e) => { |
| + e.stopPropagation(); |
| + openReactionPicker(reactBtn, (emoji) => toggleReaction(msg.id, emoji)); |
| + }; |
| + bubbleRow.appendChild(reactBtn); |
| + el.appendChild(bubbleRow); |
| + |
| + const reactions = document.createElement("div"); |
| + reactions.className = "reactions"; |
| + el.appendChild(reactions); |
| + reactionEls.set(msg.id, reactions); |
| + |
| const t = document.createElement("div"); |
| t.className = "time"; |
| t.textContent = formatTime(payload.ts || msg.created_at * 1000); |
| @@ -341,9 +408,178 @@ async function start(session) { |
| |
| const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| feed.appendChild(el); |
| + renderReactionsFor(msg.id); |
| if (atBottom || mine) feed.scrollTop = feed.scrollHeight; |
| } |
| |
| + // Repaint the reaction chips for one message from current state. |
| + function renderReactionsFor(msgId) { |
| + const container = reactionEls.get(msgId); |
| + if (!container) return; |
| + const list = reactionState.get(msgId) || []; |
| + const mineSet = myReactions.get(msgId) || new Set(); |
| + container.innerHTML = ""; |
| + for (const r of list) { |
| + if (!r.count) continue; |
| + const chip = document.createElement("button"); |
| + chip.type = "button"; |
| + chip.className = "reaction-chip" + (mineSet.has(r.emoji) ? " mine" : ""); |
| + chip.textContent = r.emoji + " " + r.count; |
| + chip.onclick = () => toggleReaction(msgId, r.emoji); |
| + container.appendChild(chip); |
| + } |
| + } |
| + |
| + // Toggle my reaction: update local state optimistically, then tell the server. |
| + // The authoritative counts come back via the "reaction" broadcast. |
| + function toggleReaction(msgId, emoji) { |
| + if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| + let mineSet = myReactions.get(msgId); |
| + if (!mineSet) { |
| + mineSet = new Set(); |
| + myReactions.set(msgId, mineSet); |
| + } |
| + const adding = !mineSet.has(emoji); |
| + let list = reactionState.get(msgId); |
| + if (!list) { |
| + list = []; |
| + reactionState.set(msgId, list); |
| + } |
| + const idx = list.findIndex((r) => r.emoji === emoji); |
| + if (adding) { |
| + mineSet.add(emoji); |
| + if (idx >= 0) list[idx].count++; |
| + else list.push({ emoji, count: 1 }); |
| + } else { |
| + mineSet.delete(emoji); |
| + if (idx >= 0) { |
| + list[idx].count--; |
| + if (list[idx].count <= 0) list.splice(idx, 1); |
| + } |
| + } |
| + renderReactionsFor(msgId); |
| + ws.send(JSON.stringify({ type: adding ? "react" : "unreact", message_id: msgId, emoji })); |
| + } |
| + |
| + let pickerEl = null; |
| + let pickerCloser = null; |
| + function closeReactionPicker() { |
| + if (pickerEl) pickerEl.remove(); |
| + pickerEl = null; |
| + if (pickerCloser) { |
| + document.removeEventListener("click", pickerCloser); |
| + document.removeEventListener("scroll", pickerCloser, true); |
| + pickerCloser = null; |
| + } |
| + } |
| + function openReactionPicker(anchorEl, onPick) { |
| + closeReactionPicker(); |
| + pickerEl = document.createElement("div"); |
| + pickerEl.className = "emoji-picker"; |
| + for (const e of REACTIONS) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "emoji-opt"; |
| + b.textContent = e; |
| + b.onclick = (ev) => { |
| + ev.stopPropagation(); |
| + onPick(e); |
| + closeReactionPicker(); |
| + }; |
| + pickerEl.appendChild(b); |
| + } |
| + document.body.appendChild(pickerEl); |
| + const r = anchorEl.getBoundingClientRect(); |
| + let top = r.top - pickerEl.offsetHeight - 8; |
| + if (top < 8) top = r.bottom + 8; |
| + let left = Math.min(r.left, window.innerWidth - pickerEl.offsetWidth - 8); |
| + left = Math.max(8, left); |
| + pickerEl.style.top = top + "px"; |
| + pickerEl.style.left = left + "px"; |
| + pickerCloser = (ev) => { |
| + if (pickerEl && !pickerEl.contains(ev.target)) closeReactionPicker(); |
| + }; |
| + setTimeout(() => { |
| + document.addEventListener("click", pickerCloser); |
| + document.addEventListener("scroll", pickerCloser, true); |
| + }, 0); |
| + } |
| + |
| + // ---- attachments ---- |
| + async function fetchBlob(id) { |
| + const res = await fetch( |
| + `/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}` |
| + ); |
| + if (!res.ok) return null; |
| + return new Uint8Array(await res.arrayBuffer()); |
| + } |
| + |
| + // Build the element for one attachment. Images render inline (decrypted to an |
| + // object URL); everything else becomes a download chip. |
| + function renderAttachment(att) { |
| + if (att.type && att.type.startsWith("image/")) { |
| + const wrap = document.createElement("div"); |
| + wrap.className = "msg-image-wrap"; |
| + const img = document.createElement("img"); |
| + img.className = "msg-image"; |
| + img.alt = att.name || "image"; |
| + if (att.w && att.h) img.style.aspectRatio = att.w + " / " + att.h; |
| + loadAttachmentImage(att, img); |
| + wrap.appendChild(img); |
| + return wrap; |
| + } |
| + const a = document.createElement("a"); |
| + a.className = "file-chip"; |
| + a.href = "#"; |
| + a.textContent = |
| + "📎 " + (att.name || "file") + (att.size ? " · " + formatBytes(att.size) : ""); |
| + a.onclick = (e) => { |
| + e.preventDefault(); |
| + downloadAttachment(att); |
| + }; |
| + return a; |
| + } |
| + |
| + async function loadAttachmentImage(att, img) { |
| + try { |
| + const bytes = await fetchBlob(att.id); |
| + if (!bytes) return; |
| + const plain = await decryptBytes(key, bytes); |
| + const url = URL.createObjectURL( |
| + new Blob([plain], { type: att.type || "application/octet-stream" }) |
| + ); |
| + const wasAtBottom = |
| + feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; |
| + img.onload = () => { |
| + if (wasAtBottom) feed.scrollTop = feed.scrollHeight; |
| + }; |
| + img.src = url; |
| + } catch { |
| + // decryption failed (wrong key / corrupt) — leave the placeholder |
| + } |
| + } |
| + |
| + async function downloadAttachment(att) { |
| + const bytes = await fetchBlob(att.id); |
| + if (!bytes) return; |
| + let plain; |
| + try { |
| + plain = await decryptBytes(key, bytes); |
| + } catch { |
| + return; |
| + } |
| + const url = URL.createObjectURL( |
| + new Blob([plain], { type: att.type || "application/octet-stream" }) |
| + ); |
| + const a = document.createElement("a"); |
| + a.href = url; |
| + a.download = att.name || "download"; |
| + document.body.appendChild(a); |
| + a.click(); |
| + a.remove(); |
| + setTimeout(() => URL.revokeObjectURL(url), 10000); |
| + } |
| + |
| // 1) Load recent history. |
| const history = await fetchMessages(session.token, 0); |
| for (const m of history) await render(m); |
| @@ -363,7 +599,12 @@ async function start(session) { |
| ws.onmessage = (ev) => { |
| try { |
| const msg = JSON.parse(ev.data); |
| - if (msg.type === "msg") render(msg); |
| + if (msg.type === "msg") { |
| + render(msg); |
| + } else if (msg.type === "reaction") { |
| + reactionState.set(msg.message_id, msg.reactions || []); |
| + renderReactionsFor(msg.message_id); |
| + } |
| } catch {} |
| }; |
| ws.onclose = () => { |
| @@ -374,24 +615,135 @@ async function start(session) { |
| } |
| connect(); |
| |
| - // 3) Sending. Enter sends; Shift+Enter inserts a newline (it's a textarea). |
| + // 3) Sending. Desktop: Enter sends, Shift+Enter = newline. Mobile/touch: |
| + // Enter = newline, the on-screen button sends (see shouldSend). |
| const form = document.getElementById("composer"); |
| const input = document.getElementById("text"); |
| + const sendBtn = document.getElementById("send-btn"); |
| + const attachBtn = document.getElementById("attach-btn"); |
| + const fileInput = document.getElementById("file-input"); |
| + const previewBox = document.getElementById("composer-preview"); |
| + const coarse = |
| + window.matchMedia && window.matchMedia("(pointer: coarse)").matches; |
| + |
| + // The attachment staged for the next send (uploaded already), or null. |
| + let pendingAttachment = null; |
| + |
| + const syncSendBtn = () => { |
| + sendBtn.hidden = input.value.trim() === "" && !pendingAttachment; |
| + }; |
| const autosize = () => { |
| input.style.height = "auto"; |
| input.style.height = Math.min(input.scrollHeight, 140) + "px"; |
| }; |
| + |
| + function clearPreview() { |
| + pendingAttachment = null; |
| + previewBox.classList.add("hidden"); |
| + previewBox.innerHTML = ""; |
| + syncSendBtn(); |
| + } |
| + |
| + function showPreview(file, uploading) { |
| + previewBox.classList.remove("hidden"); |
| + previewBox.innerHTML = ""; |
| + const chip = document.createElement("div"); |
| + chip.className = "preview-chip"; |
| + if (file.type && file.type.startsWith("image/")) { |
| + const img = document.createElement("img"); |
| + img.src = URL.createObjectURL(file); |
| + chip.appendChild(img); |
| + } else { |
| + const ic = document.createElement("div"); |
| + ic.className = "preview-file"; |
| + ic.textContent = "📎"; |
| + chip.appendChild(ic); |
| + } |
| + const label = document.createElement("div"); |
| + label.className = "preview-name"; |
| + label.textContent = uploading |
| + ? "Uploading… " + file.name |
| + : file.name + " · " + formatBytes(file.size); |
| + chip.appendChild(label); |
| + if (!uploading) { |
| + const rm = document.createElement("button"); |
| + rm.type = "button"; |
| + rm.className = "preview-remove"; |
| + rm.textContent = "×"; |
| + rm.setAttribute("aria-label", "Remove attachment"); |
| + rm.onclick = clearPreview; |
| + chip.appendChild(rm); |
| + } |
| + previewBox.appendChild(chip); |
| + } |
| + |
| + // Encrypt and upload a file, returning its attachment metadata (or null). |
| + async function uploadFile(file) { |
| + const bytes = new Uint8Array(await file.arrayBuffer()); |
| + if (bytes.length > MAX_UPLOAD) { |
| + showBanner("That file is too big (max 10 MB).", true); |
| + return null; |
| + } |
| + const enc = await encryptBytes(key, bytes); |
| + const res = await fetch( |
| + `/api/upload?token=${encodeURIComponent(session.token)}`, |
| + { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: enc } |
| + ); |
| + if (!res.ok) return null; |
| + const { id } = await res.json(); |
| + const meta = { |
| + id, |
| + name: file.name || "file", |
| + type: file.type || "application/octet-stream", |
| + size: bytes.length, |
| + }; |
| + if (meta.type.startsWith("image/")) { |
| + try { |
| + const bmp = await createImageBitmap(file); |
| + meta.w = bmp.width; |
| + meta.h = bmp.height; |
| + if (bmp.close) bmp.close(); |
| + } catch {} |
| + } |
| + return meta; |
| + } |
| + |
| + attachBtn.onclick = () => fileInput.click(); |
| + fileInput.onchange = async () => { |
| + const file = fileInput.files[0]; |
| + fileInput.value = ""; // allow picking the same file again later |
| + if (!file) return; |
| + showBanner("", false); |
| + showPreview(file, true); |
| + const meta = await uploadFile(file); |
| + if (!meta) { |
| + clearPreview(); |
| + showBanner("Couldn't upload that file. Try again.", true); |
| + return; |
| + } |
| + pendingAttachment = meta; |
| + showPreview(file, false); |
| + syncSendBtn(); |
| + input.focus(); |
| + }; |
| + |
| const send = async () => { |
| const text = input.value.trim(); |
| - if (!text || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + const payload = { name: session.name, text, ts: Date.now() }; |
| + if (pendingAttachment) payload.attachments = [pendingAttachment]; |
| input.value = ""; |
| + clearPreview(); |
| autosize(); |
| - const blob = await encrypt(key, { name: session.name, text, ts: Date.now() }); |
| + const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", blob })); |
| }; |
| - input.addEventListener("input", autosize); |
| + input.addEventListener("input", () => { |
| + autosize(); |
| + syncSendBtn(); |
| + }); |
| input.addEventListener("keydown", (e) => { |
| - if (e.key === "Enter" && !e.shiftKey) { |
| + if (shouldSend(e, coarse)) { |
| e.preventDefault(); |
| send(); |
| } |
| @@ -400,6 +752,7 @@ async function start(session) { |
| e.preventDefault(); |
| send(); |
| }); |
| + syncSendBtn(); |
| input.focus(); |
| } |
| |
| @@ -415,6 +768,24 @@ function formatTime(ms) { |
| return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| } |
| |
| +// 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. |
| +function appendText(el, text) { |
| + for (const tok of tokenizeText(text || "")) { |
| + if (tok.href && /^https?:\/\//i.test(tok.href)) { |
| + const a = document.createElement("a"); |
| + a.href = tok.href; |
| + a.textContent = tok.text; |
| + a.target = "_blank"; |
| + a.rel = "noopener noreferrer"; |
| + el.appendChild(a); |
| + } else if (tok.text) { |
| + el.appendChild(document.createTextNode(tok.text)); |
| + } |
| + } |
| +} |
| + |
| function showBanner(text, warn) { |
| const b = document.getElementById("banner"); |
| if (!b) return; |