From 71b20b549e3a88c8356e6f34259aceca1b17a3ed Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Tue, 14 Jul 2026 11:12:25 -0700 Subject: [PATCH] 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 | 167 ++------------ desktop/src-tauri/src/events_tests.rs | 215 ++++++++++++++++++ desktop/src/app/App.tsx | 2 + desktop/src/features/messages/hooks.ts | 13 +- .../messages/ui/useMentionSendFlow.ts | 27 +-- 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, 684 insertions(+), 182 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 ecb3e41fcc..dc532d96af 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, @@ -702,6 +804,11 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { if result.accepted { @@ -721,6 +828,13 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { // Sanitize internal errors — don't leak DB/system details over WS. @@ -1186,6 +1300,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 f9e54de9c5..8bb6c14ecd 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -184,16 +184,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(()) @@ -213,7 +216,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( @@ -1977,12 +1980,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 777d56d02e..4bacabf5db 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -77,6 +77,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!( @@ -84,11 +91,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(()) } @@ -847,153 +860,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).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 9561b2138c..b50500edbc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -55,6 +55,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +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"; @@ -312,6 +313,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 062b0ee40b..62f67b495b 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -465,10 +465,15 @@ 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. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + // Messages carrying media, custom-emoji, or explicit mention reference + // 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 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..2d14041d6f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -27,6 +27,7 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; +import { withMentionReferenceTags } from "@/shared/lib/mentionReferenceTags"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; type PendingNonMemberMentionSend = { @@ -110,21 +111,6 @@ type UseMentionSendFlowOptions = { resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; -function mergeOutgoingTagsWithReferenceMentions( - outgoingTags: string[][] | undefined, - pubkeys: Iterable, -) { - const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); - if (normalizedPubkeys.length === 0) { - return outgoingTags; - } - - return [ - ...(outgoingTags ?? []), - ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), - ]; -} - function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -514,11 +500,16 @@ export function useMentionSendFlow({ ); } + const finalOutgoingTags = withMentionReferenceTags( + outgoingTags ?? [], + mentionPubkeys, + ); + try { await onSendRef.current( draft.finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); @@ -799,8 +790,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 b8c861af96..fff7b6da1e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -42,6 +42,8 @@ import { } from "@/shared/api/relayRateLimitGate"; 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, @@ -303,9 +305,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, @@ -801,6 +801,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; +}