From 9853cd91b81a89a763f56b9dcacc15e6f01577fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 07:58:48 +0000 Subject: [PATCH] state: dedupe Reaction events by author Each peer can react with a given emoji at most once. Switch ChatMessage::reactions from BTreeMap> to BTreeMap> and use insert() in the materializer so duplicate Reaction events from the same author collapse to a single entry. Without this change, an author replaying or retransmitting a Reaction would inflate the reactor list, causing UI miscounts and state-hash divergence between peers. Closes #111 Progresses #108 --- crates/state/src/materialize.rs | 2 +- crates/state/src/tests.rs | 55 +++++++++++++++++++++++++++++++-- crates/state/src/types.rs | 6 ++-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/state/src/materialize.rs b/crates/state/src/materialize.rs index 03473c66..cda4668a 100644 --- a/crates/state/src/materialize.rs +++ b/crates/state/src/materialize.rs @@ -386,7 +386,7 @@ fn apply_mutation(state: &mut ServerState, event: &Event) -> ApplyResult { msg.reactions .entry(emoji.clone()) .or_default() - .push(event.author); + .insert(event.author); } } diff --git a/crates/state/src/tests.rs b/crates/state/src/tests.rs index 93b82902..3374a130 100644 --- a/crates/state/src/tests.rs +++ b/crates/state/src/tests.rs @@ -628,8 +628,59 @@ fn duplicate_reaction_from_same_peer() { ); let state = materialize(&dag); - // Implementation-dependent: may deduplicate or not. - assert!(!state.messages[0].reactions.is_empty()); + // The same author reacting twice with the same emoji collapses to a + // single entry — reactions are stored as a set keyed by author. + let reactors = state.messages[0] + .reactions + .get("👍") + .expect("emoji should be present"); + assert_eq!(reactors.len(), 1); + assert!(reactors.contains(&admin.endpoint_id())); +} + +#[test] +fn same_author_duplicate_reaction_is_idempotent() { + let admin = Identity::generate(); + let mut dag = test_dag(&admin); + + let msg = do_emit( + &mut dag, + &admin, + EventKind::Message { + channel_id: "ch-1".to_string(), + body: "react to me".to_string(), + reply_to: None, + }, + ); + + // Apply two distinct Reaction events with the same emoji from the + // same author. The events themselves are unique (different hashes + // because of timestamps/parents), so dedup must happen at + // materialization time. + do_emit( + &mut dag, + &admin, + EventKind::Reaction { + message_id: msg.hash, + emoji: "🎉".to_string(), + }, + ); + do_emit( + &mut dag, + &admin, + EventKind::Reaction { + message_id: msg.hash, + emoji: "🎉".to_string(), + }, + ); + + let state = materialize(&dag); + let reactors = state.messages[0] + .reactions + .get("🎉") + .expect("emoji should be present"); + assert_eq!(reactors.len(), 1, "duplicate reactions must be deduped"); + assert!(reactors.contains(&admin.endpoint_id())); } #[test] diff --git a/crates/state/src/types.rs b/crates/state/src/types.rs index b79496f1..13286801 100644 --- a/crates/state/src/types.rs +++ b/crates/state/src/types.rs @@ -70,8 +70,10 @@ pub struct ChatMessage { pub edited: bool, /// Whether this message has been soft-deleted. pub deleted: bool, - /// Reactions: emoji string -> list of reactor endpoint IDs. - pub reactions: BTreeMap>, + /// Reactions: emoji string -> set of reactor endpoint IDs. + /// Stored as a `BTreeSet` so each peer can only react once with a + /// given emoji to a given message. + pub reactions: BTreeMap>, /// If this is a reply, the EventHash of the parent message. pub reply_to: Option, }