eleven / messenger Public

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>
James Adam a18577a9328404c1c0ae4a6caa79be9cf3a1907f parent f3e8368
20 files changed, +538 −392
  • db.go +4 −93
  • db_test.go +0 −41
  • handlers_test.go +0 −47
  • hub.go +1 −34
  • ios/LChatCore/Sources/LChatCore/BackgroundSync.swift +3 −1
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +148 −54
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +3 −4
  • ios/LChatCore/Sources/LChatCore/Models.swift +18 −9
  • ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +2 −1
  • ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift +87 −9
  • ios/LChatMac/NativeChatView.swift +14 −1
  • ios/LChatNSE/NotificationService.swift +31 −0
  • main.go +0 −12
  • test/apps.test.mjs +12 −9
  • test/event-vectors.test.mjs +56 −15
  • test/event-vectors/chat.json +59 −31
  • web/static/apps/chat.js +52 −13
  • web/static/shell.js +31 −17
  • web/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);