From 799dcc15ed56fe51d3a4cd1bd44d0a6264e09946 Mon Sep 17 00:00:00 2001 From: Sarthak Singh Date: Sun, 2 Aug 2026 23:44:01 +0530 Subject: [PATCH] fix(desktop): tag reaction events with the target author's pubkey (#2568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaction events (NIP-25 kind 7) carried only an `e` tag for the target event, so relay-side notification filters keyed on `#p` never matched them. This adds the target event's author as a `p` tag. - `events::build_reaction`: accept an optional `target_author_pubkey` and emit a `["p", author]` tag when present (degrade: omit the tag, never fail the submit). - `add_reaction`: resolve the target event's author via a single `query_relay` lookup (same pattern as `remove_reaction`), then pass it through. On any lookup failure the reaction still submits without the tag. When the reactor IS the target author, nostr's build-step correctly drops the self-referential `p` tag. Scope: desktop persistent-reaction path only. The ephemeral ACP cosmetic reactions (πŸ‘€/πŸ’¬) and the CLI path are deliberately out of scope. Verified: cargo test events:: (57 pass, incl. 2 new p-tag tests), cargo check clean, cargo fmt clean. Signed-off-by: Sarthak Singh --- desktop/src-tauri/src/commands/messages.rs | 18 ++++++- desktop/src-tauri/src/events.rs | 57 ++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3df..bc2300b107b 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -879,7 +879,23 @@ pub async fn add_reaction( // shortcode normalization + validation match the relay exactly. Some(url) => buzz_sdk_pkg::build_custom_emoji_reaction(target_eid, emoji.trim(), &url) .map_err(|e| format!("invalid custom emoji reaction: {e}"))?, - None => events::build_reaction(target_eid, emoji.trim())?, + None => { + // NIP-25: include the target author's pubkey as a `p` tag so relay + // notification filters (`#p`) match (#2568). Resolve it from the + // target event (single round-trip); on any failure, omit the tag + // for this reaction rather than failing the submit. + let target_author = query_relay( + &state, + &[serde_json::json!({ + "ids": [target_eid.to_hex()], + "limit": 1, + })], + ) + .await + .ok() + .and_then(|events| events.first().map(|ev| ev.pubkey.to_hex())); + events::build_reaction(target_eid, emoji.trim(), target_author.as_deref())? + } }; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02ef..ddb50ae7430 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -442,14 +442,25 @@ pub fn build_delete_compat( // ── Reactions ──────────────────────────────────────────────────────────────── -/// Kind 7 β€” NIP-25 reaction. -pub fn build_reaction(target_event_id: EventId, emoji: &str) -> Result { +/// Kind 7 β€” NIP-25 reaction. Carries the target event's author as a `p` tag +/// when known, so relay-side notification filters (`#p`) can match it +/// (#2568). The author is resolved at the call-site; if the target event +/// cannot be fetched, the tag is omitted for that reaction rather than +/// failing the submit. +pub fn build_reaction( + target_event_id: EventId, + emoji: &str, + target_author_pubkey: Option<&str>, +) -> Result { if emoji.chars().count() > MAX_EMOJI_CHARS { return Err(format!( "emoji exceeds maximum length of {MAX_EMOJI_CHARS} characters" )); } - let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?]; + let mut tags = vec![tag(vec!["e", &target_event_id.to_hex()])?]; + if let Some(author) = target_author_pubkey { + tags.push(tag(vec!["p", author])?); + } Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags)) } @@ -856,6 +867,46 @@ mod tests { assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err()); assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err()); } + + /// #2568 β€” a reaction must carry the target author's pubkey as a `p` tag + /// when known, so relay notification filters (`#p`) match it; and must + /// omit the tag (degrade, not fail) when the author is unknown. + #[test] + fn reaction_carries_target_author_p_tag_when_known() { + const AUTHOR_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let target = + EventId::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .unwrap(); + // Sign with a DIFFERENT key than the target author: nostr 0.44 build() + // discards self-referential `p` tags (a user doesn't notify themselves + // of their own reaction), which would silently drop the tag here. + let signer = Keys::generate(); + let event = build_reaction(target, "πŸ‘", Some(AUTHOR_HEX)) + .unwrap() + .sign_with_keys(&signer) + .unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(7)); + assert_eq!(event.content, "πŸ‘"); + assert_eq!(tags[0], vec!["e".to_string(), target.to_hex()]); + assert_eq!(tags[1], vec!["p".to_string(), AUTHOR_HEX.to_string()]); + assert_eq!(tags.len(), 2); + } + + #[test] + fn reaction_omits_p_tag_when_author_unknown() { + let target = + EventId::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .unwrap(); + let event = build_reaction(target, "πŸ‘", None) + .unwrap() + .sign_with_keys(&Keys::generate()) + .unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(7)); + assert_eq!(tags.len(), 1, "author unknown β†’ only the e tag is emitted"); + assert_eq!(tags[0], vec!["e".to_string(), target.to_hex()]); + } /// Builder layout regression for the NIP-IA owner-of-agent archive flow. /// Compares against `docs/nips/NIP-IA.md` Β§Vector 1. #[test]