From ce9a8b663ef8cc9e53e4833ca188e3d0cc5f1924 Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Tue, 14 Jul 2026 11:12:25 -0700 Subject: [PATCH 1/2] feat(relay): warn on offline explicit mentions (#1743) When a kind:9 message carries explicit Buzz mention tags, look up Redis presence across the workspace after successful ingest and send NOTICE frames to the sender for recipients without current presence. This avoids treating reply-author p tags as mentions. Desktop send paths now attach explicit mention tags, consume relay NOTICE frames, and surface them as warning toasts. Co-authored-by: Cameron Hotchkies Signed-off-by: Cameron Hotchkies Co-authored-by: Goose Ai-assisted: true --- crates/buzz-relay/src/handlers/event.rs | 191 +++++++++++++++- crates/buzz-sdk/src/builders.rs | 15 +- desktop/src-tauri/src/events.rs | 168 ++------------ desktop/src-tauri/src/events_tests.rs | 215 ++++++++++++++++++ desktop/src/app/App.tsx | 2 + desktop/src/features/messages/hooks.ts | 7 +- .../messages/ui/useMentionSendFlow.ts | 16 +- desktop/src/shared/api/relayClientSession.ts | 8 +- desktop/src/shared/api/relayNotices.test.mjs | 54 +++++ desktop/src/shared/api/relayNotices.ts | 64 ++++++ .../src/shared/api/useRelayNoticeToasts.ts | 12 + .../shared/lib/mentionReferenceTags.test.mjs | 49 ++++ .../src/shared/lib/mentionReferenceTags.ts | 49 ++++ 13 files changed, 681 insertions(+), 169 deletions(-) create mode 100644 desktop/src-tauri/src/events_tests.rs create mode 100644 desktop/src/shared/api/relayNotices.test.mjs create mode 100644 desktop/src/shared/api/relayNotices.ts create mode 100644 desktop/src/shared/api/useRelayNoticeToasts.ts create mode 100644 desktop/src/shared/lib/mentionReferenceTags.test.mjs create mode 100644 desktop/src/shared/lib/mentionReferenceTags.ts diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a67797385b..0101eabb22 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1,6 +1,9 @@ //! EVENT handler — WS dispatcher → ingest pipeline → fan-out. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use axum::body::Bytes; use tracing::{debug, error, info, warn}; @@ -73,6 +76,105 @@ where frames } +const EXPLICIT_MENTION_TAG: &str = "mention"; +const MAX_EXPLICIT_MENTION_NOTICES: usize = 50; + +/// Extract Buzz explicit mentions from kind:9 message metadata. +/// +/// Raw Nostr `p` tags are structural reply/subscription metadata in Buzz, so +/// they are deliberately ignored here. Only client-authored +/// `["mention", ""]` tags mean the sender explicitly mentioned a +/// user and should receive an offline-presence warning. +fn explicit_mention_pubkeys_for_kind(event: &Event, kind_u32: u32) -> Vec { + if kind_u32 != buzz_core::kind::KIND_STREAM_MESSAGE { + return Vec::new(); + } + + let mut seen = HashSet::new(); + let mut pubkeys = Vec::new(); + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some(EXPLICIT_MENTION_TAG) { + continue; + } + + let Some(pubkey_hex) = parts.get(1) else { + continue; + }; + let Ok(pubkey) = PublicKey::from_hex(pubkey_hex) else { + continue; + }; + if pubkey == event.pubkey { + continue; + } + + if seen.insert(pubkey.to_hex()) { + pubkeys.push(pubkey); + if pubkeys.len() >= MAX_EXPLICIT_MENTION_NOTICES { + break; + } + } + } + + pubkeys +} + +fn has_current_buzz_presence(status: Option<&String>) -> bool { + status.is_some_and(|status| !status.eq_ignore_ascii_case("offline")) +} + +fn offline_mention_notice_pubkeys( + accepted: bool, + mentioned_pubkeys: &[PublicKey], + presence_by_pubkey: &HashMap, +) -> Vec { + if !accepted { + return Vec::new(); + } + + mentioned_pubkeys + .iter() + .copied() + .filter(|pubkey| !has_current_buzz_presence(presence_by_pubkey.get(&pubkey.to_hex()))) + .collect() +} + +async fn send_offline_mention_notices( + state: &AppState, + conn: &ConnectionState, + mentioned_pubkeys: &[PublicKey], +) { + if mentioned_pubkeys.is_empty() { + return; + } + + let presence_by_pubkey = match state + .pubsub + .get_presence_bulk(&conn.tenant, mentioned_pubkeys) + .await + { + Ok(presence) => presence, + Err(error) => { + warn!( + error = %error, + mention_count = mentioned_pubkeys.len(), + "offline mention presence lookup failed; skipping sender NOTICE" + ); + metrics::counter!("buzz_mention_presence_lookup_errors_total").increment(1); + return; + } + }; + + for pubkey in offline_mention_notice_pubkeys(true, mentioned_pubkeys, &presence_by_pubkey) { + let pubkey_hex = pubkey.to_hex(); + metrics::counter!("buzz_mention_offline_notices_total").increment(1); + conn.send(RelayMessage::notice(&format!( + "offline: explicitly mentioned user {pubkey_hex} has no current Buzz presence in this workspace" + ))); + } +} + fn send_fanout_frames<'a, I>( state: &AppState, recipients: I, @@ -725,6 +827,11 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { if result.accepted { @@ -744,6 +851,13 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { // Sanitize internal errors — don't leak DB/system details over WS. @@ -1209,6 +1323,81 @@ mod tests { ); } + #[test] + fn explicit_mention_pubkeys_ignore_structural_p_tags() { + let sender = Keys::generate(); + let reply_author = Keys::generate(); + let explicit = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags([ + Tag::parse(["h", &uuid::Uuid::new_v4().to_string()]).expect("h tag"), + Tag::parse(["p", &reply_author.public_key().to_hex()]).expect("p tag"), + Tag::parse(["mention", &explicit.public_key().to_hex()]).expect("mention tag"), + ]) + .sign_with_keys(&sender) + .expect("sign event"); + + assert_eq!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_STREAM_MESSAGE), + vec![explicit.public_key()] + ); + } + + #[test] + fn explicit_mention_pubkeys_dedupe_filter_and_cap() { + let sender = Keys::generate(); + let duplicate = Keys::generate().public_key(); + let mut expected = vec![duplicate]; + let mut tags = vec![ + Tag::parse(["h", &uuid::Uuid::new_v4().to_string()]).expect("h tag"), + Tag::parse(["mention", &duplicate.to_hex()]).expect("mention tag"), + Tag::parse(["mention", &duplicate.to_hex().to_ascii_uppercase()]) + .expect("duplicate mention tag"), + Tag::parse(["mention", &sender.public_key().to_hex()]).expect("self mention tag"), + Tag::parse(["mention", "not-a-pubkey"]).expect("invalid mention tag"), + ]; + + for _ in 1..(super::MAX_EXPLICIT_MENTION_NOTICES + 5) { + let pubkey = Keys::generate().public_key(); + expected.push(pubkey); + tags.push(Tag::parse(["mention", &pubkey.to_hex()]).expect("mention tag")); + } + expected.truncate(super::MAX_EXPLICIT_MENTION_NOTICES); + + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags(tags) + .sign_with_keys(&sender) + .expect("sign event"); + + assert_eq!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_STREAM_MESSAGE), + expected + ); + assert!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_FORUM_POST).is_empty(), + "offline mention NOTICEs are only for stream messages" + ); + } + + #[test] + fn offline_mention_notice_pubkeys_require_acceptance_and_missing_presence() { + let offline = Keys::generate().public_key(); + let explicit_offline = Keys::generate().public_key(); + let online = Keys::generate().public_key(); + let away = Keys::generate().public_key(); + let mentioned = vec![offline, explicit_offline, online, away]; + let mut presence = HashMap::new(); + presence.insert(explicit_offline.to_hex(), "offline".to_string()); + presence.insert(online.to_hex(), "online".to_string()); + presence.insert(away.to_hex(), "away".to_string()); + + assert!(super::offline_mention_notice_pubkeys(false, &mentioned, &presence).is_empty()); + assert_eq!( + super::offline_mention_notice_pubkeys(true, &mentioned, &presence), + vec![offline, explicit_offline] + ); + } + #[test] fn channel_scoped_content_kinds_require_h_tags() { for kind in [ diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 948fa775f5..303f2b9b59 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -189,16 +189,19 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec) -> Result<(), SdkErr Ok(()) } -/// Deduplicate and cap mentions, emitting p-tags. +/// Deduplicate and cap mentions, emitting delivery `p` tags and explicit +/// Buzz `mention` reference tags. The relay uses the latter to distinguish +/// actual @mentions from structural reply-author `p` tags. fn mention_tags(mentions: &[&str], tags: &mut Vec) -> Result<(), SdkError> { if mentions.len() > crate::mentions::MENTION_CAP { return Err(SdkError::TooManyMentions); } let mut seen = std::collections::HashSet::new(); for &hex in mentions { - let lower = hex.to_ascii_lowercase(); + let lower = check_pubkey_hex(hex, "mention")?; if seen.insert(lower.clone()) { tags.push(tag(&["p", &lower])?); + tags.push(tag(&["mention", &lower])?); } } Ok(()) @@ -218,7 +221,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `channel_id`: target channel UUID /// - `content`: message text (max 64 KiB) /// - `thread_ref`: optional NIP-10 reply context -/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) +/// - `mentions`: pubkey hex strings to p-tag and `mention`-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors pub fn build_message( @@ -2404,12 +2407,14 @@ mod tests { } #[test] - fn message_mentions_deduped() { + fn message_mentions_emit_delivery_and_reference_tags_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); let p_tags = tag_values(&ev, "p"); - assert_eq!(p_tags.len(), 1); + let mention_tags = tag_values(&ev, "mention"); + assert_eq!(p_tags, vec![hex]); + assert_eq!(mention_tags, vec![hex]); } #[test] diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf..d692c9f35e 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -75,6 +75,13 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { } fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { + if mentions.len() > MAX_MENTIONS { + return Err(format!( + "too many mention reference tags (max {MAX_MENTIONS})" + )); + } + + let mut seen = std::collections::HashSet::new(); for mention in mentions { if mention.first().map(String::as_str) != Some("mention") { return Err(format!( @@ -82,11 +89,17 @@ fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Resu mention.first() )); } + if mention.len() != 2 { + return Err("mention reference tags must contain exactly prefix and pubkey".into()); + } let Some(pubkey) = mention.get(1) else { return Err("mention reference tag missing pubkey".into()); }; check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); + let lower = pubkey.to_ascii_lowercase(); + if seen.insert(lower.clone()) { + tags.push(tag(vec!["mention", &lower])?); + } } Ok(()) } @@ -846,154 +859,5 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - - assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); - // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); - assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); - } - - #[test] - fn archive_request_rejects_replaced_by_equal_target() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None) - .unwrap_err(); - assert!(err.contains("replaced-by")); - } - - #[test] - fn unarchive_request_layout_self_path() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let builder = build_unarchive_identity_request( - TARGET_HEX, - "I am active again.", - Some("returned"), - None, - ) - .unwrap(); - let target_secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000002", - ) - .unwrap(); - let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); - let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); - // Self-unarchive: the `p` tag MUST point at the signer. Verifies our - // `.allow_self_tagging()` call survives nostr 0.44's default scrub. - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "returned"]); - assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); - assert_eq!(event.pubkey.to_hex(), TARGET_HEX); - } - - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; - const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - - fn edit_tags(mentions: &[&str]) -> Vec> { - let channel = Uuid::parse_str(CH_ID).unwrap(); - let target = - EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") - .unwrap(); - let builder = - build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); - let secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000003", - ) - .unwrap(); - let event = builder.sign_with_keys(&Keys::new(secret)).unwrap(); - event.tags.iter().map(|t| t.as_slice().to_vec()).collect() - } - - #[test] - fn edit_with_added_mention_emits_p_tag() { - let tags = edit_tags(&[ALICE_HEX]); - assert_eq!(tags[0][0], "h"); - assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). - assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); - } - - #[test] - fn edit_with_no_added_mentions_emits_no_p_tag() { - // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. - // The edit event must carry no `p` tag and re-wake nobody. - let tags = edit_tags(&[]); - assert!( - !tags - .iter() - .any(|t| t.first().map(String::as_str) == Some("p")), - "unchanged-mention edit must not emit any `p` tag, got {tags:?}" - ); - } - - #[test] - fn edit_mentions_are_deduped_and_lowercased() { - let alice_upper = ALICE_HEX.to_ascii_uppercase(); - let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]); - let p_tags: Vec<&Vec> = tags - .iter() - .filter(|t| t.first().map(String::as_str) == Some("p")) - .collect(); - // ALICE appears twice (mixed case) but collapses to one lowercase tag. - assert_eq!( - p_tags.len(), - 2, - "duplicate mention must collapse, got {p_tags:?}" - ); - assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); - assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); - } -} +#[path = "events_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/events_tests.rs b/desktop/src-tauri/src/events_tests.rs new file mode 100644 index 0000000000..e7a017ca3d --- /dev/null +++ b/desktop/src-tauri/src/events_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use nostr::Keys; + +#[test] +fn channel_builders_reject_hash_only_names() { + let channel_id = Uuid::new_v4(); + assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err()); + assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err()); +} + +/// Builder layout regression for the NIP-IA owner-of-agent archive flow. +/// Compares against `docs/nips/NIP-IA.md` §Vector 1. +#[test] +fn archive_identity_request_matches_spec_vector_1_layout() { + const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const CONDITIONS: &str = "kind=1&created_at<1713957000"; + const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + let auth: [String; 4] = [ + "auth".into(), + OWNER_HEX.into(), + CONDITIONS.into(), + SIG.into(), + ]; + let builder = build_archive_identity_request( + TARGET_HEX, + "Archiving zombie agent after rebuild.", + Some("bot-rebuilt"), + None, + Some(&auth), + ) + .expect("build_archive_identity_request"); + + let owner_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(); + let owner_keys = Keys::new(owner_secret); + let event = builder.sign_with_keys(&owner_keys).unwrap(); + + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); + // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); + assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); +} + +#[test] +fn archive_request_rejects_replaced_by_equal_target() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let err = + build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None).unwrap_err(); + assert!(err.contains("replaced-by")); +} + +#[test] +fn build_message_accepts_deduped_mention_reference_tags() { + const PUBKEY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const PUBKEY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let channel_id = uuid::Uuid::new_v4(); + let builder = build_message( + channel_id, + "hi", + None, + &[PUBKEY_A], + &[], + &[], + &[ + vec!["mention".into(), PUBKEY_A.to_ascii_uppercase()], + vec!["mention".to_string(), PUBKEY_A.to_string()], + vec!["mention".to_string(), PUBKEY_B.to_string()], + ], + ) + .expect("build message"); + + let event = builder.sign_with_keys(&Keys::generate()).expect("sign"); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert!(tags.contains(&vec!["p".to_string(), PUBKEY_A.to_string()])); + assert_eq!( + tags.iter() + .filter(|tag| tag.first().map(String::as_str) == Some("mention")) + .cloned() + .collect::>(), + vec![ + vec!["mention".to_string(), PUBKEY_A.to_string()], + vec!["mention".to_string(), PUBKEY_B.to_string()], + ] + ); +} + +#[test] +fn build_message_rejects_malformed_mention_reference_tags() { + const PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let channel_id = uuid::Uuid::new_v4(); + + let wrong_prefix = build_message( + channel_id, + "hi", + None, + &[], + &[], + &[], + &[vec!["p".into(), PUBKEY.into()]], + ) + .unwrap_err(); + assert!(wrong_prefix.contains("mention reference tags must use 'mention' prefix")); + + let extra_field = build_message( + channel_id, + "hi", + None, + &[], + &[], + &[], + &[vec!["mention".into(), PUBKEY.into(), "extra".into()]], + ) + .unwrap_err(); + assert!(extra_field.contains("exactly prefix and pubkey")); + + let too_many = vec![vec!["mention".into(), PUBKEY.into()]; MAX_MENTIONS + 1]; + let too_many_err = build_message(channel_id, "hi", None, &[], &[], &[], &too_many).unwrap_err(); + assert!(too_many_err.contains("too many mention reference tags")); +} + +#[test] +fn unarchive_request_layout_self_path() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let builder = + build_unarchive_identity_request(TARGET_HEX, "I am active again.", Some("returned"), None) + .unwrap(); + let target_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); + // Self-unarchive: the `p` tag MUST point at the signer. Verifies our + // `.allow_self_tagging()` call survives nostr 0.44's default scrub. + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "returned"]); + assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); + assert_eq!(event.pubkey.to_hex(), TARGET_HEX); +} + +// ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── +// +// The composer diffs the edited body's mentions against the original and +// hands `build_message_edit` only the *newly added* pubkeys. These tests +// pin the builder's contract given that contract: emit a `p` per added +// mention (deduped, lowercased), and none when the added set is empty +// (typo-fix edit) — so an unchanged mention set re-wakes nobody. + +const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; +const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + +fn edit_tags(mentions: &[&str]) -> Vec> { + let channel = Uuid::parse_str(CH_ID).unwrap(); + let target = + EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") + .unwrap(); + let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000003", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(secret)).unwrap(); + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +#[test] +fn edit_with_added_mention_emits_p_tag() { + let tags = edit_tags(&[ALICE_HEX]); + assert_eq!(tags[0][0], "h"); + assert_eq!(tags[1][0], "e"); + // The `p` tag rides right after the `e` tag (insertion order). + assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); +} + +#[test] +fn edit_with_no_added_mentions_emits_no_p_tag() { + // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. + // The edit event must carry no `p` tag and re-wake nobody. + let tags = edit_tags(&[]); + assert!( + !tags + .iter() + .any(|t| t.first().map(String::as_str) == Some("p")), + "unchanged-mention edit must not emit any `p` tag, got {tags:?}" + ); +} + +#[test] +fn edit_mentions_are_deduped_and_lowercased() { + let alice_upper = ALICE_HEX.to_ascii_uppercase(); + let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]); + let p_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("p")) + .collect(); + // ALICE appears twice (mixed case) but collapses to one lowercase tag. + assert_eq!( + p_tags.len(), + 2, + "duplicate mention must collapse, got {p_tags:?}" + ); + assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); + assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..8621140b9c 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -60,6 +60,7 @@ import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityAp import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; +import { useRelayNoticeToasts } from "@/shared/api/useRelayNoticeToasts"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -328,6 +329,7 @@ function CommunityApp({ // as toasts. Mounted before useCommunityInit so the listeners are registered // ahead of the first apply_workspace call. useNestNotifications(); + useRelayNoticeToasts(); // Composite key: changes when community ID changes OR when // the active community's config is updated (relayUrl/token). diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 545500bfe4..050288df15 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -466,13 +466,14 @@ export function useSendMessageMutation( mentionPubkeys, ); - // Messages carrying media OR custom-emoji tags MUST go through REST so - // the relay's tag validation runs. The WebSocket path emits no extra - // tags, so emoji-only messages would otherwise lose their emoji tag. +// Messages carrying media, custom-emoji, explicit mention reference, or + // link-preview tags MUST go through REST so the Rust tag guards validate + // each channel. The WebSocket path emits only structural send tags. if ( parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || + mentionTags.length > 0 || linkPreviewTags.length > 0 ) { const cachedMessages = diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index cc51c73345..4cca25bf27 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -36,6 +36,8 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + + import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { getErrorMessage, @@ -92,6 +94,8 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; + + export function useMentionSendFlow({ channelId, channelLinks, @@ -511,6 +515,7 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, ); + }; const restoreComposerAfterFailure = () => { persistCanceledDraft(); @@ -555,13 +560,14 @@ export function useMentionSendFlow({ ), ]), ); - const finalOutgoingTags = mergeOutgoingTags( - mediaTags, - outgoingTags ?? [], + const finalOutgoingTags = mergeOutgoingTagsWithReferenceMentions( + mergeOutgoingTags(mediaTags, outgoingTags ?? []), + mentionPubkeys, ); if (signal?.aborted) return; await send( finalContent, + mentionPubkeys, finalOutgoingTags, sendChannelId, @@ -867,8 +873,8 @@ export function useMentionSendFlow({ const mentionPubkeys = pendingNonMemberSend.mentionPubkeys.filter( (pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)), ); - const outgoingTags = mergeOutgoingTagsWithReferenceMentions( - pendingNonMemberSend.outgoingTags, + const outgoingTags = withMentionReferenceTags( + pendingNonMemberSend.outgoingTags ?? [], nonMemberPubkeys, ); void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index fd6758f791..7e747c2f46 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -44,6 +44,8 @@ import { requestHistoryGated, } from "@/shared/api/relayGateBoundary"; import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter"; +import { handleRelayNoticeFrame } from "@/shared/api/relayNotices"; +import { withMentionReferenceTags } from "@/shared/lib/mentionReferenceTags"; import { isServiceRestartClose, isWebSocketClose, @@ -261,9 +263,7 @@ export class RelayClient { for (const pubkey of mentionPubkeys) { tags.push(["p", pubkey]); } - for (const tag of extraTags) { - tags.push(tag); - } + tags.push(...withMentionReferenceTags(extraTags, mentionPubkeys)); const event = await signRelayEvent({ kind: KIND_STREAM_MESSAGE, @@ -784,6 +784,8 @@ export class RelayClient { return; } + if (handleRelayNoticeFrame(data)) return; + const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { await this.handleAuthChallenge(rest[0], generation); diff --git a/desktop/src/shared/api/relayNotices.test.mjs b/desktop/src/shared/api/relayNotices.test.mjs new file mode 100644 index 0000000000..06cf71ac5b --- /dev/null +++ b/desktop/src/shared/api/relayNotices.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearRelayNoticeListenersForTests, + emitRelayNotice, + handleRelayNoticeFrame, + relayNoticeMessageFromFrame, + subscribeToRelayNotices, +} from "./relayNotices.ts"; + +test.afterEach(() => { + clearRelayNoticeListenersForTests(); +}); + +test("relayNoticeMessageFromFrame parses NOTICE messages only", () => { + assert.equal(relayNoticeMessageFromFrame(["NOTICE", "hello"]), "hello"); + assert.equal(relayNoticeMessageFromFrame(["NOTICE", " "]), null); + assert.equal(relayNoticeMessageFromFrame(["OK", "event", true, ""]), null); + assert.equal(relayNoticeMessageFromFrame({ type: "NOTICE" }), null); +}); + +test("handleRelayNoticeFrame consumes NOTICE frames without treating them as errors", () => { + const messages = []; + + assert.equal( + handleRelayNoticeFrame(["NOTICE", "offline warning"], (message) => { + messages.push(message); + }), + true, + ); + assert.deepEqual(messages, ["offline warning"]); + + assert.equal(handleRelayNoticeFrame(["NOTICE", " "], messages.push), true); + assert.deepEqual(messages, ["offline warning"]); + assert.equal( + handleRelayNoticeFrame(["EOSE", "sub-id"], messages.push), + false, + ); +}); + +test("emitRelayNotice notifies subscribers and respects unsubscribe", () => { + const messages = []; + const unsubscribe = subscribeToRelayNotices((message) => { + messages.push(message); + }); + + emitRelayNotice("first"); + unsubscribe(); + emitRelayNotice("second"); + emitRelayNotice(" "); + + assert.deepEqual(messages, ["first"]); +}); diff --git a/desktop/src/shared/api/relayNotices.ts b/desktop/src/shared/api/relayNotices.ts new file mode 100644 index 0000000000..f8809cb9b9 --- /dev/null +++ b/desktop/src/shared/api/relayNotices.ts @@ -0,0 +1,64 @@ +export type RelayNoticeListener = (message: string) => void; + +const relayNoticeListeners = new Set(); + +export function subscribeToRelayNotices( + listener: RelayNoticeListener, +): () => void { + relayNoticeListeners.add(listener); + return () => { + relayNoticeListeners.delete(listener); + }; +} + +export function emitRelayNotice(message: string): void { + if (message.trim().length === 0) { + return; + } + + for (const listener of relayNoticeListeners) { + try { + listener(message); + } catch (error) { + console.error("Failed to deliver relay NOTICE", error); + } + } +} + +export function relayNoticeMessageFromFrame(frame: unknown): string | null { + if (!Array.isArray(frame) || frame[0] !== "NOTICE") { + return null; + } + + const message = frame[1]; + if (typeof message !== "string" || message.trim().length === 0) { + return null; + } + + return message; +} + +/** + * Consume a NIP-01 NOTICE frame and surface it to registered UI listeners. + * Returns true for all NOTICE frames, including malformed/blank ones, so callers + * do not treat relay warnings as socket failures or ordinary protocol frames. + */ +export function handleRelayNoticeFrame( + frame: unknown, + emit: RelayNoticeListener = emitRelayNotice, +): boolean { + if (!Array.isArray(frame) || frame[0] !== "NOTICE") { + return false; + } + + const message = relayNoticeMessageFromFrame(frame); + if (message) { + emit(message); + } + + return true; +} + +export function clearRelayNoticeListenersForTests(): void { + relayNoticeListeners.clear(); +} diff --git a/desktop/src/shared/api/useRelayNoticeToasts.ts b/desktop/src/shared/api/useRelayNoticeToasts.ts new file mode 100644 index 0000000000..c083495d2e --- /dev/null +++ b/desktop/src/shared/api/useRelayNoticeToasts.ts @@ -0,0 +1,12 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { subscribeToRelayNotices } from "@/shared/api/relayNotices"; + +export function useRelayNoticeToasts(): void { + React.useEffect(() => { + return subscribeToRelayNotices((message) => { + toast.warning(message); + }); + }, []); +} diff --git a/desktop/src/shared/lib/mentionReferenceTags.test.mjs b/desktop/src/shared/lib/mentionReferenceTags.test.mjs new file mode 100644 index 0000000000..f9b32da29a --- /dev/null +++ b/desktop/src/shared/lib/mentionReferenceTags.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + appendMentionReferenceTags, + withMentionReferenceTags, +} from "./mentionReferenceTags.ts"; + +const PUBKEY_A = "A".repeat(64); +const PUBKEY_B = "b".repeat(64); + +test("appendMentionReferenceTags appends normalized explicit mention tags", () => { + const tags = [["h", "channel-1"]]; + + appendMentionReferenceTags(tags, [PUBKEY_A, PUBKEY_B]); + + assert.deepEqual(tags, [ + ["h", "channel-1"], + ["mention", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_B], + ]); +}); + +test("appendMentionReferenceTags dedupes existing mention reference tags", () => { + const tags = [["mention", PUBKEY_A.toLowerCase()]]; + + appendMentionReferenceTags(tags, [ + PUBKEY_A, + PUBKEY_B, + PUBKEY_B.toUpperCase(), + ]); + + assert.deepEqual(tags, [ + ["mention", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_B], + ]); +}); + +test("withMentionReferenceTags does not mutate the input tag array", () => { + const tags = [["p", PUBKEY_A.toLowerCase()]]; + + const next = withMentionReferenceTags(tags, [PUBKEY_A]); + + assert.deepEqual(tags, [["p", PUBKEY_A.toLowerCase()]]); + assert.deepEqual(next, [ + ["p", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_A.toLowerCase()], + ]); +}); diff --git a/desktop/src/shared/lib/mentionReferenceTags.ts b/desktop/src/shared/lib/mentionReferenceTags.ts new file mode 100644 index 0000000000..3535d2b52f --- /dev/null +++ b/desktop/src/shared/lib/mentionReferenceTags.ts @@ -0,0 +1,49 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; + +function mentionReferenceKey(tag: string[]): string | null { + if (tag[0] !== MENTION_REFERENCE_TAG || !tag[1]) { + return null; + } + + return normalizePubkey(tag[1]); +} + +/** + * Append explicit-mention reference tags for pubkeys that are already being + * sent as mention targets. Raw `p` tags also carry reply/subscription metadata, + * so the relay uses these tags to distinguish an intentional @mention from a + * structural reply-author `p` tag. + */ +export function appendMentionReferenceTags( + tags: string[][], + pubkeys: Iterable, +): void { + const seen = new Set(); + + for (const tag of tags) { + const pubkey = mentionReferenceKey(tag); + if (pubkey) { + seen.add(pubkey); + } + } + + for (const pubkey of pubkeys) { + const normalized = normalizePubkey(pubkey); + if (!normalized || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + tags.push([MENTION_REFERENCE_TAG, normalized]); + } +} + +export function withMentionReferenceTags( + tags: string[][], + pubkeys: Iterable, +): string[][] { + const next = tags.map((tag) => [...tag]); + appendMentionReferenceTags(next, pubkeys); + return next; +} From f7b13d5b0863a93af30cb0b1f1fbeb32c1e7b155 Mon Sep 17 00:00:00 2001 From: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 10:51:41 -0700 Subject: [PATCH 2/2] fix(desktop): preserve mention WebSocket flow Keep explicit mention-only sends on the normal WebSocket path so DM expansion and relay NOTICE handling remain observable. Keep the relay client within the file-size ratchet. Co-authored-by: Cameron Hotchkies Signed-off-by: Cameron Hotchkies Ai-assisted: true --- desktop/src-tauri/src/events_tests.rs | 22 +++++++++++++++++-- desktop/src/features/messages/hooks.ts | 8 +++---- .../messages/ui/useMentionSendFlow.ts | 8 ++----- desktop/src/shared/api/relayClientSession.ts | 6 ++--- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/events_tests.rs b/desktop/src-tauri/src/events_tests.rs index e7a017ca3d..e972d76985 100644 --- a/desktop/src-tauri/src/events_tests.rs +++ b/desktop/src-tauri/src/events_tests.rs @@ -74,6 +74,8 @@ fn build_message_accepts_deduped_mention_reference_tags() { vec!["mention".to_string(), PUBKEY_A.to_string()], vec!["mention".to_string(), PUBKEY_B.to_string()], ], + &[], + "https://relay.example", ) .expect("build message"); @@ -105,6 +107,8 @@ fn build_message_rejects_malformed_mention_reference_tags() { &[], &[], &[vec!["p".into(), PUBKEY.into()]], + &[], + "https://relay.example", ) .unwrap_err(); assert!(wrong_prefix.contains("mention reference tags must use 'mention' prefix")); @@ -117,12 +121,25 @@ fn build_message_rejects_malformed_mention_reference_tags() { &[], &[], &[vec!["mention".into(), PUBKEY.into(), "extra".into()]], + &[], + "https://relay.example", ) .unwrap_err(); assert!(extra_field.contains("exactly prefix and pubkey")); let too_many = vec![vec!["mention".into(), PUBKEY.into()]; MAX_MENTIONS + 1]; - let too_many_err = build_message(channel_id, "hi", None, &[], &[], &[], &too_many).unwrap_err(); + let too_many_err = build_message( + channel_id, + "hi", + None, + &[], + &[], + &[], + &too_many, + &[], + "https://relay.example", + ) + .unwrap_err(); assert!(too_many_err.contains("too many mention reference tags")); } @@ -165,7 +182,8 @@ fn edit_tags(mentions: &[&str]) -> Vec> { let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let builder = + build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 050288df15..e03e9c3133 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -466,14 +466,14 @@ export function useSendMessageMutation( mentionPubkeys, ); -// Messages carrying media, custom-emoji, explicit mention reference, or - // link-preview tags MUST go through REST so the Rust tag guards validate - // each channel. The WebSocket path emits only structural send tags. + // Messages carrying media, custom-emoji, or link-preview tags MUST go + // through REST so the relay's tag validation runs. Explicit mention + // reference tags are supported by the WebSocket path and should stay + // there so DM expansion and relay NOTICE handling observe normal sends. if ( parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || - mentionTags.length > 0 || linkPreviewTags.length > 0 ) { const cachedMessages = diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 4cca25bf27..73e4581a77 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -37,7 +37,6 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; - import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { getErrorMessage, @@ -95,7 +94,6 @@ type UseMentionSendFlowOptions = { resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; - export function useMentionSendFlow({ channelId, channelLinks, @@ -515,7 +513,6 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, ); - }; const restoreComposerAfterFailure = () => { persistCanceledDraft(); @@ -567,7 +564,6 @@ export function useMentionSendFlow({ if (signal?.aborted) return; await send( finalContent, - mentionPubkeys, finalOutgoingTags, sendChannelId, @@ -873,8 +869,8 @@ export function useMentionSendFlow({ const mentionPubkeys = pendingNonMemberSend.mentionPubkeys.filter( (pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)), ); - const outgoingTags = withMentionReferenceTags( - pendingNonMemberSend.outgoingTags ?? [], + const outgoingTags = mergeOutgoingTagsWithReferenceMentions( + pendingNonMemberSend.outgoingTags, nonMemberPubkeys, ); void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 7e747c2f46..f4c117e8ed 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -264,7 +264,6 @@ export class RelayClient { tags.push(["p", pubkey]); } tags.push(...withMentionReferenceTags(extraTags, mentionPubkeys)); - const event = await signRelayEvent({ kind: KIND_STREAM_MESSAGE, content: content.trim(), @@ -784,9 +783,8 @@ export class RelayClient { return; } - if (handleRelayNoticeFrame(data)) return; - const [type, ...rest] = data; + if (type === "NOTICE" && handleRelayNoticeFrame(data)) return; if (type === "AUTH" && typeof rest[0] === "string") { await this.handleAuthChallenge(rest[0], generation); return; @@ -829,7 +827,7 @@ export class RelayClient { } if (type === "NOTICE" && typeof rest[0] === "string") { - const notice: string = rest[0]; + const notice = rest[0]; // Relay back-pressure — arm the gate until the window expires. if (notice.startsWith("rate-limited:")) { activateRateLimit(parseRateLimitHint(notice));