| index 3698e9a..2c752ca 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -37,7 +37,7 @@ import { |
| sanitizeActions, |
| memberCountLabel, |
| } from "/static/ui.js"; |
| -import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| +import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| import { |
| @@ -2524,6 +2524,87 @@ async function sendChatText(threadId, text) { |
| return true; |
| } |
| |
| +// Blobs we've sent over the socket and are waiting to see echoed back, keyed by |
| +// the exact blob string → a resolver. The server stores a message before it |
| +// broadcasts (hub.go), so the echo is proof of a durable write; announceLeave uses |
| +// this to land the "X left" line before /leave drops our membership. |
| +const pendingSends = new Map(); |
| + |
| +// Send a message and resolve once the server echoes it back (durably stored) — or |
| +// after a timeout, so a WS hiccup can never hang the caller. |
| +function sendAndConfirm(threadId, blob) { |
| + return new Promise((resolve) => { |
| + let done = false; |
| + const finish = () => { |
| + if (done) return; |
| + done = true; |
| + pendingSends.delete(blob); |
| + resolve(); |
| + }; |
| + pendingSends.set(blob, finish); |
| + setTimeout(finish, 2000); |
| + try { |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| + } catch { |
| + finish(); // socket died mid-send — resolve now, don't leak the pending entry |
| + } |
| + }); |
| +} |
| + |
| +// Announce that people were just added to a group: a centered .chatapp-action |
| +// line, sent as a normal thread-key-encrypted message so it persists in history |
| +// and renders for every member (including the newcomers, from stored history on |
| +// their first load) — the server never sees the names. We send the added members' |
| +// user *ids* (plus our own), not baked names, so each recipient renders the line |
| +// from their own point of view — "Paul added you and Bob" (membershipLine, applied |
| +// at fold time). The baked `text` is a fallback for surfaces that don't run that |
| +// rewrite (native clients, the sidebar preview, push). The `action` flag routes it |
| +// through the action-line path in apps/chat.js rather than rendering as a bubble. |
| +async function announceJoin(threadId, uids) { |
| + const key = threadKeys[threadId]; |
| + if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + const payload = { name: session.name, text: `${session.name} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true }; |
| + if (myUserId) payload.uid = myUserId; |
| + const blob = await encrypt(key, payload); |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| +} |
| + |
| +// Announce that we're leaving a group, as our final act before the server drops |
| +// our membership. Same shape as announceJoin (recipients render "Paul left"). The |
| +// server gates every msg on membership, so we must get this stored BEFORE /leave — |
| +// and the two travel on different channels (WebSocket vs HTTP), so we await the |
| +// echo (proof of the write) with sendAndConfirm's timeout guard rather than firing |
| +// and hoping it wins the race. |
| +function announceLeave(threadId) { |
| + const key = threadKeys[threadId]; |
| + if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(); |
| + const payload = { name: session.name, text: `${session.name} left`, left: true, ts: Date.now(), action: true }; |
| + if (myUserId) payload.uid = myUserId; |
| + return encrypt(key, payload).then((blob) => sendAndConfirm(threadId, blob)); |
| +} |
| + |
| +// Announce that someone (a person or a bot) was removed from a group ("Paul |
| +// removed Bob"). Like announceJoin, sent as a normal thread-key-encrypted message |
| +// carrying the removed members' ids so each recipient renders it locally. No |
| +// leave-style echo race: the remover stays a member. The removed member is no |
| +// longer in the thread, so the server won't deliver it to them. |
| +async function announceRemove(threadId, uids) { |
| + const key = threadKeys[threadId]; |
| + if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + const payload = { name: session.name, text: `${session.name} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true }; |
| + if (myUserId) payload.uid = myUserId; |
| + const blob = await encrypt(key, payload); |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| +} |
| + |
| +// Build the chat.msg fold event for a plain message. Membership announcements |
| +// (join/leave) get their text rewritten to the viewer's point of view here, so the |
| +// live and history fold paths share one rewrite and can't drift. |
| +function chatMsgEvent(payload) { |
| + const line = membershipLine(payload, myUserId, rosterLabel); |
| + return line ? { ...payload, type: "chat.msg", text: line } : { ...payload, type: "chat.msg" }; |
| +} |
| + |
| // createAppThread makes a personal app's single-member thread: a `self` thread |
| // whose name_blob is the ENCRYPTED app id, wrapped only to us. The server sees |
| // just another opaque self thread — it never learns which app it hosts. |
| @@ -2608,7 +2689,7 @@ async function chatAppFoldMessage(threadId, m) { |
| return; |
| } |
| if (data.type || (!data.text && !data.attachments)) return; |
| - foldHostEvent(threadId, "chat", { ...data, type: "chat.msg" }, { id: m.id, ts: data.ts || m.created_at * 1000 }); |
| + foldHostEvent(threadId, "chat", chatMsgEvent(data), { id: m.id, ts: data.ts || m.created_at * 1000 }); |
| if (m.reactions && m.reactions.length) { |
| foldHostEvent(threadId, "chat", { type: "chat.react", msgId: m.id, reactions: m.reactions }); |
| } |
| @@ -3723,6 +3804,10 @@ function connectWS() { |
| } |
| if (msg.type !== "msg") return; |
| const tid = msg.thread_id; |
| + // Echo of a message we're awaiting confirmation for (the server stores before |
| + // it broadcasts, so seeing it back means it's durably persisted). Resolve it |
| + // before any early return below, so announceLeave can proceed to /leave. |
| + if (msg.blob && pendingSends.has(msg.blob)) pendingSends.get(msg.blob)(); |
| // A message in a DM we thought was pending means the invited person has joined |
| // and started chatting — refresh roster + threads so their identity lands and the |
| // "waiting to join" state clears (only they can post here until they've joined). |
| @@ -3800,7 +3885,7 @@ function connectWS() { |
| // app-thread's chat view — it's a regular DM, so notes you type there just work |
| // (folded + rendered), whether or not its bot ever replies. |
| if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) { |
| - foldHostEvent(tid, "chat", { ...payload, type: "chat.msg" }, { id: msg.id, ts: payload.ts }); |
| + foldHostEvent(tid, "chat", chatMsgEvent(payload), { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |
| } |
| if (tid !== activeId) { |
| @@ -4208,6 +4293,10 @@ async function removeMember(uid, name, backdrop) { |
| alert("Couldn't remove them. Try again."); |
| return; |
| } |
| + // Announce the removal as a centered action line ("Paul removed Bob"). We stay a |
| + // member, so no echo race — but send before loadThreads so it goes out while the |
| + // thread state is still intact. |
| + await announceRemove(id, [uid]); |
| if (backdrop) backdrop.remove(); |
| await loadThreads(); // refresh members + the (possibly member-derived) name |
| renderThreadList(); |
| @@ -4322,6 +4411,7 @@ function addPeople() { |
| add.disabled = true; |
| try { |
| const b64 = threadKeyB64[activeId]; |
| + const added = []; |
| for (const uid of selected) { |
| const e = roster.find((x) => x.user_id === uid); |
| const wrapped = await wrapThreadKey(b64, e.public_key); |
| @@ -4331,7 +4421,12 @@ function addPeople() { |
| body: JSON.stringify({ token: session.token, user_id: uid, wrapped }), |
| }); |
| if (!r.ok) throw new Error("server"); |
| + added.push(uid); |
| } |
| + // Announce the additions in the thread as a centered action line. We pass |
| + // user ids (not names) so each member sees it from their own view — a |
| + // newcomer reads "Paul added you and Bob". |
| + if (added.length) await announceJoin(activeId, added); |
| backdrop.remove(); |
| await loadThreads(); |
| renderThreadList(); |
| @@ -4350,11 +4445,19 @@ function addPeople() { |
| // Set a group photo: downscaled, encrypted with the THREAD key (so only members |
| // can see it), uploaded, and pointed at via the thread's avatar_blob. |
| async function leaveGroup() { |
| - const t = threads.find((x) => x.id === activeId); |
| + // Capture the thread up front: announceLeave awaits, and activeId is mutable |
| + // module state — the user could navigate away mid-leave, so every step below |
| + // must target this id, not whatever's active by the time the await resolves. |
| + const id = activeId; |
| + const t = threads.find((x) => x.id === id); |
| if (!t || t.kind !== "group") return; |
| - if (!confirm(`Leave "${threadName[activeId] || "this group"}"? You won't get new messages.`)) return; |
| + if (!confirm(`Leave "${threadName[id] || "this group"}"? You won't get new messages.`)) return; |
| + // Leave a parting "Paul left" action line — sent (and confirmed stored) while |
| + // we're still a member, since the server drops our posts the moment we leave. |
| + // Best-effort: a cosmetic line must never block or abort the actual leave. |
| + await announceLeave(id).catch(() => {}); |
| try { |
| - const r = await fetch(`/api/threads/${encodeURIComponent(activeId)}/leave`, { |
| + const r = await fetch(`/api/threads/${encodeURIComponent(id)}/leave`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ token: session.token }), |
| @@ -4363,14 +4466,16 @@ async function leaveGroup() { |
| } catch { |
| return alert("Couldn't leave the group. Try again."); |
| } |
| - threads = threads.filter((x) => x.id !== activeId); |
| - delete threadKeys[activeId]; |
| - activeId = null; |
| - localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone |
| + threads = threads.filter((x) => x.id !== id); |
| + delete threadKeys[id]; |
| + if (activeId === id) { |
| + activeId = null; |
| + localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone |
| + document.getElementById("thread-view").classList.add("hidden"); |
| + document.getElementById("thread-empty").classList.remove("hidden"); |
| + showList(); |
| + } |
| renderThreadList(); |
| - document.getElementById("thread-view").classList.add("hidden"); |
| - document.getElementById("thread-empty").classList.remove("hidden"); |
| - showList(); |
| } |
| |
| // ---- new chat (roster picker) ------------------------------------------- |