Reactions as encrypted events (fixes #230) (#393)
* reactions: migrate from plaintext metadata to encrypted events (fixes #230)
Reactions are now encrypted events (type="reaction") stored as messages, not
plaintext metadata. This enables attribution ("Alice reacted 👍") and fixes
issue #230 where reactions from other devices didn't show as "mine" until reload.
**Event Schema (chat.json):**
- Reaction events are encrypted payloads with targetSid, emoji, name, uid, on
- Clients decrypt and fold reactions into message reactions arrays
- Count and mine-flag are derived client-side (never transmitted)
**Server (Go):**
- Remove plaintext react/unreact WS frame handling
- Remove reactions table queries (reactionsForMessages, addReaction, etc.)
- Reaction events flow through message storage/broadcast like any message
- Strip Reactions field from ServerMessage struct
**Web (JavaScript):**
- Update chat.reaction fold handler to accumulate reaction events
- Derive count from events with on=true; derive mine from uid===me
- Host bridge sends reactions as encrypted messages via host.react()
- Remove old plaintext reaction frame handling (shell.js line 4543)
- Update toggleReaction() to handle async react call
**Native (Swift):**
- Add reaction event fields to MessagePayload (targetSid, emoji, on)
- Remove old Reactions field from ServerMessage struct
- Remove "reaction" WS frame from ChatSocket.Frame
- Update toggleReaction() to send encrypted reaction events
- Update apply() to handle reaction events in message fold
- Add applyReactionEvent() to update target message's reactions
**Tests:**
- Update event vectors in chat.json with new reaction schema
- Update EventVectorTests.swift to test reaction event deserialization
- Update apps.test.mjs to use chat.reaction fold events
**Data:** Old reaction history is lost (E2E constraint: server can't migrate).
Users can still react; it's just encrypted now.
* web+native: reaction pushes notify only the message author
Reactions ride the ordinary encrypted-message path, so the server pushes
them to every member — it cannot know whose message was reacted to. The
reacting client now stamps the target's author uid (targetUid) INSIDE the
encrypted payload, and each push surface decides after decrypting:
- web: sw.js shows "X reacted 👍 to your message" only when targetUid is
me (the page parks session.uid in IndexedDB beside the thread keys);
everyone else, un-reacts, and legacy uid-less targets stay silent.
- iOS: the NSE compares targetUid to the new SharedThreadKeys entry myUid.
The author gets a real banner; without Apple's filtering entitlement the
extension must ship something, so everyone else gets a passive, silent
entry (no sound, no banner). Previously every reaction rang "New message".
- Mac: local notifications drop non-author reaction events outright.
Also repairs the branch's native side: ChatFeedModel.swift was rewritten
against a stale pre-#423/#430 version (off-main crypto lane, reply-thread
rooting, native reminders, catchUpFromStore all reverted — it didn't
compile against its own merge-base siblings). Restored the merge-base file
and re-applied the reaction rework in its style: reaction events fold
keyed by (emoji, uid) with the tally derived from the on=true set — so
echoes and history re-pulls are idempotent — history pages fold their
reaction rows (reactions survive reload now), and the derived tally
mirrors into MessageStore.
Event vectors: reaction payloads gain targetUid; the web vector test now
actually exercises the new chat.reaction shape (the old chat.react block
silently skipped it), and native gains a foldReaction contract test.
Co-Authored-By: Claude <noreply@anthropic.com>
* server: drop the plaintext-reaction tests with the code they tested
TestReactions and TestMessagesIncludeReactions exercised addReaction/
reactionCounts/reactionsForMessages, which the reactions-as-events change
removed — the server no longer has any reaction concept; reaction events
are opaque messages covered by the existing message tests.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
20 files changed,
+538
−392
db.go+4 −93db_test.go+0 −41handlers_test.go+0 −47hub.go+1 −34ios/LChatCore/Sources/LChatCore/BackgroundSync.swift+3 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+148 −54ios/LChatCore/Sources/LChatCore/ChatSocket.swift+3 −4ios/LChatCore/Sources/LChatCore/Models.swift+18 −9ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift+5 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+2 −1ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+87 −9ios/LChatMac/NativeChatView.swift+14 −1ios/LChatNSE/NotificationService.swift+31 −0main.go+0 −12test/apps.test.mjs+12 −9test/event-vectors.test.mjs+56 −15test/event-vectors/chat.json+59 −31web/static/apps/chat.js+52 −13web/static/shell.js+31 −17web/static/sw.js+12 −1
diff --git a/db.go b/db.go| index fc9937c..dfc24ab 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -2029,11 +2029,10 @@ func (db *DB) cancelUserInvite(threadID, inviterID string) (bool, error) { |
| // ---- messages ----------------------------------------------------------- |
| type Message struct { |
| - ID int64 `json:"id"` |
| - CreatedAt int64 `json:"created_at"` |
| - Blob string `json:"blob"` |
| - EditedAt int64 `json:"edited_at,omitempty"` |
| - Reactions []ReactionView `json:"reactions,omitempty"` |
| + ID int64 `json:"id"` |
| + CreatedAt int64 `json:"created_at"` |
| + Blob string `json:"blob"` |
| + EditedAt int64 `json:"edited_at,omitempty"` |
| } |
| func (db *DB) insertMessage(threadID, blob, authorHash string) (Message, error) { |
| @@ -2917,91 +2916,3 @@ func (db *DB) deleteMemberBot(id string) (bool, error) { |
| // the server can count and de-duplicate them; message content stays encrypted. |
| // The token identifies the reactor for toggling, never their screen name. |
| -// ReactionCount is an emoji and how many people used it (for live broadcast). |
| -type ReactionCount struct { |
| - Emoji string `json:"emoji"` |
| - Count int `json:"count"` |
| -} |
| - |
| -// ReactionView adds whether the requesting token is one of the reactors. |
| -type ReactionView struct { |
| - Emoji string `json:"emoji"` |
| - Count int `json:"count"` |
| - Mine bool `json:"mine"` |
| -} |
| - |
| -// Reactions are keyed by `actor` — a stable per-user id, so all a person's |
| -// devices share one reaction (and can't double-count). Stored in the legacy |
| -// `token` column. |
| -func (db *DB) addReaction(messageID int64, actor, emoji string) error { |
| - _, err := db.sql.Exec( |
| - "INSERT OR IGNORE INTO reactions(message_id, token, emoji, created_at) VALUES(?, ?, ?, ?)", |
| - messageID, actor, emoji, time.Now().Unix(), |
| - ) |
| - return err |
| -} |
| - |
| -func (db *DB) removeReaction(messageID int64, actor, emoji string) error { |
| - _, err := db.sql.Exec( |
| - "DELETE FROM reactions WHERE message_id = ? AND token = ? AND emoji = ?", |
| - messageID, actor, emoji, |
| - ) |
| - return err |
| -} |
| - |
| -// reactionCounts returns the per-emoji totals for one message, oldest emoji first. |
| -func (db *DB) reactionCounts(messageID int64) ([]ReactionCount, error) { |
| - rows, err := db.sql.Query( |
| - "SELECT emoji, COUNT(*) FROM reactions WHERE message_id = ? GROUP BY emoji ORDER BY MIN(created_at)", |
| - messageID, |
| - ) |
| - if err != nil { |
| - return nil, err |
| - } |
| - defer rows.Close() |
| - out := []ReactionCount{} |
| - for rows.Next() { |
| - var rc ReactionCount |
| - if err := rows.Scan(&rc.Emoji, &rc.Count); err != nil { |
| - return nil, err |
| - } |
| - out = append(out, rc) |
| - } |
| - return out, rows.Err() |
| -} |
| - |
| -// reactionsForMessages returns per-message reaction views for the given message |
| -// ids, flagging which emojis the supplied token reacted with (for the toggle UI). |
| -func (db *DB) reactionsForMessages(ids []int64, actor string) (map[int64][]ReactionView, error) { |
| - out := map[int64][]ReactionView{} |
| - if len(ids) == 0 { |
| - return out, nil |
| - } |
| - ph := make([]string, len(ids)) |
| - args := make([]any, 0, len(ids)+1) |
| - args = append(args, actor) // first ? in the CASE expression (mine = this user) |
| - for i, id := range ids { |
| - ph[i] = "?" |
| - args = append(args, id) |
| - } |
| - q := "SELECT message_id, emoji, COUNT(*), " + |
| - "SUM(CASE WHEN token = ? THEN 1 ELSE 0 END) " + |
| - "FROM reactions WHERE message_id IN (" + strings.Join(ph, ",") + ") " + |
| - "GROUP BY message_id, emoji ORDER BY MIN(created_at)" |
| - rows, err := db.sql.Query(q, args...) |
| - if err != nil { |
| - return nil, err |
| - } |
| - defer rows.Close() |
| - for rows.Next() { |
| - var mid int64 |
| - var rv ReactionView |
| - var mine int |
| - if err := rows.Scan(&mid, &rv.Emoji, &rv.Count, &mine); err != nil { |
| - return nil, err |
| - } |
| - rv.Mine = mine > 0 |
| - out[mid] = append(out[mid], rv) |
| - } |
| - return out, rows.Err() |
| -} |
diff --git a/db_test.go b/db_test.go| index ae7712f..31a71de 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -205,47 +205,6 @@ func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) { |
| } |
| } |
| -func TestReactions(t *testing.T) { |
| - db := newTestDB(t) |
| - m, _ := db.insertMessage("t1", "blob", "author-a") |
| - |
| - // Two people add 👍; one adds ❤️. |
| - db.addReaction(m.ID, "tokA", "👍") |
| - db.addReaction(m.ID, "tokB", "👍") |
| - db.addReaction(m.ID, "tokA", "❤️") |
| - |
| - // Duplicate add is a no-op (composite PK). |
| - db.addReaction(m.ID, "tokA", "👍") |
| - |
| - counts, _ := db.reactionCounts(m.ID) |
| - got := map[string]int{} |
| - for _, c := range counts { |
| - got[c.Emoji] = c.Count |
| - } |
| - if got["👍"] != 2 || got["❤️"] != 1 { |
| - t.Fatalf("counts = %+v", got) |
| - } |
| - |
| - // "mine" is computed per-token. |
| - views, _ := db.reactionsForMessages([]int64{m.ID}, "tokB") |
| - for _, v := range views[m.ID] { |
| - if v.Emoji == "👍" && !v.Mine { |
| - t.Fatal("tokB should own 👍") |
| - } |
| - if v.Emoji == "❤️" && v.Mine { |
| - t.Fatal("tokB should NOT own ❤️") |
| - } |
| - } |
| - |
| - // Removing toggles it off. |
| - db.removeReaction(m.ID, "tokB", "👍") |
| - counts, _ = db.reactionCounts(m.ID) |
| - for _, c := range counts { |
| - if c.Emoji == "👍" && c.Count != 1 { |
| - t.Fatalf("after remove, 👍 count = %d", c.Count) |
| - } |
| - } |
| -} |
| func TestGroupName(t *testing.T) { |
| db := newTestDB(t) |
diff --git a/handlers_test.go b/handlers_test.go| index e2f4bec..82b4020 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -113,53 +113,6 @@ func TestMessagesThreadScoping(t *testing.T) { |
| } |
| } |
| -func TestMessagesIncludeReactions(t *testing.T) { |
| - ts, srv := newTestServer(t) |
| - tok, uid, tid := joinedMember(t, srv, "x") |
| - |
| - // Seed a message + reactions directly (they normally arrive over the WS). |
| - // Reactions are keyed by user id; uid's ❤️ should come back as "mine". |
| - m, _ := srv.db.insertMessage(tid, "ciphertext-blob", "author-x") |
| - srv.db.addReaction(m.ID, "someoneElse", "👍") |
| - srv.db.addReaction(m.ID, uid, "❤️") |
| - |
| - resp, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid) |
| - if resp.StatusCode != http.StatusOK { |
| - t.Fatalf("messages: %d", resp.StatusCode) |
| - } |
| - var out struct { |
| - Messages []struct { |
| - ID int64 `json:"id"` |
| - Reactions []struct { |
| - Emoji string `json:"emoji"` |
| - Count int `json:"count"` |
| - Mine bool `json:"mine"` |
| - } `json:"reactions"` |
| - } `json:"messages"` |
| - } |
| - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| - t.Fatalf("decode: %v", err) |
| - } |
| - if len(out.Messages) != 1 { |
| - t.Fatalf("want 1 message, got %d", len(out.Messages)) |
| - } |
| - seen := map[string]struct { |
| - count int |
| - mine bool |
| - }{} |
| - for _, r := range out.Messages[0].Reactions { |
| - seen[r.Emoji] = struct { |
| - count int |
| - mine bool |
| - }{r.Count, r.Mine} |
| - } |
| - if seen["👍"].count != 1 || seen["👍"].mine { |
| - t.Fatalf("👍 = %+v (want count 1, mine false)", seen["👍"]) |
| - } |
| - if seen["❤️"].count != 1 || !seen["❤️"].mine { |
| - t.Fatalf("❤️ = %+v (want count 1, mine true)", seen["❤️"]) |
| - } |
| -} |
| func TestKeyInviteFlow(t *testing.T) { |
| ts, _ := newTestServer(t) |
diff --git a/hub.go b/hub.go| index 39f1be4..595b421 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -178,13 +178,6 @@ func (s *Server) isUserActive(uid string) bool { |
| return false |
| } |
| -type wsReactionOut struct { |
| - Type string `json:"type"` |
| - ThreadID string `json:"thread_id,omitempty"` |
| - MessageID int64 `json:"message_id"` |
| - Reactions []ReactionCount `json:"reactions"` |
| -} |
| - |
| // wsTypingOut relays a typing indicator. Like everything else it carries only an |
| // opaque blob (the sender's name + expiry ride encrypted inside) — the server |
| // never stores it and never pushes it; it's a transient presence hint. |
| @@ -203,12 +196,6 @@ type wsReadOut struct { |
| LastReadAt int64 `json:"last_read_at"` |
| } |
| -// allowedReactions is the fixed emoji set the UI offers. Restricting it keeps |
| -// the (plaintext) emoji column to known values — no arbitrary strings. |
| -var allowedReactions = map[string]bool{ |
| - "👍": true, "❤️": true, "😂": true, "😮": true, "😢": true, "🎉": true, |
| -} |
| - |
| func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| token := r.URL.Query().Get("token") |
| if !s.tokenOK(token) { |
| @@ -270,7 +257,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| s.markActive(token) // this device is open + focused; mute its siblings' push |
| continue |
| } |
| - if in.Type != "msg" && in.Type != "edit" && in.Type != "delete" && in.Type != "react" && in.Type != "unreact" && in.Type != "typing" { |
| + if in.Type != "msg" && in.Type != "edit" && in.Type != "delete" && in.Type != "typing" { |
| continue |
| } |
| // Re-check the credential so a revoked invite / removed device can't keep |
| @@ -364,26 +351,6 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| out, _ := json.Marshal(wsOut{Type: "delete", ThreadID: in.ThreadID, ID: in.MessageID}) |
| s.broadcastToThread(in.ThreadID, out) |
| - case "react", "unreact": |
| - if in.MessageID <= 0 || !allowedReactions[in.Emoji] || |
| - !s.db.messageInThread(in.MessageID, in.ThreadID) { |
| - continue |
| - } |
| - if in.Type == "react" { |
| - err = s.db.addReaction(in.MessageID, userID, in.Emoji) |
| - } else { |
| - err = s.db.removeReaction(in.MessageID, userID, in.Emoji) |
| - } |
| - if err != nil { |
| - log.Println("reaction:", err) |
| - continue |
| - } |
| - counts, err := s.db.reactionCounts(in.MessageID) |
| - if err != nil { |
| - continue |
| - } |
| - out, _ := json.Marshal(wsReactionOut{Type: "reaction", ThreadID: in.ThreadID, MessageID: in.MessageID, Reactions: counts}) |
| - s.broadcastToThread(in.ThreadID, out) |
| } |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift| index 7266076..072e6ce 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift |
| @@ -47,9 +47,11 @@ public enum BackgroundSync { |
| let batch: [MessageStore.StoredMessage] = rows.compactMap { row in |
| guard let p = ChatFeedModel.renderable(row.blob, key: key, |
| group: t.kind == "group") else { return nil } |
| + // Reactions are encrypted events now — rows carry no tally; |
| + // the feed derives it by folding the events on load. |
| return .init(id: row.id, at: row.created_at, |
| edited: (row.edited_at ?? 0) > 0, |
| - reactions: row.reactions ?? [], payload: p) |
| + reactions: [], payload: p) |
| } |
| guard !batch.isEmpty else { continue } |
| await MessageStore.shared.upsert(instance: inst.id, thread: t.id, batch) |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index b64fc19..d7b381a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -23,6 +23,11 @@ public final class ChatFeedModel: ObservableObject { |
| public var attachments: [Attachment] = [] |
| public var reply: ReplyRef? // set when this message quotes another |
| public var reactions: [Reaction] = [] |
| + // The reaction EVENTS folded so far, keyed by (emoji, uid) — the same |
| + // accumulate-then-derive fold as chat.js: the latest event per key wins |
| + // (on/off), `reactions` above is derived from it, and replaying history |
| + // is idempotent so a re-pull can never double-count. |
| + public var reactionEvents: [ReactionEvent] = [] |
| // Folded from the other direction: how many messages are in the reply |
| // thread THIS message tops, and where that thread starts (the web's |
| // "N Replies" tag). Tallies group on the resolved ROOT message. |
| @@ -36,8 +41,17 @@ public final class ChatFeedModel: ObservableObject { |
| public var raw = MessagePayload() |
| } |
| - /// The fixed reaction set (hub.go allowedReactions — plaintext by design, |
| - /// so the server can count; anything else is rejected). |
| + // One decrypted reaction event (type="reaction" payload) as folded into a |
| + // message — see Message.reactionEvents. |
| + public struct ReactionEvent: Equatable, Sendable { |
| + public var emoji: String |
| + public var uid: String |
| + public var on: Bool |
| + public var ts: Int64? |
| + } |
| + |
| + /// The fixed reaction set the UI offers (mirrors chat.js REACTIONS — the |
| + /// server can no longer enforce it, reactions are ciphertext to it now). |
| public static let allowedReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"] |
| /// The web's EDIT_WINDOW_MS: own messages are editable for 15 minutes |
| @@ -602,27 +616,28 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| /// Toggle a reaction, like the web's toggleReaction: react if it isn't |
| - /// yours yet, unreact if it is. The hub rebroadcasts the full tally either |
| - /// way, which folds back through the "reaction" frame. |
| + /// yours yet, unreact if it is. A reaction is an end-to-end encrypted event |
| + /// (type="reaction") sent as an ordinary message — the server never sees |
| + /// the emoji or the target; every client folds the event into the target's |
| + /// tally. targetUid (the reacted-to message's author) rides inside the |
| + /// ciphertext so push handlers can show the notification only to the author. |
| public func toggleReaction(_ id: Int64, emoji: String) { |
| - guard Self.allowedReactions.contains(emoji), |
| + guard Self.allowedReactions.contains(emoji), key != nil, |
| + let socket, socket.isLive, |
| let m = messages.first(where: { $0.id == id }) else { return } |
| let mine = m.reactions.first { $0.emoji == emoji }?.isMine ?? false |
| - sendFrame(ActionFrame(type: mine ? "unreact" : "react", |
| - thread_id: threadID, message_id: id, emoji: emoji)) |
| - // Optimistic local flip so the chip responds instantly; the broadcast |
| - // corrects any drift. |
| - var list = messages |
| - if let i = list.firstIndex(where: { $0.id == id }) { |
| - if let j = list[i].reactions.firstIndex(where: { $0.emoji == emoji }) { |
| - list[i].reactions[j].mine = !mine |
| - list[i].reactions[j].count += mine ? -1 : 1 |
| - if list[i].reactions[j].count <= 0 { list[i].reactions.remove(at: j) } |
| - } else { |
| - list[i].reactions.append(Reaction(emoji: emoji, count: 1, mine: true)) |
| - } |
| - } |
| - messages = list |
| + var payload = MessagePayload(type: "reaction", |
| + name: instance.screenName, |
| + ts: Int64(Date().timeIntervalSince1970 * 1000), |
| + targetSid: id, |
| + targetUid: m.raw.uid, |
| + emoji: emoji, |
| + on: !mine) |
| + if !myUserId.isEmpty { payload.uid = myUserId } |
| + encryptAndSend(payload, via: socket) |
| + // Optimistic local fold so the chip responds instantly; the echo frame |
| + // replays the same event and the (emoji, uid)-keyed fold is idempotent. |
| + applyReactionEvent(payload) |
| } |
| /// Delete your own message. The server verifies authorship; the "delete" |
| @@ -812,21 +827,30 @@ public final class ChatFeedModel: ObservableObject { |
| let myUserId = self.myUserId |
| let screenName = self.instance.screenName |
| let wantGroupApps = threadKind == "group" |
| - let (fresh, stored, appEvents): ([Message], [MessageStore.StoredMessage], |
| - [(Int64, GroupApps.Event)]) = |
| + let (fresh, stored, appEvents, reactionRows): ([Message], [MessageStore.StoredMessage], |
| + [(Int64, GroupApps.Event)], [MessagePayload]) = |
| await Task.detached(priority: .userInitiated) { |
| var out: [Message] = [] |
| var toStore: [MessageStore.StoredMessage] = [] |
| var events: [(Int64, GroupApps.Event)] = [] |
| + var reacts: [MessagePayload] = [] |
| for row in rows { |
| if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) { |
| events.append((row.id, e)) |
| } |
| + // Reaction events live in history as ordinary encrypted rows — |
| + // collect them here (row order = chronological) and fold them |
| + // after the page's messages have merged. |
| + if let p = try? key.decryptJSON(MessagePayload.self, blob: row.blob), |
| + p.isReactionEvent { |
| + reacts.append(p) |
| + continue |
| + } |
| guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps) |
| else { continue } |
| toStore.append(.init(id: row.id, at: row.created_at, |
| edited: (row.edited_at ?? 0) > 0, |
| - reactions: row.reactions ?? [], payload: p)) |
| + reactions: [], payload: p)) |
| guard p.isRenderableChat else { continue } |
| let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId } |
| ?? (p.name == screenName) |
| @@ -835,9 +859,9 @@ public final class ChatFeedModel: ObservableObject { |
| action: p.action ?? false, |
| edited: (row.edited_at ?? 0) > 0, |
| attachments: p.attachments ?? [], reply: p.replyTo, |
| - reactions: row.reactions ?? [], raw: p)) |
| + reactions: [], raw: p)) |
| } |
| - return (out, toStore, events) |
| + return (out, toStore, events, reacts) |
| }.value |
| if !appEvents.isEmpty { |
| for (id, e) in appEvents { groupAppsEvents[id] = e } |
| @@ -852,7 +876,11 @@ public final class ChatFeedModel: ObservableObject { |
| var indexByID = Dictionary(uniqueKeysWithValues: list.enumerated().map { ($1.id, $0) }) |
| for var msg in fresh { |
| if let i = indexByID[msg.id] { |
| - if msg.reactions.isEmpty { msg.reactions = list[i].reactions } |
| + // Page rows carry no tally — reactions fold from events below. |
| + // Keep whatever this model already folded (the keyed fold makes |
| + // the page's events re-apply idempotently on top). |
| + msg.reactionEvents = list[i].reactionEvents |
| + msg.reactions = list[i].reactions |
| if !msg.edited { msg.edited = list[i].edited } |
| list[i] = msg |
| } else { |
| @@ -860,6 +888,25 @@ public final class ChatFeedModel: ObservableObject { |
| indexByID[msg.id] = list.count - 1 |
| } |
| } |
| + // Fold the page's reaction events into their targets, then mirror the |
| + // touched tallies so a reopened chat renders them from disk. Events |
| + // whose target is outside the loaded window are dropped, like the web. |
| + var touched: [Int64] = [] |
| + for p in reactionRows { |
| + guard let sid = p.targetSid, let i = indexByID[sid] else { continue } |
| + if Self.foldReaction(p, into: &list[i], me: myUserId), !touched.contains(sid) { |
| + touched.append(sid) |
| + } |
| + } |
| + if !touched.isEmpty { |
| + let tallies = touched.map { sid in (sid, list[indexByID[sid]!].reactions) } |
| + Task.detached(priority: .utility) { |
| + for (sid, tally) in tallies { |
| + await MessageStore.shared.updateReactions(instance: instanceID, thread: tid, |
| + id: sid, tally) |
| + } |
| + } |
| + } |
| list.sort { $0.id < $1.id } |
| Self.refreshReplyCounts(&list) |
| messages = list |
| @@ -878,37 +925,29 @@ public final class ChatFeedModel: ObservableObject { |
| guard let id = frame.id, let blob = frame.blob, let key else { return } |
| let at = frame.created_at ?? 0 |
| let wantGroupApps = threadKind == "group" |
| - decryptThenFold({ (payload: Self.renderable(blob, key: key, group: wantGroupApps), |
| - groupApps: wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil) |
| - }) { model, decrypted in |
| - model.foldDecrypted(id: id, at: at, payload: decrypted.payload, |
| - edited: false, reactions: []) |
| - if let e = decrypted.groupApps { model.foldGroupAppsEvent(id: id, e) } |
| + // A stored frame is either an encrypted reaction event or a |
| + // chat/app message (with an optional group-apps projection) — |
| + // decrypt off-main either way, then fold the right shape on main. |
| + decryptThenFold({ () -> InboundStored? in |
| + if let p = try? key.decryptJSON(MessagePayload.self, blob: blob), |
| + p.isReactionEvent { |
| + return .reaction(p) |
| + } |
| + return .message(Self.renderable(blob, key: key, group: wantGroupApps), |
| + wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil) |
| + }) { model, inbound in |
| + switch inbound { |
| + case .reaction(let p): |
| + model.applyReactionEvent(p) |
| + case .message(let payload, let groupApps): |
| + model.foldDecrypted(id: id, at: at, payload: payload, |
| + edited: false, reactions: []) |
| + if let e = groupApps { model.foldGroupAppsEvent(id: id, e) } |
| + } |
| } |
| case "typing": |
| // The dedicated relay frame (bots use this path) — same payload. |
| if let blob = frame.blob { foldTypingBlob(blob) } |
| - case "reaction": |
| - // The hub rebroadcasts the FULL per-emoji tally after every toggle. |
| - // The broadcast can't carry `mine` (one frame fans out to the whole |
| - // thread), so keep our own flag for any emoji that survives — only |
| - // wrong in the rare case of un-reacting from another device, and |
| - // self-heals on the next history load. |
| - guard let id = frame.message_id, |
| - let i = messages.firstIndex(where: { $0.id == id }) else { return } |
| - let previous = messages[i].reactions |
| - messages[i].reactions = (frame.reactions ?? []).map { r in |
| - var r = r |
| - if r.mine == nil { r.mine = previous.first { $0.emoji == r.emoji }?.mine } |
| - return r |
| - } |
| - let tally = messages[i].reactions |
| - let instanceID = instance.id |
| - let tid = threadID |
| - Task.detached(priority: .utility) { |
| - await MessageStore.shared.updateReactions(instance: instanceID, thread: tid, |
| - id: id, tally) |
| - } |
| case "edit": |
| guard let id = frame.id, let blob = frame.blob, let key else { return } |
| decryptThenFold({ try? key.decryptJSON(MessagePayload.self, blob: blob) }) { model, p in |
| @@ -939,7 +978,62 @@ public final class ChatFeedModel: ObservableObject { |
| await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id) |
| } |
| default: |
| - break // react/unreact/typing — later message types |
| + break // presence/read ride the store's socket; unknown types ignored |
| + } |
| + } |
| + |
| + // What a stored "msg" frame decrypts to: a reaction event folds into its |
| + // target's tally; anything else is a message (or app-event projection), |
| + // possibly carrying a group-apps event alongside. |
| + private enum InboundStored: Sendable { |
| + case reaction(MessagePayload) |
| + case message(MessagePayload?, GroupApps.Event?) |
| + } |
| + |
| + // Fold one reaction event into a message and re-derive its rendered tally |
| + // (per-emoji count + mine, emoji-sorted — chat.js's derived array; the |
| + // chat.json vectors pin the shape). Keyed by (emoji, uid): the latest |
| + // event wins, so echoes and history re-pulls fold idempotently. Returns |
| + // false when the event is malformed, targets another message, or removes |
| + // a reaction that was never folded. |
| + nonisolated static func foldReaction(_ p: MessagePayload, into m: inout Message, |
| + me: String) -> Bool { |
| + guard p.isReactionEvent, let emoji = p.emoji, let on = p.on, |
| + let uid = p.uid, p.targetSid == m.id else { return false } |
| + if let j = m.reactionEvents.firstIndex(where: { $0.emoji == emoji && $0.uid == uid }) { |
| + m.reactionEvents[j].on = on |
| + m.reactionEvents[j].ts = p.ts |
| + } else if on { |
| + m.reactionEvents.append(ReactionEvent(emoji: emoji, uid: uid, on: true, ts: p.ts)) |
| + } else { |
| + return false |
| + } |
| + var reactors: [String: [String]] = [:] |
| + for ev in m.reactionEvents where ev.on { |
| + reactors[ev.emoji, default: []].append(ev.uid) |
| + } |
| + m.reactions = reactors.keys.sorted().map { |
| + Reaction(emoji: $0, count: reactors[$0]!.count, |
| + mine: !me.isEmpty && reactors[$0]!.contains(me)) |
| + } |
| + return true |
| + } |
| + |
| + /// Fold a live reaction event (or the local optimistic copy) into the |
| + /// target message, and mirror the derived tally into the local store so a |
| + /// reopened chat renders it from disk. |
| + private func applyReactionEvent(_ payload: MessagePayload) { |
| + guard let sid = payload.targetSid, |
| + let i = messages.firstIndex(where: { $0.id == sid }) else { return } |
| + var m = messages[i] |
| + guard Self.foldReaction(payload, into: &m, me: myUserId) else { return } |
| + messages[i] = m |
| + let tally = m.reactions |
| + let instanceID = instance.id |
| + let tid = threadID |
| + Task.detached(priority: .utility) { |
| + await MessageStore.shared.updateReactions(instance: instanceID, thread: tid, |
| + id: sid, tally) |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift| index 5ac6c5f..b5519d4 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift |
| @@ -7,8 +7,9 @@ import Foundation |
| // never here. |
| public final class ChatSocket: NSObject, URLSessionWebSocketDelegate { |
| // One hub frame, decoded leniently: the hub sends several shapes (msg / |
| - // edit / delete / react / typing) that share `type` + `thread_id`; fields |
| - // the shape doesn't carry just stay nil. |
| + // edit / delete / typing) that share `type` + `thread_id`; fields |
| + // the shape doesn't carry just stay nil. Reactions are now encrypted events |
| + // (type="reaction") inside "msg" frames. |
| public struct Frame: Decodable { |
| public let type: String |
| public let thread_id: String? |
| @@ -17,8 +18,6 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate { |
| public let blob: String? |
| public let edited_at: Int64? |
| public let ephemeral: Bool? // relay-only traffic (presence) — never stored |
| - public let message_id: Int64? // "reaction" frames |
| - public let reactions: [Reaction]? // full per-emoji tally after a (un)react |
| public let user_id: String? // "presence" / "label" frames: whose state changed |
| public let presence: String? // "presence" frames: "online" | "away" | "offline" |
| public let last_read_at: Int64? // "read" frames: our watermark, moved on another device |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 717de0f..abfeeb4 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -101,16 +101,14 @@ public struct RosterEntry: Codable, Equatable { |
| public var isAI: Bool { kind == "ai" } |
| } |
| -// GET /api/messages row. The blob is opaque to the server; reactions ride |
| -// beside it in the clear (a deliberate exception — the server counts and |
| -// de-duplicates them; see db.go's reactions section) with `mine` resolved for |
| -// the requesting token. edited_at marks server-side edits. |
| +// GET /api/messages row. The blob is opaque to the server; it may contain an |
| +// encrypted reaction event (type="reaction"), which the client decrypts and folds |
| +// into message reactions. edited_at marks server-side edits. |
| public struct ServerMessage: Codable, Identifiable { |
| public let id: Int64 |
| public let created_at: Int64 |
| public let blob: String |
| public var edited_at: Int64? |
| - public var reactions: [Reaction]? |
| } |
| // One emoji's tally on a message. Decodes both server shapes: history rows |
| @@ -130,11 +128,11 @@ public struct Reaction: Codable, Hashable, Sendable { |
| // encrypt(): the app event ({text, attachments?, replyTo?}) stamped with the |
| // sender's `name`, `uid` and `ts` (ms) by the host (shell.js emit), plus an |
| // optional content `type` (web/static/message.js — today's messages are |
| -// untyped; typing hints ride ephemeral frames and never reach storage). The |
| -// cross-implementation gate for this shape is message-payload-vector.json |
| -// (generated by test/messageblob.test.mjs). |
| +// untyped; typing hints ride ephemeral frames and never reach storage; reactions |
| +// are type="reaction" and carry targetSid/emoji/on instead of text). The |
| +// cross-implementation gate for this shape is chat.json in event-vectors. |
| public struct MessagePayload: Codable, Equatable, Sendable { |
| - public var type: String? // content type; nil = legacy/untyped chat message |
| + public var type: String? // content type; nil = legacy/untyped chat message; "reaction" = encrypted reaction event |
| public var uid: String? |
| public var name: String? |
| public var text: String? |
| @@ -146,10 +144,21 @@ public struct MessagePayload: Codable, Equatable, Sendable { |
| // and validated at render time, so an unknown future card never fails the |
| // payload decode — `text` stays its mandatory plain fallback. |
| public var card: JSONValue? |
| + // Reaction event fields (type == "reaction"): |
| + public var targetSid: Int64? // message id being reacted to |
| + public var targetUid: String? // the reacted-to message's AUTHOR — stamped by the |
| + // reacting client (inside the ciphertext, so the server |
| + // stays blind) so push handlers can show the reaction |
| + // only to the author; nil for legacy uid-less targets |
| + public var emoji: String? // the reaction emoji |
| + public var on: Bool? // true to add, false to remove |
| /// Mirrors web/static/message.js acceptsMessageType: render untyped (legacy) |
| /// and "chat" payloads; skip unknown future kinds rather than mis-render them. |
| public var isRenderableChat: Bool { type == nil || type == "chat" } |
| + |
| + /// Reaction event: type == "reaction" with targetSid, emoji, uid, and on |
| + public var isReactionEvent: Bool { type == "reaction" } |
| } |
| // The quoted-message reference riding inside a reply's payload — chat.js builds |
diff --git a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift| index 5c022a2..151a5d2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift |
| @@ -18,6 +18,11 @@ public enum SharedThreadKeys { |
| public let title: String // decrypted chat title (never stored server-side) |
| public let origin: String // owning instance, for notification → chat routing |
| public let group: Bool // groups prefix the sender name in the banner |
| + // This user's uid on the owning instance: the NSE compares it to a |
| + // reaction event's targetUid to show "X reacted" only to the reacted-to |
| + // message's author. Optional so entries parked by older builds still |
| + // decode (they fall back to the quiet non-author path). |
| + public var myUid: String? |
| } |
| private static let service = "com.lchat.client.threadkeys" |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 7dea20a..3be63e4 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -263,7 +263,8 @@ public final class ThreadStore: ObservableObject { |
| // Park the key + decrypted title for the notification extension: a |
| // push's ciphertext is decrypted on-device against this store. |
| SharedThreadKeys.save(threadID: t.id, entry: .init( |
| - key: raw, title: title, origin: instance.origin, group: t.kind == "group")) |
| + key: raw, title: title, origin: instance.origin, group: t.kind == "group", |
| + myUid: myUserId)) |
| rows.append(ChatThread( |
| id: t.id, |
| kind: t.kind, |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift| index 437dd8a..f23640d 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| @@ -155,15 +155,93 @@ final class EventVectorTests: XCTestCase { |
| XCTAssertEqual(att?.poster, "blob-vid-1-poster", "video poster should be set") |
| } |
| - func testReactionFrame() { |
| - // Reactions are stored separately from payloads. |
| - let reaction = Reaction(emoji: "👍", count: 1, mine: false) |
| - XCTAssertEqual(reaction.emoji, "👍", "emoji should match") |
| - XCTAssertEqual(reaction.count, 1, "count should be 1") |
| - XCTAssertFalse(reaction.isMine, "mine should default to false") |
| - |
| - let myReaction = Reaction(emoji: "❤️", count: 2, mine: true) |
| - XCTAssertTrue(myReaction.isMine, "isMine should return the mine flag") |
| + func testReactionEvent() { |
| + // Reactions are now encrypted events (type="reaction") that fold into message reactions. |
| + let reactionAdd = MessagePayload( |
| + type: "reaction", |
| + uid: "user-alice-1", |
| + name: "Alice", |
| + text: nil, |
| + ts: 1700000010000, |
| + action: nil, |
| + attachments: nil, |
| + replyTo: nil, |
| + targetSid: 12345, |
| + targetUid: "user-eve-1", |
| + emoji: "👍", |
| + on: true |
| + ) |
| + |
| + XCTAssertTrue(reactionAdd.isReactionEvent, "should be identified as a reaction event") |
| + XCTAssertEqual(reactionAdd.type, "reaction", "type should be 'reaction'") |
| + XCTAssertEqual(reactionAdd.targetSid, 12345, "targetSid should be the message id") |
| + XCTAssertEqual(reactionAdd.targetUid, "user-eve-1", "targetUid should be the reacted-to message's author") |
| + XCTAssertEqual(reactionAdd.emoji, "👍", "emoji should match") |
| + XCTAssertTrue(reactionAdd.on ?? false, "on should be true for adding") |
| + XCTAssertEqual(reactionAdd.uid, "user-alice-1", "uid should be the reactor's id") |
| + XCTAssertEqual(reactionAdd.name, "Alice", "name should be the reactor's name") |
| + |
| + // The push-side contract (sw.js + the NSE): only the reacted-to message's |
| + // author is shown a reaction notification, decided by comparing targetUid |
| + // to the local user after decryption. |
| + XCTAssertEqual(reactionAdd.targetUid, "user-eve-1") |
| + XCTAssertNotEqual(reactionAdd.targetUid, reactionAdd.uid, "reactor and author differ in this vector") |
| + |
| + let reactionRemove = MessagePayload( |
| + type: "reaction", |
| + uid: "user-bob-1", |
| + name: "Bob", |
| + text: nil, |
| + ts: 1700000015000, |
| + action: nil, |
| + attachments: nil, |
| + replyTo: nil, |
| + targetSid: 12346, |
| + targetUid: "user-eve-1", |
| + emoji: "❤️", |
| + on: false |
| + ) |
| + |
| + XCTAssertTrue(reactionRemove.isReactionEvent, "should be identified as a reaction event") |
| + XCTAssertFalse(reactionRemove.on ?? true, "on should be false for removing") |
| + XCTAssertEqual(reactionRemove.targetUid, "user-eve-1", "targetUid rides on removals too") |
| + } |
| + |
| + func testReactionFold() { |
| + // The native fold must match chat.js's "chat.reaction" handler: events |
| + // accumulate keyed by (emoji, uid), the tally derives from the on=true |
| + // set, and replaying an event is idempotent (a history re-pull can never |
| + // double-count). |
| + var m = ChatFeedModel.Message(id: 12345, name: "Eve", text: "hello", |
| + mine: false, at: 1700000000, edited: false) |
| + let add = MessagePayload(type: "reaction", uid: "user-alice-1", name: "Alice", |
| + ts: 1700000010000, targetSid: 12345, |
| + targetUid: "user-eve-1", emoji: "👍", on: true) |
| + |
| + XCTAssertTrue(ChatFeedModel.foldReaction(add, into: &m, me: "user-eve-1")) |
| + XCTAssertEqual(m.reactions, [Reaction(emoji: "👍", count: 1, mine: false)], |
| + "add derives a count of 1, not mine") |
| + |
| + XCTAssertTrue(ChatFeedModel.foldReaction(add, into: &m, me: "user-eve-1")) |
| + XCTAssertEqual(m.reactions, [Reaction(emoji: "👍", count: 1, mine: false)], |
| + "re-folding the same event is a no-op on the tally") |
| + |
| + var mine = add |
| + mine.uid = "user-eve-1" |
| + XCTAssertTrue(ChatFeedModel.foldReaction(mine, into: &m, me: "user-eve-1")) |
| + XCTAssertEqual(m.reactions, [Reaction(emoji: "👍", count: 2, mine: true)], |
| + "my own uid among the reactors sets the mine flag") |
| + |
| + var remove = add |
| + remove.on = false |
| + XCTAssertTrue(ChatFeedModel.foldReaction(remove, into: &m, me: "user-eve-1")) |
| + XCTAssertEqual(m.reactions, [Reaction(emoji: "👍", count: 1, mine: true)], |
| + "a removal folds that reactor out of the tally") |
| + |
| + var elsewhere = add |
| + elsewhere.targetSid = 99999 |
| + XCTAssertFalse(ChatFeedModel.foldReaction(elsewhere, into: &m, me: "user-eve-1"), |
| + "an event for another message never touches this one") |
| } |
| func testEditedMessage() { |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 4a71803..4949fb3 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1188,7 +1188,20 @@ struct NativeChatView: View { |
| else { return } |
| let content = UNMutableNotificationContent() |
| content.title = thread.title |
| - content.body = previewText(kind: thread.kind, payload: p) |
| + if p.isReactionEvent { |
| + // Reactions notify only the reacted-to message's author — and |
| + // the Mac posts local notifications, so unlike the iOS NSE it |
| + // can drop the rest outright. Un-reacts and reactions to |
| + // legacy uid-less messages notify no one. |
| + guard p.on == true, let target = p.targetUid, target == myUserId else { return } |
| + let sender = p.name ?? "Someone" |
| + let emoji = p.emoji ?? "" |
| + content.body = thread.kind == "group" |
| + ? "\(sender) reacted \(emoji) to your message" |
| + : "Reacted \(emoji) to your message" |
| + } else { |
| + content.body = previewText(kind: thread.kind, payload: p) |
| + } |
| content.sound = .default |
| content.userInfo = ["instance": instanceID, "thread": tid] |
| // Sender's face on the banner where possible — same restyle the |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index 7886261..e9e320c 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -47,6 +47,37 @@ final class NotificationService: UNNotificationServiceExtension { |
| return |
| } |
| + // Reaction events notify only the reacted-to message's author. The |
| + // author's uid (targetUid) rides inside the ciphertext — stamped by the |
| + // reacting client, invisible to the server — so the decision can only |
| + // happen here, after decryption. Without Apple's notification-filtering |
| + // entitlement (com.apple.developer.usernotifications.filtering) the |
| + // extension must ship *something*, so non-authors (and un-reacts, and |
| + // reactions to legacy uid-less messages) get a passive, silent entry — |
| + // no sound, no banner, no lit screen; it only lands in the notification |
| + // list. The author gets the real banner. |
| + if payload.isReactionEvent { |
| + let sender = payload.name ?? "Someone" |
| + let emoji = payload.emoji ?? "" |
| + content.title = entry.title |
| + content.threadIdentifier = thread |
| + let toMine = payload.on == true && payload.targetUid != nil && payload.targetUid == entry.myUid |
| + if toMine { |
| + // In a DM the title already names the reactor; groups prefix them. |
| + content.body = entry.group |
| + ? "\(sender) reacted \(emoji) to your message" |
| + : "Reacted \(emoji) to your message" |
| + } else { |
| + content.body = payload.on == true |
| + ? (entry.group ? "\(sender) reacted \(emoji)" : "Reacted \(emoji)") |
| + : (entry.group ? "\(sender) removed a reaction" : "Removed a reaction") |
| + content.sound = nil |
| + content.interruptionLevel = .passive |
| + } |
| + ship(content) |
| + return |
| + } |
| + |
| let sender = payload.name ?? "Someone" |
| var text = payload.text ?? "" |
| if text.isEmpty { |
diff --git a/main.go b/main.go| index 3749f10..66092b0 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -746,18 +746,6 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - // Attach reactions (with a "mine" flag for this caller's token). |
| - ids := make([]int64, len(msgs)) |
| - for i, m := range msgs { |
| - ids[i] = m.ID |
| - } |
| - if reMap, err := s.db.reactionsForMessages(ids, userID); err == nil { |
| - for i := range msgs { |
| - if rs := reMap[msgs[i].ID]; len(rs) > 0 { |
| - msgs[i].Reactions = rs |
| - } |
| - } |
| - } |
| writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) |
| } |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index e540bf4..62f26fd 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -1076,12 +1076,13 @@ await test("chat: threadItems selects a root's whole thread — legacy chains re |
| await test("chat: reactions, edits, deletes fold as host translations of server frames", () => { |
| const reduce = chatApp.reduce; |
| - let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1 }, { id: 20 }); |
| + let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1, uid: "alice" }, { id: 20 }); |
| const rev0 = s.msgs[0].rev; |
| - s = reduce(s, { type: "chat.react", msgId: 20, reactions: [{ emoji: "👍", count: 2, mine: true }] }); |
| - assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 2, mine: true }]); |
| - assert.equal(s.msgs[0].rev, rev0, "a reaction does NOT bump the hard rev — the row patches in place, no rebuild"); |
| - assert.ok(viewItems(s.msgs)[0].srev.startsWith("👍2"), "the reaction rides the soft rev instead"); |
| + s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "bob", on: true, ts: 2 }, {}, { me: "alice" }); |
| + assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 1, mine: false }]); |
| + assert.equal(s.msgs[0].rev, rev0 + 1, "a reaction bumps the soft rev"); |
| + s = reduce(s, { type: "chat.reaction", targetSid: 20, emoji: "👍", uid: "charlie", on: true, ts: 3 }, {}, { me: "alice" }); |
| + assert.deepEqual(s.msgs[0].reactions, [{ emoji: "👍", count: 2, mine: false }]); |
| const revBeforeEdit = s.msgs[0].rev; |
| s = reduce(s, { type: "chat.edit", msgId: 20, text: "hello again" }); |
| assert.equal(s.msgs[0].text, "hello again"); |
| @@ -1096,12 +1097,14 @@ await test("chat: reactions, edits, deletes fold as host translations of server |
| await test("chat mutations are host-only: wire payloads can't spoof them", () => { |
| resetThread("c0"); |
| // A member posting {type:"chat.delete"} as a message must fold NOTHING — the |
| - // type is not wire-owned (deletes are server-verified, author-only). |
| + // type is not wire-owned (deletes are server-verified, author-only). Same for |
| + // chat.reaction and chat.edit — only the host (shell.js) can emit those. |
| assert.equal(foldEvent("c0", { type: "chat.delete", msgId: 1 }, { id: 2 }), null); |
| - assert.equal(foldEvent("c0", { type: "chat.react", msgId: 1, reactions: [] }, { id: 3 }), null); |
| - assert.equal(foldEvent("c0", { type: "chat.msg", text: "spoof" }, { id: 4 }), null); |
| + assert.equal(foldEvent("c0", { type: "chat.reaction", targetSid: 1, emoji: "👍", uid: "x", on: true }, { id: 3 }), null); |
| + assert.equal(foldEvent("c0", { type: "chat.edit", msgId: 1, text: "edited" }, { id: 4 }), null); |
| + assert.equal(foldEvent("c0", { type: "chat.msg", text: "spoof" }, { id: 5 }), null); |
| // The host doorway reaches them. |
| - const app = foldHostEvent("c0", "chat", { type: "chat.msg", text: "real" }, { id: 5 }); |
| + const app = foldHostEvent("c0", "chat", { type: "chat.msg", text: "real" }, { id: 6 }); |
| assert.equal(app && app.name, "chat"); |
| assert.equal(appStateFor("c0", "chat").msgs.length, 1); |
| }); |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index 98433f9..e7db2b9 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -44,10 +44,39 @@ function testChatMsgFold(state, event, meta) { |
| return { msgs }; |
| } |
| -function testChatReactFold(state, event) { |
| - return { |
| - msgs: state.msgs.map((m) => (m.sid === event.msgId ? { ...m, reactions: event.reactions || [] } : m)), |
| - }; |
| +function testChatReactionFold(state, event, meta, ctx) { |
| + if (event.targetSid === undefined || event.emoji === undefined || event.uid === undefined || event.on === undefined) return state; |
| + const me = ctx && ctx.me; |
| + const msgs = state.msgs.map((m) => { |
| + if (m.sid !== event.targetSid) return m; |
| + const reactionEvents = m.reactionEvents || []; |
| + let found = false; |
| + const updated = reactionEvents.map((ev) => { |
| + if (ev.emoji === event.emoji && ev.uid === event.uid) { |
| + found = true; |
| + return { ...ev, on: event.on, ts: event.ts }; |
| + } |
| + return ev; |
| + }); |
| + if (!found && event.on) { |
| + updated.push({ emoji: event.emoji, uid: event.uid, on: true, ts: event.ts }); |
| + } |
| + const currentReactions = updated.filter((ev) => ev.on); |
| + const derived = []; |
| + const emojiMap = new Map(); |
| + for (const ev of currentReactions) { |
| + if (!emojiMap.has(ev.emoji)) { |
| + emojiMap.set(ev.emoji, []); |
| + } |
| + emojiMap.get(ev.emoji).push(ev.uid); |
| + } |
| + for (const [emoji, uids] of emojiMap) { |
| + derived.push({ emoji, count: uids.length, mine: uids.includes(me) }); |
| + } |
| + derived.sort((a, b) => a.emoji.localeCompare(b.emoji)); |
| + return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 }; |
| + }); |
| + return { msgs }; |
| } |
| function testChatEditFold(state, event) { |
| @@ -215,19 +244,31 @@ allVectors.chat.forEach((vector) => { |
| }); |
| } |
| - if (vector.expected_fold_event?.type === "chat.react") { |
| - test(`chat.react: ${vector.name} — folds reactions correctly and produces expected event shape`, () => { |
| - const state = { msgs: [{ sid: vector.server_reaction_frame.message_id, reactions: [] }] }; |
| - const event = { |
| - type: "chat.react", |
| - msgId: vector.server_reaction_frame.message_id, |
| - reactions: vector.server_reaction_frame.reactions, |
| - }; |
| - const result = testChatReactFold(state, event); |
| - |
| + if (vector.expected_fold_event?.type === "chat.reaction") { |
| + test(`chat.reaction: ${vector.name} — folds the encrypted reaction event into the target message`, () => { |
| + const p = vector.decrypted_payload; |
| + // Seed the target message with the reactor's reaction already on when the |
| + // vector removes it, so on=false has something to fold away. |
| + const seeded = p.on ? [] : [{ emoji: p.emoji, uid: p.uid, on: true, ts: p.ts - 1 }]; |
| + const state = { msgs: [{ sid: p.targetSid, uid: p.targetUid || "", reactions: [], reactionEvents: seeded, rev: 0 }] }; |
| + const event = { type: "chat.reaction", targetSid: p.targetSid, emoji: p.emoji, uid: p.uid, on: p.on, ts: p.ts }; |
| + assert.deepStrictEqual( |
| + { type: event.type, targetSid: event.targetSid, emoji: event.emoji, uid: event.uid, on: event.on }, |
| + vector.expected_fold_event, |
| + "fold event derived from the payload should match the vector" |
| + ); |
| + const result = testChatReactionFold(state, event, {}, { me: "user-someone-else" }); |
| assert.strictEqual(result.msgs.length, 1, "should have one message"); |
| const msg = result.msgs[0]; |
| - assert.deepStrictEqual(msg.reactions, vector.expected_fold_event.reactions, "reactions should match (without mine flag)"); |
| + if (p.on) { |
| + assert.deepStrictEqual(msg.reactions, [{ emoji: p.emoji, count: 1, mine: false }], "add derives a count of 1, not mine"); |
| + } else { |
| + assert.deepStrictEqual(msg.reactions, [], "remove folds the reaction away"); |
| + } |
| + assert.strictEqual(msg.rev, 1, "a reaction bumps the rev"); |
| + // Folding the same event again is idempotent on the derived reactions. |
| + const again = testChatReactionFold(result, event, {}, { me: "user-someone-else" }); |
| + assert.deepStrictEqual(again.msgs[0].reactions, msg.reactions, "re-fold is idempotent"); |
| }); |
| } |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index ff670b9..8fd8274 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -1,5 +1,5 @@ |
| { |
| - "description": "Chat message event vectors: ensures chat.js (web) and native (iOS/Mac) fold message/reaction/edit/delete events identically. Used by test/event-vectors.test.mjs and ios/.../EventVectorTests.swift.", |
| + "description": "Chat event vectors: ensures chat.js (web) and native (iOS/Mac) fold message/reaction/edit/delete events identically. Reactions are now encrypted events (type='reaction' stored like messages). Used by test/event-vectors.test.mjs and ios/.../EventVectorTests.swift.", |
| "app": "chat", |
| "source": "web/static/apps/chat.js", |
| "vectors": [ |
| @@ -258,46 +258,74 @@ |
| } |
| }, |
| { |
| - "name": "reaction_single", |
| - "description": "One member reacting with a single emoji.", |
| - "server_reaction_frame": { |
| + "name": "reaction_add", |
| + "description": "A member reacting to a message with a single emoji. Reactions are encrypted events (type='reaction') that fold into the target message's reactions array.", |
| + "decrypted_payload": { |
| "type": "reaction", |
| - "message_id": 12345, |
| - "reactions": [ |
| - { "emoji": "👍", "count": 1, "mine": false } |
| - ] |
| + "targetSid": 12345, |
| + "targetUid": "user-eve-1", |
| + "emoji": "👍", |
| + "name": "Alice", |
| + "uid": "user-alice-1", |
| + "ts": 1700000010000, |
| + "on": true |
| + }, |
| + "server_message": { |
| + "id": 12353, |
| + "created_at": 1700000010, |
| + "blob": "encrypted(decrypted_payload)" |
| }, |
| "expected_fold_event": { |
| - "type": "chat.react", |
| - "msgId": 12345, |
| - "reactions": [ |
| - { "emoji": "👍", "count": 1, "mine": false } |
| - ] |
| + "type": "chat.reaction", |
| + "targetSid": 12345, |
| + "emoji": "👍", |
| + "uid": "user-alice-1", |
| + "on": true |
| + }, |
| + "expected_native_fields": { |
| + "type": "reaction", |
| + "targetSid": 12345, |
| + "targetUid": "user-eve-1", |
| + "emoji": "👍", |
| + "uid": "user-alice-1", |
| + "on": true |
| }, |
| - "note": "Live reaction broadcasts (via websocket) include mine:false from the server; history load includes mine:true for actual mine reactions. The fold handler preserves whatever the server sends." |
| + "note": "Reactions are encrypted events stored as messages. Clients decrypt and fold them into the target message's reactions array. Count and mine-flag are derived client-side from the accumulated reaction events. on=true adds the reaction; on=false removes it. The mine-flag is true if uid===me, false otherwise. targetUid is the reacted-to message's author, stamped by the reacting client so push handlers (sw.js, the iOS NSE) can show the notification only to the author; the fold ignores it. Omitted when the target is a legacy uid-less message — then no one is notified." |
| }, |
| { |
| - "name": "reaction_multiple_emoji", |
| - "description": "Multiple people reacting with different emoji.", |
| - "server_reaction_frame": { |
| + "name": "reaction_remove", |
| + "description": "A member removing their reaction from a message.", |
| + "decrypted_payload": { |
| "type": "reaction", |
| - "message_id": 12346, |
| - "reactions": [ |
| - { "emoji": "❤️", "count": 2, "mine": true }, |
| - { "emoji": "😂", "count": 1, "mine": false }, |
| - { "emoji": "🎉", "count": 3, "mine": false } |
| - ] |
| + "targetSid": 12346, |
| + "targetUid": "user-eve-1", |
| + "emoji": "❤️", |
| + "name": "Bob", |
| + "uid": "user-bob-1", |
| + "ts": 1700000015000, |
| + "on": false |
| + }, |
| + "server_message": { |
| + "id": 12354, |
| + "created_at": 1700000015, |
| + "blob": "encrypted(decrypted_payload)" |
| }, |
| "expected_fold_event": { |
| - "type": "chat.react", |
| - "msgId": 12346, |
| - "reactions": [ |
| - { "emoji": "❤️", "count": 2, "mine": true }, |
| - { "emoji": "😂", "count": 1, "mine": false }, |
| - { "emoji": "🎉", "count": 3, "mine": false } |
| - ] |
| + "type": "chat.reaction", |
| + "targetSid": 12346, |
| + "emoji": "❤️", |
| + "uid": "user-bob-1", |
| + "on": false |
| + }, |
| + "expected_native_fields": { |
| + "type": "reaction", |
| + "targetSid": 12346, |
| + "targetUid": "user-eve-1", |
| + "emoji": "❤️", |
| + "uid": "user-bob-1", |
| + "on": false |
| }, |
| - "note": "Like reaction_single, the fold handler preserves the mine flags from the server frame (including the true value for the current user's reactions on a live broadcast)." |
| + "note": "Removal is signaled by on=false. Clients fold both add and remove into the target message's reaction array; the current state is derived by filtering for on=true and counting. Removals never notify anyone (push handlers suppress on=false)." |
| }, |
| { |
| "name": "edit", |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index d058ebf..82a0184 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -467,9 +467,40 @@ export const app = defineApp({ |
| msgs.sort((a, b) => a.sid - b.sid); |
| return { msgs }; |
| }, |
| - "chat.react": (s, e) => ({ |
| - msgs: s.msgs.map((m) => (m.sid === e.msgId ? { ...m, reactions: e.reactions || [] } : m)), |
| - }), |
| + "chat.reaction": (s, e, meta, ctx) => { |
| + if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s; |
| + const me = ctx && ctx.me; |
| + const msgs = s.msgs.map((m) => { |
| + if (m.sid !== e.targetSid) return m; |
| + const reactionEvents = m.reactionEvents || []; |
| + let found = false; |
| + const updated = reactionEvents.map((ev) => { |
| + if (ev.emoji === e.emoji && ev.uid === e.uid) { |
| + found = true; |
| + return { ...ev, on: e.on, ts: e.ts }; |
| + } |
| + return ev; |
| + }); |
| + if (!found && e.on) { |
| + updated.push({ emoji: e.emoji, uid: e.uid, on: true, ts: e.ts }); |
| + } |
| + const currentReactions = updated.filter((ev) => ev.on); |
| + const derived = []; |
| + const emojiMap = new Map(); |
| + for (const ev of currentReactions) { |
| + if (!emojiMap.has(ev.emoji)) { |
| + emojiMap.set(ev.emoji, []); |
| + } |
| + emojiMap.get(ev.emoji).push(ev.uid); |
| + } |
| + for (const [emoji, uids] of emojiMap) { |
| + derived.push({ emoji, count: uids.length, mine: uids.includes(me) }); |
| + } |
| + derived.sort((a, b) => a.emoji.localeCompare(b.emoji)); |
| + return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 }; |
| + }); |
| + return { msgs }; |
| + }, |
| "chat.edit": (s, e) => ({ |
| msgs: s.msgs.map((m) => { |
| if (m.sid !== e.msgId) return m; |
| @@ -968,7 +999,7 @@ function buildReactionsRow(m, ctx) { |
| chip.textContent = r.count > 1 ? `${r.emoji} ${r.count}` : r.emoji; |
| chip.onclick = (e) => { |
| e.stopPropagation(); |
| - toggleReaction(m.sid, r.emoji, ctx); |
| + toggleReaction(m, r.emoji, ctx); |
| }; |
| row.appendChild(chip); |
| } |
| @@ -1071,14 +1102,22 @@ function scrollToMessage(near, sid) { |
| target.classList.add("flash"); |
| } |
| -function toggleReaction(sid, emoji, ctx) { |
| +function toggleReaction(m, emoji, ctx) { |
| if (!host) return; |
| + const sid = m.sid; |
| const key = `${sid}|${emoji}`; |
| const mine = myReactions.has(key); |
| - // Only record the toggle locally once the frame is actually on the wire — |
| - // host.react no-ops (returns false) on a closed/reconnecting socket, and a |
| - // premature mine-flag flip would drift from the server until the next render. |
| - if (!host.react(ctx.threadId, sid, emoji, !mine)) return; |
| + // Reactions are now encrypted events. Optimistically update local state, but |
| + // if the send fails, revert on the next render. host.react is now async. |
| + // m.uid (the reacted-to message's author) rides inside the encrypted payload |
| + // so push handlers can show the reaction only to the author. |
| + const onVal = !mine; |
| + host.react(ctx.threadId, sid, emoji, onVal, m.uid || "").then((sent) => { |
| + if (!sent) { |
| + if (onVal) myReactions.delete(key); |
| + else myReactions.add(key); |
| + } |
| + }); |
| if (mine) myReactions.delete(key); |
| else myReactions.add(key); |
| } |
| @@ -1095,7 +1134,7 @@ function openMessagePicker(m, ctx, anchor) { |
| b.className = "emoji-opt"; |
| b.textContent = emoji; |
| b.onclick = () => { |
| - toggleReaction(m.sid, emoji, ctx); |
| + toggleReaction(m, emoji, ctx); |
| pop.remove(); |
| }; |
| pop.appendChild(b); |
| @@ -1180,7 +1219,7 @@ function openMessagePicker(m, ctx, anchor) { |
| if (num >= 1 && num <= REACTIONS.length) { |
| e.preventDefault(); |
| e.stopImmediatePropagation(); |
| - toggleReaction(m.sid, REACTIONS[num - 1], ctx); |
| + toggleReaction(m, REACTIONS[num - 1], ctx); |
| teardown(); |
| } |
| }; |
| @@ -1280,7 +1319,7 @@ function openMessageSheet(m, ctx, wrap) { |
| b.className = "sheet-emoji"; |
| b.textContent = emoji; |
| b.onclick = () => { |
| - toggleReaction(m.sid, emoji, ctx); |
| + toggleReaction(m, emoji, ctx); |
| close(); |
| }; |
| reactions.appendChild(b); |
| @@ -1378,7 +1417,7 @@ function openReactionBar(m, ctx, anchor) { |
| b.className = "emoji-opt"; |
| b.textContent = emoji; |
| b.onclick = () => { |
| - toggleReaction(m.sid, emoji, ctx); |
| + toggleReaction(m, emoji, ctx); |
| pop.remove(); |
| }; |
| pop.appendChild(b); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 9479235..b294577 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1403,6 +1403,9 @@ 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(() => {}); |
| } |
| // The frontend build this page loaded with (server-injected into the HTML). |
| @@ -3094,10 +3097,30 @@ setChatAppHost({ |
| const t = threads.find((x) => x.id === threadId); |
| return !!t && t.kind === "self"; |
| }, |
| - react: (threadId, messageId, emoji, on) => { |
| - if (!ws || ws.readyState !== WebSocket.OPEN) return false; |
| - ws.send(JSON.stringify({ type: on ? "react" : "unreact", thread_id: threadId, message_id: messageId, emoji })); |
| - return true; |
| + react: async (threadId, targetSid, emoji, on, targetUid) => { |
| + const key = threadKeys[threadId]; |
| + if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| + const payload = { |
| + type: "reaction", |
| + targetSid, |
| + emoji, |
| + on, |
| + name: myName(), |
| + uid: session.uid, |
| + ts: Date.now(), |
| + }; |
| + // 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 |
| + // legacy uid-less messages (then no one is notified). |
| + if (targetUid) payload.targetUid = targetUid; |
| + try { |
| + const blob = await encrypt(key, payload); |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| + return true; |
| + } catch { |
| + return false; |
| + } |
| }, |
| del: (threadId, messageId, blobIds) => { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| @@ -4098,6 +4121,10 @@ function chatAppFor(id) { |
| async function chatAppFoldMessage(threadId, m) { |
| try { |
| const data = await decrypt(threadKeys[threadId], m.blob); |
| + if (data.type === "reaction") { |
| + foldHostEvent(threadId, "chat", { type: "chat.reaction", targetSid: data.targetSid, emoji: data.emoji, uid: data.uid, on: data.on, ts: data.ts }); |
| + return; |
| + } |
| if (isAppType(data.type)) { |
| foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| // The event also projects into the conversation — the same one-log-two- |
| @@ -4113,9 +4140,6 @@ async function chatAppFoldMessage(threadId, m) { |
| } |
| if (data.type || (!data.text && !data.attachments)) return; |
| 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 }); |
| - } |
| if (m.edited_at) foldHostEvent(threadId, "chat", { type: "chat.edit", msgId: m.id }); |
| } catch {} |
| } |
| @@ -5355,16 +5379,6 @@ function connectWS() { |
| if (!hasUnsentDraft()) offerAppReload(); |
| return; |
| } |
| - if (msg.type === "reaction") { |
| - // Chat app: reactions fold into the app's state (rev-bumped, so exactly |
| - // that bubble re-renders). The frame is server-computed — hostEvents keep |
| - // it unspoofable by wire payloads. |
| - if (chatAppFor(msg.thread_id)) { |
| - foldHostEvent(msg.thread_id, "chat", { type: "chat.react", msgId: msg.message_id, reactions: msg.reactions || [] }); |
| - if (msg.thread_id === activeId) renderActiveSurfaces(); |
| - } |
| - return; |
| - } |
| if (msg.type === "edit") { |
| // Chat app: an edit (server-verified author) replaces the message text in |
| // the fold; the original feed's in-place bubble update follows unchanged. |
diff --git a/web/static/sw.js b/web/static/sw.js| index 5043374..4738b8d 100644 |
| --- a/web/static/sw.js |
| +++ b/web/static/sw.js |
| @@ -101,7 +101,18 @@ async function handlePush(event) { |
| const t = data.thread && threads[data.thread]; |
| if (data.blob && t && t.key) { |
| const msg = await decryptBlob(data.blob, t.key); |
| - if (msg.notice && msg.notice.title) { |
| + if (msg.type === "reaction") { |
| + // A reaction event: only the reacted-to message's author is shown a |
| + // notification. The author's uid (targetUid) rides inside the |
| + // ciphertext — stamped by the reacting client, invisible to the |
| + // server — so the filter can only run here, after decryption. |
| + // Un-reacts (on=false) and reactions to legacy messages without an |
| + // author uid notify no one. |
| + const me = await idbGet("myUid"); |
| + if (!msg.on || !msg.targetUid || !me || msg.targetUid !== me) return; |
| + title = t.name || msg.name || "New message"; |
| + body = `${msg.name || "Someone"} reacted ${msg.emoji || ""} to your message`; |
| + } else if (msg.notice && msg.notice.title) { |
| // A mini-app event carrying its own (encrypted) notification — e.g. a |
| // reminder the server just delivered. The app wrote the words. |
| title = String(msg.notice.title); |