web: stamp reactions with myUserId and pass identity ctx to folds — verified in-browser (#523)
Two more breaks in the encrypted-reactions path (#393), found by driving the
app in Chromium against a local build:
1. The react bridge stamped uid: session.uid — a field that is never assigned
anywhere (identity lives in the module-level myUserId, roster-matched by
pubkey; every other outbound payload stamps that). The reaction event went
out uid-less, and the chat.reaction fold's guard drops uid-less events, so
every client silently discarded every reaction. Stamp myUserId, and skip
the send when it's empty (a uid-less reaction can't render or be
un-reacted). Same fix for the SW's myUid in IndexedDB (sw.js uses it to
show reaction pushes only to the reacted-to author) — it read session.uid
too, so it was never stored.
2. makeReducer called fold handlers with (state, event, meta) — but the
chat.reaction handler (and the event-vectors spec, whose web test
reimplements the fold rather than exercising this path) expects a 4th ctx
arg carrying ctx.me, from which the reaction's mine flag derives. ctx was
always undefined, so after a reload no reaction was yours: the chip lost
its 'mine' highlight and tapping it re-reacted instead of un-reacting
(masked live by the optimistic myReactions set). makeReducer now passes a
minimal identity ctx { me, meName } — identity only, reducers stay pure.
Verified end-to-end in Chromium (Playwright) against a local build: react →
chip renders live; reload → chip persists and is marked mine; tap again →
un-reacts and the chip disappears; no console errors.
Co-authored-by: Claude <noreply@anthropic.com>
2 files changed,
+10
−3
web/static/apps.js+4 −1web/static/shell.js+6 −2
diff --git a/web/static/apps.js b/web/static/apps.js| index 14de905..7a9b1e8 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -228,7 +228,10 @@ function makeReducer(events, initialState) { |
| return (state, event, meta) => { |
| const s = state === undefined ? seed() : state; |
| const handler = events[event.type]; |
| - return handler ? handler(s, event, meta || {}) : s; |
| + // The 4th arg is a minimal identity ctx: some folds derive per-user state |
| + // (chat.reaction's mine flag is uid === ctx.me). Only identity — a reducer |
| + // must stay pure, so no emit/schedule/getState here. |
| + return handler ? handler(s, event, meta || {}, { me: currentUser, meName: currentUserName }) : s; |
| }; |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index 4f261f6..6ac8168 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1410,7 +1410,7 @@ function syncThreadKeysToSW() { |
| idbSet("threadKeys", map).catch(() => {}); |
| // The SW also needs to know who "me" is: reaction pushes are shown only to |
| // the reacted-to message's author (see sw.js), decided after decryption. |
| - if (session && session.uid) idbSet("myUid", session.uid).catch(() => {}); |
| + if (myUserId) idbSet("myUid", myUserId).catch(() => {}); |
| } |
| // The frontend build this page loaded with (server-injected into the HTML). |
| @@ -3111,9 +3111,13 @@ setChatAppHost({ |
| emoji, |
| on, |
| name: session.name || "", |
| - uid: session.uid, |
| + uid: myUserId, |
| ts: Date.now(), |
| }; |
| + // The fold (chat.js "chat.reaction") keys reactions by (emoji, uid) and |
| + // drops uid-less events — without an identity the reaction can't render |
| + // or be un-reacted, so don't send one. |
| + if (!myUserId) return false; |
| // The reacted-to message's author, riding inside the ciphertext: push |
| // handlers (sw.js, the iOS NSE) use it to show the reaction only to the |
| // author — the server never learns whose message it was. Absent for |