From 1dd3c7e61dfb3aab9bbab62c5e7fcf50c3615163 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 24 Jul 2026 03:15:28 +0530 Subject: [PATCH 1/2] fix(sdk,desktop,cli): tag reactions with the target author (NIP-25 p tag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every kind:7 Buzz emits carries only `["e", ]`. NIP-25 also requires the reacted-to author's `p` tag, and that tag is the mechanism a notification feed keys on: without it `{"kinds":[7],"#p":[]}` can never match, so a reaction to your message is invisible to every NIP-01 client — including Amethyst, which is where this was reported from. It is not only an interop gap. The relay already advertises kind 7 in `PUSH_KINDS` (push_lease.rs) and the lease subscription shape is exactly `{"kinds":[7],"#p":[...]}`, so the push surface is built for a tag no Buzz client produces. Thread the target author through every reaction producer: - buzz-sdk `build_reaction` / `build_custom_emoji_reaction` take the author and emit the `p` tag. Required rather than optional so a caller cannot silently reintroduce the non-conformant form. - Both builders set `.allow_self_tagging()`. nostr 0.44 strips a `p` tag matching the signer, which would have quietly dropped the tag whenever someone reacts to their own message; a regression test pins this. - The desktop `add_reaction` command takes the target pubkey from the caller. The timeline already resolved it (`resolveEventAuthorPubkey`), so this uses the displayed author rather than the raw signer — for relay-signed agent messages those differ and the displayed author is who should be notified. - buzz-cli resolves the author with an `ids:[...]` lookup, since it is only given an event id. - buzz-acp's 👀/💬 status reactions carry the tag too; the author is already on the batch event. No in-app behaviour changes: neither the desktop feed nor the mobile activity filter includes kind 7, and the ACP mention subscription is kinds [9, approval, reminder], so p-tagged reactions do not wake agents. Fixes #2568 Signed-off-by: Shani Singh --- crates/buzz-acp/src/lib.rs | 6 +- crates/buzz-acp/src/pool.rs | 64 ++++++++++++++---- crates/buzz-cli/src/commands/reactions.rs | 31 ++++++++- crates/buzz-sdk/src/builders.rs | 66 ++++++++++++++++--- desktop/src-tauri/src/commands/messages.rs | 17 ++++- desktop/src-tauri/src/events.rs | 23 ++++++- .../channels/useChannelPaneHandlers.ts | 7 +- desktop/src/features/home/ui/HomeView.tsx | 1 + desktop/src/features/messages/hooks.ts | 17 ++++- .../src/features/pulse/lib/useNoteActions.ts | 1 + desktop/src/shared/api/tauri.ts | 8 ++- desktop/src/testing/e2eBridge.ts | 36 ++++++++-- 12 files changed, 237 insertions(+), 40 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 862732f478..713749c351 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2128,6 +2128,10 @@ async fn tokio_main() -> Result<()> { // buzz_event.event (needed for mode gate below). let author_hex = buzz_event.event.pubkey.to_hex(); let event_id_hex = buzz_event.event.id.to_hex(); + // Captured before `buzz_event.event` moves into the + // queue; the 👀 reaction below needs it for its + // NIP-25 `p` tag. + let event_author = buzz_event.event.pubkey; // Clone for the non-cancelling steer fork, which // needs the event to render the steer body. The // clone is unconditional because we don't know @@ -2155,7 +2159,7 @@ async fn tokio_main() -> Result<()> { let rc = ctx.rest_client.clone(); let eid = event_id_hex.clone(); tokio::spawn(async move { - pool::reaction_add(&rc, &eid, "👀").await; + pool::reaction_add(&rc, &eid, event_author, "👀").await; }); } // Event is already queued. If mode requires it AND diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fb08096792..ca418d169a 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1289,14 +1289,30 @@ pub async fn run_prompt_task( let liveness_handle = tokio::spawn(liveness); let liveness_guard = LivenessGuard::new(liveness_handle, liveness_state); - // Collects event IDs up front. On drop (any exit path — normal, early - // return, or panic), spawns best-effort cleanup of both 👀 and 💬. + // Collects reaction targets up front. On drop (any exit path — normal, + // early return, or panic), spawns best-effort cleanup of both 👀 and 💬. // See `ReactionGuard` docs for ordering guarantees and known edge cases. - let reaction_ids: Vec = batch + let reaction_targets: Vec = batch .as_ref() - .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) + .map(|b| { + b.events + .iter() + .map(|be| ReactionTarget { + event_id: be.event.id.to_hex(), + author: be.event.pubkey, + }) + .collect() + }) .unwrap_or_default(); - let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); + // Removal is a kind:5 delete keyed on the reaction's own id, so cleanup + // only ever needs the target event ids. + let _reaction_guard = ReactionGuard::new( + ctx.rest_client.clone(), + reaction_targets + .iter() + .map(|t| t.event_id.clone()) + .collect(), + ); // // Core memory is delivered inside the system prompt the harness already @@ -1755,11 +1771,11 @@ pub async fn run_prompt_task( // 💬 — fire-and-forget so the prompt fires immediately. // The guard's cleanup (spawned on drop) removes 💬 after the turn completes. // A brief race where 💬 appears slightly after the agent starts is acceptable. - if !reaction_ids.is_empty() { + if !reaction_targets.is_empty() { let rest = ctx.rest_client.clone(); - let ids = reaction_ids.clone(); + let targets = reaction_targets.clone(); tokio::spawn(async move { - react_working(&rest, &ids).await; + react_working(&rest, &targets).await; }); } @@ -3422,7 +3438,16 @@ fn pct_encode(s: &str) -> String { /// Builds a reaction event with `buzz_sdk::build_reaction`, signs it with /// the keys already stored in `RestClient`, and submits via `POST /events`. /// Returns immediately on timeout or any error — reactions are cosmetic. -pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) { +/// +/// `target_author` is the pubkey of the event being reacted to; it becomes the +/// NIP-25 `p` tag. The agent's 👀/💬 indicators are reactions like any other, +/// so they carry it too rather than emitting a non-conformant kind:7. +pub(crate) async fn reaction_add( + rest: &crate::relay::RestClient, + event_id: &str, + target_author: nostr::PublicKey, + emoji: &str, +) { let target_id = match nostr::EventId::from_hex(event_id) { Ok(id) => id, Err(e) => { @@ -3430,7 +3455,7 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str return; } }; - let builder = match buzz_sdk::build_reaction(target_id, emoji) { + let builder = match buzz_sdk::build_reaction(target_id, target_author, emoji) { Ok(b) => b, Err(e) => { tracing::warn!(event_id, emoji, "reaction add: build failed: {e}"); @@ -3580,14 +3605,27 @@ pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: & /// Prevents unbounded parallelism when a large batch of events arrives. const REACTION_CONCURRENCY: usize = 10; +/// An event an agent status reaction is attached to. +/// +/// Adding a reaction needs the target's author for the NIP-25 `p` tag; +/// removing one does not (a kind:5 delete keys off the reaction's own id). +#[derive(Clone)] +pub(crate) struct ReactionTarget { + pub(crate) event_id: String, + pub(crate) author: nostr::PublicKey, +} + /// Add 💬 to all events, capped at `REACTION_CONCURRENCY` concurrent requests. /// Awaited inline before the prompt fires. -async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) { - for chunk in event_ids.chunks(REACTION_CONCURRENCY) { +/// +/// Targets are `(event_id, author)` pairs — the author rides along so each +/// kind:7 can carry its NIP-25 `p` tag. +async fn react_working(rest: &crate::relay::RestClient, targets: &[ReactionTarget]) { + for chunk in targets.chunks(REACTION_CONCURRENCY) { futures_util::future::join_all( chunk .iter() - .map(|eid| reaction_add(rest, eid, REACTION_WORKING)), + .map(|t| reaction_add(rest, &t.event_id, t.author, REACTION_WORKING)), ) .await; } diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d30131..7e86edac9b 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -6,6 +6,32 @@ use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; +/// Look up the author of a reaction's target event. +/// +/// NIP-25 requires the reaction to carry the target author's `p` tag, and the +/// CLI is only given an event id — so resolve the author from the relay. The +/// relay rejects reactions whose target it cannot find, so a target that does +/// not resolve here would have been rejected on submit anyway; failing at this +/// point just produces a clearer message. +async fn fetch_reaction_target_author( + client: &BuzzClient, + event_id: &str, +) -> Result { + let raw = client + .query(&serde_json::json!({ "ids": [event_id], "limit": 1 })) + .await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse target event query: {e}")))?; + let author = events + .as_array() + .and_then(|arr| arr.first()) + .and_then(|ev| ev.get("pubkey")) + .and_then(|pk| pk.as_str()) + .ok_or_else(|| CliError::Other(format!("reaction target event {event_id} not found")))?; + nostr::PublicKey::parse(author) + .map_err(|e| CliError::Other(format!("target event has an invalid pubkey: {e}"))) +} + pub async fn cmd_add_reaction( client: &BuzzClient, event_id: &str, @@ -15,12 +41,13 @@ pub async fn cmd_add_reaction( validate_hex64(event_id)?; let target_eid = EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?; + let target_author = fetch_reaction_target_author(client, event_id).await?; let builder = if let Some(url) = emoji_url { - buzz_sdk::build_custom_emoji_reaction(target_eid, emoji, url) + buzz_sdk::build_custom_emoji_reaction(target_eid, target_author, emoji, url) .map_err(|e| CliError::Other(format!("build_custom_emoji_reaction failed: {e}")))? } else { - buzz_sdk::build_reaction(target_eid, emoji) + buzz_sdk::build_reaction(target_eid, target_author, emoji) .map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))? }; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index edad401bf9..568e500c3a 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -460,24 +460,43 @@ pub fn build_vote( } /// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars. +/// +/// `target_author` is the pubkey of the event being reacted to. NIP-25 requires +/// the reaction to carry the target's `p` tag; it is what lets the author (and +/// any NIP-01 client) find the reaction with a `{"kinds":[7],"#p":[]}` +/// notification filter. Omitting it makes the reaction invisible to every +/// notification surface, so it is a required argument rather than an option. +/// +/// `.allow_self_tagging()` is required: reacting to your own message makes the +/// `p` tag match the signer, and nostr 0.44 strips matching `p` tags by +/// default. Without it, self-reactions would silently regress to the +/// no-`p`-tag wire form. pub fn build_reaction( target_event_id: nostr::EventId, + target_author: nostr::PublicKey, emoji: &str, ) -> Result { if emoji.chars().count() > 64 { return Err(SdkError::EmojiTooLong); } - let tags = vec![tag(&["e", &target_event_id.to_hex()])?]; - Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags)) + let tags = vec![ + tag(&["e", &target_event_id.to_hex()])?, + tag(&["p", &target_author.to_hex()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(7), emoji) + .tags(tags) + .allow_self_tagging()) } /// Build a NIP-25 reaction event using a NIP-30 custom emoji. /// /// The reaction content is `:shortcode:` and the event carries exactly one /// `["emoji", shortcode, url]` tag, matching NIP-25's custom emoji reaction -/// guidance. +/// guidance. `target_author` carries the same NIP-25 `p` tag as +/// [`build_reaction`], including its `.allow_self_tagging()` requirement. pub fn build_custom_emoji_reaction( target_event_id: nostr::EventId, + target_author: nostr::PublicKey, shortcode: &str, url: &str, ) -> Result { @@ -486,9 +505,12 @@ pub fn build_custom_emoji_reaction( let content = format!(":{shortcode}:"); let tags = vec![ tag(&["e", &target_event_id.to_hex()])?, + tag(&["p", &target_author.to_hex()])?, tag(&["emoji", &shortcode, url])?, ]; - Ok(EventBuilder::new(Kind::Custom(7), content).tags(tags)) + Ok(EventBuilder::new(Kind::Custom(7), content) + .tags(tags) + .allow_self_tagging()) } /// Build a deletion event targeting a reaction (kind 5). @@ -2244,9 +2266,28 @@ mod tests { #[test] fn reaction_happy_path() { let eid = event_id(); - let ev = sign(build_reaction(eid, "👍").unwrap()); + let author = keys().public_key(); + let ev = sign(build_reaction(eid, author, "👍").unwrap()); assert_eq!(ev.kind.as_u16(), 7); assert_eq!(ev.content, "👍"); + assert!(has_tag(&ev, "e", &eid.to_hex())); + // NIP-25: the reaction MUST carry the target author's `p` tag so + // `{"kinds":[7],"#p":[author]}` notification filters match. + assert!(has_tag(&ev, "p", &author.to_hex())); + } + + #[test] + fn reaction_keeps_p_tag_when_reacting_to_own_message() { + // Self-reaction: the `p` tag equals the signer, which nostr 0.44 + // scrubs unless `.allow_self_tagging()` is set. Pins that the p tag + // survives so a self-reaction is still NIP-25 conformant. + let k = keys(); + let eid = event_id(); + let ev = build_reaction(eid, k.public_key(), "👍") + .unwrap() + .sign_with_keys(&k) + .expect("sign"); + assert!(has_tag(&ev, "p", &k.public_key().to_hex())); } #[test] @@ -2254,7 +2295,7 @@ mod tests { let eid = event_id(); let long_emoji = "a".repeat(65); assert!(matches!( - build_reaction(eid, &long_emoji), + build_reaction(eid, keys().public_key(), &long_emoji), Err(SdkError::EmojiTooLong) )); } @@ -2263,19 +2304,26 @@ mod tests { fn reaction_emoji_max_len_ok() { let eid = event_id(); let max_emoji = "a".repeat(64); - assert!(build_reaction(eid, &max_emoji).is_ok()); + assert!(build_reaction(eid, keys().public_key(), &max_emoji).is_ok()); } #[test] fn custom_emoji_reaction_happy_path() { let eid = event_id(); + let author = keys().public_key(); let ev = sign( - build_custom_emoji_reaction(eid, ":Party_Parrot:", "https://example.com/parrot.png") - .unwrap(), + build_custom_emoji_reaction( + eid, + author, + ":Party_Parrot:", + "https://example.com/parrot.png", + ) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 7); assert_eq!(ev.content, ":party_parrot:"); assert!(has_tag(&ev, "emoji", "party_parrot")); + assert!(has_tag(&ev, "p", &author.to_hex())); } #[test] diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23..369b9df946 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -816,21 +816,32 @@ pub async fn send_managed_agent_channel_message( }) } +/// `target_pubkey` is the *displayed* author of the reacted-to message — the +/// pubkey the timeline resolved via `resolveEventAuthorPubkey`, not the raw +/// signer. For relay-signed agent messages those differ, and the person who +/// should see the notification is the resolved author. Callers already hold +/// this on the message they are reacting to, so it is passed in rather than +/// re-derived here from a second relay round trip. #[tauri::command] pub async fn add_reaction( event_id: String, emoji: String, emoji_url: Option, + target_pubkey: String, state: State<'_, AppState>, ) -> Result<(), String> { let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let target_author = PublicKey::from_hex(target_pubkey.trim()) + .map_err(|e| format!("invalid target pubkey: {e}"))?; let builder = match emoji_url { // Custom-emoji reaction (NIP-30): kind:7 with `:shortcode:` content and // an `["emoji", shortcode, url]` tag. Delegates to the SDK builder so // 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())?, + Some(url) => { + buzz_sdk_pkg::build_custom_emoji_reaction(target_eid, target_author, emoji.trim(), &url) + .map_err(|e| format!("invalid custom emoji reaction: {e}"))? + } + None => events::build_reaction(target_eid, target_author, emoji.trim())?, }; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..8b9d895481 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -443,14 +443,31 @@ pub fn build_delete_compat( // ── Reactions ──────────────────────────────────────────────────────────────── /// Kind 7 — NIP-25 reaction. -pub fn build_reaction(target_event_id: EventId, emoji: &str) -> Result { +/// +/// `target_author` is the pubkey of the reacted-to event. NIP-25 requires the +/// `p` tag: it is what makes the reaction visible to a +/// `{"kinds":[7],"#p":[]}` notification filter (the shape the relay's +/// push-lease surface advertises for kind 7). +/// +/// `.allow_self_tagging()` is required: reacting to your own message makes the +/// `p` tag match the signer, and nostr strips matching `p` tags by default. +pub fn build_reaction( + target_event_id: EventId, + target_author: nostr::PublicKey, + emoji: &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()])?]; - Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags)) + let tags = vec![ + tag(vec!["e", &target_event_id.to_hex()])?, + tag(vec!["p", &target_author.to_hex()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(7), emoji) + .tags(tags) + .allow_self_tagging()) } /// Kind 5 — delete a reaction event. diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 8c5f54fffc..3fc2dee505 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -326,11 +326,16 @@ export function useChannelPaneHandlers({ ); const handleToggleReaction = React.useCallback( - async (message: { id: string }, emoji: string, remove: boolean) => { + async ( + message: { id: string; pubkey?: string }, + emoji: string, + remove: boolean, + ) => { await toggleMutateRef.current({ emoji, eventId: message.id, remove, + targetPubkey: message.pubkey, }); }, [], diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 992702f431..579d3f734c 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -921,6 +921,7 @@ export function HomeView({ emoji, eventId: message.id, remove, + targetPubkey: message.pubkey, }); await threadContext.refreshReactions(); await channelMessagesQuery.refetch(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..60627a1ef8 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -643,14 +643,27 @@ export function useToggleReactionMutation() { eventId: string; emoji: string; remove: boolean; + /** + * Resolved author of the message being reacted to. Becomes the kind:7 + * NIP-25 `p` tag so the author's notification filter can match. + */ + targetPubkey: string | undefined; } >({ - mutationFn: async ({ eventId, emoji, remove }) => { + mutationFn: async ({ eventId, emoji, remove, targetPubkey }) => { if (remove) { await removeReaction(eventId, emoji); return; } + // NIP-25 requires the target author's `p` tag, so a message whose author + // we could not resolve cannot produce a conformant reaction. Every + // timeline row carries a resolved author, so this is a guard, not a + // reachable UI state. + if (!targetPubkey) { + throw new Error("Cannot react: the message author is unknown."); + } + // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL // from the cached community palette so the kind:7 carries the NIP-30 // `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL. @@ -658,7 +671,7 @@ export function useToggleReactionMutation() { emoji, queryClient.getQueryData(customEmojiQueryKey), ); - await addReaction(eventId, emoji, emojiUrl); + await addReaction(eventId, emoji, targetPubkey, emojiUrl); }, }); } diff --git a/desktop/src/features/pulse/lib/useNoteActions.ts b/desktop/src/features/pulse/lib/useNoteActions.ts index 8178cdc53a..65d9abdc6d 100644 --- a/desktop/src/features/pulse/lib/useNoteActions.ts +++ b/desktop/src/features/pulse/lib/useNoteActions.ts @@ -76,6 +76,7 @@ export function usePulseNoteActions({ eventId: note.id, emoji: "+", remove, + targetPubkey: note.pubkey, }); if (currentPubkey) { void queryClient.invalidateQueries({ diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index add41deb24..a3ac8152a3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -629,12 +629,18 @@ export async function deleteMessage( await invokeTauri("delete_message", { channelId, eventId }); } +/** + * `targetPubkey` is the resolved author of the reacted-to message. It becomes + * the NIP-25 `p` tag on the kind:7, which is what lets the author find the + * reaction with a `{"kinds":[7],"#p":[self]}` notification filter. + */ export async function addReaction( eventId: string, emoji: string, + targetPubkey: string, emojiUrl?: string, ): Promise { - await invokeTauri("add_reaction", { eventId, emoji, emojiUrl }); + await invokeTauri("add_reaction", { eventId, emoji, emojiUrl, targetPubkey }); } export async function removeReaction( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4433961adf..4fc579dd0e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8283,6 +8283,17 @@ function findMockEventChannel(eventId: string): string | undefined { return undefined; } +/** Author of a stored mock event, for the NIP-25 `p` tag on reactions. */ +function findMockEventAuthor(eventId: string): string | undefined { + for (const events of mockMessages.values()) { + const match = events.find((event) => event.id === eventId); + if (match) { + return match.pubkey; + } + } + return undefined; +} + /** * Mock the `add_reaction` Tauri command. Mirrors the real Rust command: a * kind:7 whose content is the emoji, plus — for a custom emoji — the NIP-30 @@ -8292,7 +8303,12 @@ function findMockEventChannel(eventId: string): string | undefined { * kind:7). Unicode reactions carry no emoji tag, like the real command. */ async function handleAddReaction( - args: { eventId: string; emoji: string; emojiUrl?: string | null }, + args: { + eventId: string; + emoji: string; + emojiUrl?: string | null; + targetPubkey?: string; + }, config: E2eConfig | undefined, ): Promise { const channelId = findMockEventChannel(args.eventId); @@ -8300,11 +8316,21 @@ async function handleAddReaction( throw new Error(`mock add_reaction: unknown target event ${args.eventId}`); } + // The real command is handed the target author by its caller; the mock can + // also read it back out of its own event store, so specs that drive + // `add_reaction` directly need not thread a pubkey through. + const targetPubkey = + args.targetPubkey ?? findMockEventAuthor(args.eventId) ?? ""; + const emoji = args.emoji.trim(); - // Real add_reaction events carry only the target `e` tag. Channel live - // subscriptions already know which channel matched and restore that context - // before merging the event into the timeline cache. - const tags: string[][] = [["e", args.eventId]]; + // Real add_reaction events carry the target `e` tag plus the NIP-25 `p` tag + // naming the reacted-to author. Channel live subscriptions already know which + // channel matched and restore that context before merging the event into the + // timeline cache. + const tags: string[][] = [ + ["e", args.eventId], + ["p", targetPubkey], + ]; if (args.emojiUrl) { const shortcode = emoji.replace(/^:+/, "").replace(/:+$/, "").toLowerCase(); tags.push(["emoji", shortcode, args.emojiUrl]); From 8bc1e973887ef3a99bdf7c3a6fdc6ccfb808c17f Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 24 Jul 2026 06:41:34 +0530 Subject: [PATCH 2/2] fix(mobile): carry the NIP-25 p tag on reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flutter client had the same gap as the desktop/CLI/SDK paths: its `addReaction` emitted only `["e", target]` plus an optional NIP-30 emoji tag, so mobile-authored reactions were invisible to any `{"kinds":[7],"#p":[self]}` notification filter. `ChannelActions.addReaction` now takes the target author and emits its `p` tag. All four call sites already hold it — channel and thread rows and the message-action sheet pass `TimelineMessage.pubkey`, and Pulse threads `UserNote.pubkey` through `toggleNoteUpvote`. Unlike the Rust builders this needs no self-tagging opt-out: the Dart signer passes tags through verbatim, so a self-reaction keeps its `p` tag. Found while confirming there were no remaining kind:7 emit sites without the tag. The other two `Kind::Custom(7)` constructions in the tree (`buzz-db/src/event.rs`, `desktop/src-tauri/src/commands/social.rs`) are `#[cfg(test)]` fixtures. Signed-off-by: Shani Singh --- .../channels/channel_management_provider.dart | 13 ++++++++++++- mobile/lib/features/channels/message_actions.dart | 4 ++-- mobile/lib/features/channels/reaction_row.dart | 2 +- mobile/lib/features/pulse/note_card.dart | 1 + mobile/lib/features/pulse/pulse_actions.dart | 4 +++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 97953653c7..b49144fc8c 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -434,7 +434,17 @@ class ChannelActions { _ref.invalidate(channelMembersProvider(channelId)); } - Future addReaction(String eventId, String emoji) async { + /// Add a NIP-25 reaction to [eventId], authored by [targetPubkey]. + /// + /// [targetPubkey] becomes the `p` tag. NIP-25 requires it, and it is what + /// lets the reacted-to author find the reaction with a + /// `{"kinds":[7],"#p":[]}` notification filter — without it a reaction + /// is invisible to every notification surface. + Future addReaction( + String eventId, + String targetPubkey, + String emoji, + ) async { final shortcode = normalizeShortcode(emoji); final emojiUrl = reactionEmojiUrl( emoji, @@ -445,6 +455,7 @@ class ChannelActions { content: emoji, tags: [ ['e', eventId], + ['p', targetPubkey.toLowerCase()], if (shortcode != null && emojiUrl != null) ['emoji', shortcode, emojiUrl], ], diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index fe03bcb65e..56ceb1a424 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -55,7 +55,7 @@ void showMessageActions({ Navigator.of(sheetContext).pop(); ref .read(channelActionsProvider) - .addReaction(message.id, emoji); + .addReaction(message.id, message.pubkey, emoji); }, child: Container( width: 44, @@ -76,7 +76,7 @@ void showMessageActions({ onSelect: (emoji) { ref .read(channelActionsProvider) - .addReaction(message.id, emoji); + .addReaction(message.id, message.pubkey, emoji); }, ); }, diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index a721ef1988..796bf32890 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -21,7 +21,7 @@ void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) { if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) { actions.removeReaction(reaction.currentUserReactionId!, emoji); } else { - actions.addReaction(message.id, emoji); + actions.addReaction(message.id, message.pubkey, emoji); } } diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index c843de7010..6b8d10d760 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -159,6 +159,7 @@ class NoteCard extends HookConsumerWidget { await toggleNoteUpvote( ref, noteId: note.id, + notePubkey: note.pubkey, isUpvoted: reaction.reactedByCurrentUser, reactionEventId: reaction.currentUserReactionId, ); diff --git a/mobile/lib/features/pulse/pulse_actions.dart b/mobile/lib/features/pulse/pulse_actions.dart index f1657abd13..766a6b4957 100644 --- a/mobile/lib/features/pulse/pulse_actions.dart +++ b/mobile/lib/features/pulse/pulse_actions.dart @@ -41,9 +41,11 @@ Future publishNote( ref.invalidate(likedNotesProvider); } +/// [notePubkey] is the note author — it becomes the reaction's NIP-25 `p` tag. Future toggleNoteUpvote( WidgetRef ref, { required String noteId, + required String notePubkey, required bool isUpvoted, String? reactionEventId, }) async { @@ -53,7 +55,7 @@ Future toggleNoteUpvote( await actions.removeReaction(reactionEventId, '+'); } } else { - await actions.addReaction(noteId, '+'); + await actions.addReaction(noteId, notePubkey, '+'); } ref.invalidate(likedNotesProvider); }