diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d47..b61503ce8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -503,6 +503,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetSelfPresenceStatus()` — self presence signal read by the `@here` check **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..11c5cc7194 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3569,14 +3569,21 @@ pub(crate) async fn post_failure_notice( parent_event_id: parent_id, }) }); - let builder = - match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { - Ok(b) => b, - Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); - return; - } - }; + let builder = match buzz_sdk::build_message( + channel_id, + content, + thread_ref.as_ref(), + &[], + false, + None, + &[], + ) { + Ok(b) => b, + Err(e) => { + tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + return; + } + }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..c744877c92 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -629,6 +629,7 @@ async fn publish_setup_nudge( thread_ref.as_ref(), &[&author_hex], // p-tag the asker false, + None, &[], ) .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 40699459fc..05b74d214b 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -32,6 +32,8 @@ export BUZZ_RELAY_URL="https://relay.example.com" buzz messages send --channel --content "Hello" buzz messages send --channel --content "Reply" --reply-to --broadcast buzz messages send --channel --content - < message.md # read body from stdin +buzz messages send --channel --content "deploy is done" --notify channel # @channel +buzz messages send --channel --content "standup now" --notify here # @here buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages search --query "architecture" diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d3d5c7f81a..cf916b354d 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -5,17 +5,31 @@ use crate::error::CliError; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; -/// Get activity feed — query events mentioning our pubkey (via p-tag). -pub async fn cmd_get_feed( - client: &BuzzClient, +/// Feed types requested when `--types` is omitted. +/// +/// Only the addressed-to-me feeds are on by default. `activity` (and its +/// `agent_activity` alias, which the relay canonicalizes to `activity`) is an +/// unbounded community firehose that would drown the mention and needs-action +/// rows an agent actually has to act on — request it explicitly with +/// `--types activity`. +const DEFAULT_FEED_TYPES: &[&str] = &["mentions", "needs_action"]; + +/// Build the `POST /query` filter for the activity feed. +/// +/// `feed_types` routes the query to the relay's bounded feed queries — direct +/// `p`-tag mentions UNION NIP-CM `@channel` notifications, membership- and +/// visibility-scoped server-side. Without it the bridge treats this as a raw +/// `#p` filter, and marker-only `@channel` events (which carry no `p` tag) +/// never produce a feed row. The raw `#p`/`limit` fields stay as a graceful +/// fallback: the bridge ignores them when `feed_types` is present, while a +/// relay predating the extension drops the unknown field and still serves +/// direct mentions. +fn build_feed_filter( + my_pk: &str, since: Option, - limit: Option, + limit: u32, types: Option<&str>, - format: &crate::OutputFormat, -) -> Result<(), CliError> { - let my_pk = client.keys().public_key().to_hex(); - let limit = limit.unwrap_or(20).min(50); - +) -> Result { let mut filter = serde_json::json!({ "#p": [my_pk], "limit": limit @@ -25,18 +39,37 @@ pub async fn cmd_get_feed( filter["since"] = serde_json::json!(s); } - if let Some(types_str) = types { - let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect(); - for t in &type_list { - if !VALID_FEED_TYPES.contains(t) { - return Err(crate::error::CliError::Usage(format!( - "invalid feed type {t:?} — must be one of: {}", - VALID_FEED_TYPES.join(", ") - ))); + filter["feed_types"] = match types { + Some(types_str) => { + let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect(); + for t in &type_list { + if !VALID_FEED_TYPES.contains(t) { + return Err(CliError::Usage(format!( + "invalid feed type {t:?} — must be one of: {}", + VALID_FEED_TYPES.join(", ") + ))); + } } + serde_json::json!(type_list) } - filter["feed_types"] = serde_json::json!(type_list); - } + None => serde_json::json!(DEFAULT_FEED_TYPES), + }; + + Ok(filter) +} + +/// Get activity feed — mentions, needs-action, and activity rows addressed to us. +pub async fn cmd_get_feed( + client: &BuzzClient, + since: Option, + limit: Option, + types: Option<&str>, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + let my_pk = client.keys().public_key().to_hex(); + let limit = limit.unwrap_or(20).min(50); + + let filter = build_feed_filter(&my_pk, since, limit, types)?; let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); @@ -78,3 +111,57 @@ pub async fn dispatch( } => cmd_get_feed(client, since, limit, types.as_deref(), format).await, } } + +#[cfg(test)] +mod tests { + use super::*; + + const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn default_requests_the_addressed_to_me_feeds() { + let filter = build_feed_filter(PK, None, 20, None).expect("default filter builds"); + assert_eq!( + filter["feed_types"], + serde_json::json!(["mentions", "needs_action"]), + "the default routes to the bounded addressed-to-me feeds (so marker-only @channel rows appear) and leaves the activity firehose to an explicit --types" + ); + assert_eq!(filter["#p"], serde_json::json!([PK])); + assert_eq!(filter["limit"], serde_json::json!(20)); + assert!(filter.get("since").is_none()); + } + + #[test] + fn explicit_types_are_passed_through() { + let filter = build_feed_filter(PK, Some(1_700_000_000), 5, Some("mentions,activity")) + .expect("explicit filter builds"); + assert_eq!( + filter["feed_types"], + serde_json::json!(["mentions", "activity"]) + ); + assert_eq!(filter["#p"], serde_json::json!([PK])); + assert_eq!(filter["since"], serde_json::json!(1_700_000_000)); + } + + #[test] + fn invalid_type_is_a_usage_error() { + let err = build_feed_filter(PK, None, 20, Some("mentions,bogus")) + .expect_err("invalid type must be rejected"); + match err { + CliError::Usage(msg) => assert!(msg.contains("bogus"), "message names the bad type"), + other => panic!("expected Usage error, got {other:?}"), + } + } + + #[test] + fn p_tag_retained_for_every_type_selection() { + for types in [None, Some("mentions"), Some("needs_action,agent_activity")] { + let filter = build_feed_filter(PK, None, 20, types).expect("filter builds"); + assert_eq!( + filter["#p"], + serde_json::json!([PK]), + "#p is the fallback for relays predating feed_types ({types:?})" + ); + } + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..8e0a2d403e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,4 +1,4 @@ -use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; +use buzz_sdk::{DeleteMessageOptions, DiffMeta, NotifyMode, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; @@ -9,8 +9,8 @@ use crate::validate::{ validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ - extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions, - MENTION_CAP, + extract_at_mentions_with_known, extract_nostr_uris, extract_reserved_mention_tokens, + merge_mentions, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -477,19 +477,58 @@ pub struct SendMessageParams { pub kind: Option, pub reply_to: Option, pub broadcast: bool, + /// Raw `--notify` value, parsed by [`parse_notify_mode`]. + pub notify: Option, pub files: Vec, } +/// Parse the `--notify` flag value into a [`NotifyMode`]. +/// +/// Unknown values are a usage error (exit code 1) rather than a silent +/// downgrade — sending a message that quietly failed to notify anyone is the +/// worse outcome. +fn parse_notify_mode(value: Option<&str>) -> Result, CliError> { + match value { + None => Ok(None), + Some(raw) => raw.parse::().map(Some).map_err(|_| { + CliError::Usage(format!( + "invalid --notify value '{raw}' (expected 'channel' or 'here')" + )) + }), + } +} + +/// Warning shown when content mentions `@channel`/`@here` without the flag. +/// +/// Returns `None` when no reserved token is present outside code regions, so +/// an `@here` inside a code fence stays quiet. +fn unflagged_notify_warning(content: &str, notify: Option) -> Option { + if notify.is_some() { + return None; + } + let tokens = extract_reserved_mention_tokens(&strip_code_regions(content)); + let first = tokens.first()?; + Some(format!( + "warning: @{first} does not notify anyone unless --notify {first} is passed; sending without it" + )) +} + pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, ) -> Result<(), CliError> { + // Reject a bad --notify value before consuming stdin, so a typo does not + // eat piped content the caller cannot replay. + let notify = parse_notify_mode(p.notify.as_deref())?; // Allow '-' to read content from stdin. This keeps callers from having to // jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv // quoting — the source of countless self-inflicted command-substitution // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; + if let Some(warning) = unflagged_notify_warning(&p.content, notify) { + eprintln!("{warning}"); + } if let Some(ref r) = p.reply_to { validate_hex64(r)?; } @@ -538,10 +577,14 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); let builder = match p.kind { - Some(45001) => { - buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) - .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))? - } + Some(45001) => buzz_sdk::build_forum_post( + channel_uuid, + &final_content, + &mention_refs, + notify, + &media_tags, + ) + .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?, Some(45003) => { let tr = thread_ref.as_ref().ok_or_else(|| { CliError::Usage("--reply-to is required for forum comments (kind 45003)".into()) @@ -551,6 +594,7 @@ pub async fn cmd_send_message( &final_content, tr, &mention_refs, + notify, &media_tags, ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? @@ -561,6 +605,7 @@ pub async fn cmd_send_message( thread_ref.as_ref(), &mention_refs, p.broadcast, + notify, &media_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, @@ -764,6 +809,7 @@ pub async fn dispatch( kind, reply_to, broadcast, + notify, files, } => { cmd_send_message( @@ -774,6 +820,7 @@ pub async fn dispatch( kind, reply_to, broadcast, + notify, files, }, ) @@ -876,7 +923,10 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + find_root_from_tags, match_profiles_by_name, parse_member_pubkeys, parse_notify_mode, + unflagged_notify_warning, CliError, NotifyMode, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1164,4 +1214,49 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[test] + fn notify_flag_parses_both_modes() { + assert_eq!(parse_notify_mode(None).expect("no flag"), None); + assert_eq!( + parse_notify_mode(Some("channel")).expect("channel"), + Some(NotifyMode::Channel) + ); + assert_eq!( + parse_notify_mode(Some("here")).expect("here"), + Some(NotifyMode::Here) + ); + } + + #[test] + fn notify_flag_rejects_unknown_value_as_usage_error() { + // Usage errors exit 1; anything else would mask a typo as a send failure. + let err = parse_notify_mode(Some("everyone")).unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "got {err:?}"); + assert!(err.to_string().contains("everyone")); + // Wire format is lowercase-only. + assert!(parse_notify_mode(Some("Channel")).is_err()); + } + + #[test] + fn literal_reserved_token_without_flag_warns() { + let warning = unflagged_notify_warning("ship it @channel", None).expect("warning"); + assert!(warning.contains("@channel")); + assert!(warning.contains("--notify channel")); + assert!(unflagged_notify_warning("heads up @here", None) + .expect("warning") + .contains("--notify here")); + } + + #[test] + fn no_warning_when_flag_passed_or_token_absent() { + assert!(unflagged_notify_warning("ship it @channel", Some(NotifyMode::Channel)).is_none()); + assert!(unflagged_notify_warning("hello @alice", None).is_none()); + } + + #[test] + fn reserved_token_inside_code_region_does_not_warn() { + assert!(unflagged_notify_warning("run `git push @here`", None).is_none()); + assert!(unflagged_notify_warning("```\n@channel\n```", None).is_none()); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b46734584..d52e0ec138 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -348,7 +348,11 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --content \"deploy is done\" --notify channel\n echo \"hello from stdin\" | buzz messages send --channel --content -\n\n\ +Channel-wide mentions:\n \ +--notify channel notifies every member of the channel (muted members excluded)\n \ +--notify here notifies members who are online right now\n \ +Literal @channel/@here text in --content does NOT notify without the flag." )] Send { /// Channel UUID (from 'buzz channels list') @@ -366,6 +370,9 @@ pub enum MessagesCmd { /// Also publish to the Nostr network #[arg(long, default_value_t = false)] broadcast: bool, + /// Channel-wide mention: 'channel' (all members) or 'here' (online members) + #[arg(long, value_name = "MODE")] + notify: Option, /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, @@ -942,7 +949,8 @@ pub enum FeedCmd { /// Maximum number of results to return #[arg(long)] limit: Option, - /// Comma-separated feed types to include: mentions, needs_action, activity, agent_activity + /// Comma-separated feed types: mentions, needs_action, activity, + /// agent_activity [default: mentions,needs_action] #[arg(long)] types: Option, }, diff --git a/crates/buzz-core/src/channel_mentions.rs b/crates/buzz-core/src/channel_mentions.rs new file mode 100644 index 0000000000..3c0a3fe1a1 --- /dev/null +++ b/crates/buzz-core/src/channel_mentions.rs @@ -0,0 +1,354 @@ +//! NIP-CM channel-wide mentions — the `["notify", …]` marker tag. +//! +//! A channel-wide mention is carried by a single marker tag on the message +//! event itself; there is no per-member `p` tag expansion, so the roster is +//! never written into the event and agents are never woken by `@channel` or +//! `@here`. +//! +//! ```text +//! ["notify", "channel"] // every member of the channel +//! ["notify", "here"] // members who are online right now (live-only) +//! ``` +//! +//! Validation here is pure (no I/O): it covers tag shape, mode spelling, +//! at-most-one-tag, and the allowed kinds. The DM-channel rejection needs the +//! channel row and therefore lives at the relay ingest seam, which calls +//! [`validate_notify_tag`] first and then applies +//! [`NotifyTagError::DirectMessage`] itself. + +use std::fmt; +use std::str::FromStr; + +use crate::kind::{ + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT, +}; + +/// Tag name carrying a channel-wide mention. +pub const NOTIFY_TAG: &str = "notify"; + +/// Reserved mention tokens that must never resolve to a member identity. +/// +/// Parsers compare case-insensitively: a member whose display name is +/// literally `here` still loses to the reserved token. +pub const RESERVED_MENTION_TOKENS: [&str; 2] = ["channel", "here"]; + +/// Event kinds that may carry a [`NOTIFY_TAG`]. +/// +/// `40003` (message edit) is accepted for render continuity only — it never +/// escalates a notification and never persists a feed row (see +/// [`persists_channel_notification`]). +pub const NOTIFY_ALLOWED_KINDS: [u32; 4] = [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, +]; + +/// Returns whether `token` is a reserved channel-wide mention token. +/// +/// Comparison is ASCII case-insensitive and the token must be given without +/// its leading `@`. +pub fn is_reserved_mention_token(token: &str) -> bool { + RESERVED_MENTION_TOKENS + .iter() + .any(|reserved| token.eq_ignore_ascii_case(reserved)) +} + +/// Who a `["notify", …]` tag notifies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifyMode { + /// Every member of the channel; persistent (feed row, badge, offline catch-up). + Channel, + /// Members who are online at delivery time; live-only, never persisted. + Here, +} + +impl NotifyMode { + /// Canonical string representation (the tag's second element). + pub fn as_str(&self) -> &'static str { + match self { + Self::Channel => "channel", + Self::Here => "here", + } + } +} + +impl fmt::Display for NotifyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for NotifyMode { + type Err = NotifyTagError; + + fn from_str(s: &str) -> Result { + match s { + "channel" => Ok(Self::Channel), + "here" => Ok(Self::Here), + other => Err(NotifyTagError::InvalidMode(other.to_string())), + } + } +} + +/// Why a `["notify", …]` tag was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotifyTagError { + /// The tag has no mode element (`["notify"]`). + MissingMode, + /// The mode is not `channel` or `here`. Carries the offending value. + InvalidMode(String), + /// More than one notify tag on a single event. + Duplicate, + /// The event kind may not carry a notify tag. Carries the kind. + KindNotAllowed(u32), + /// Channel-wide mentions are meaningless in a DM channel. + DirectMessage, +} + +impl fmt::Display for NotifyTagError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMode => write!(f, "notify tag requires a mode value"), + Self::InvalidMode(value) => { + write!( + f, + "invalid notify mode {value:?} (expected channel or here)" + ) + } + Self::Duplicate => write!(f, "at most one notify tag is allowed per event"), + Self::KindNotAllowed(kind) => { + write!(f, "kind {kind} may not carry a notify tag") + } + Self::DirectMessage => { + write!(f, "channel-wide mentions are not allowed in DM channels") + } + } + } +} + +impl std::error::Error for NotifyTagError {} + +/// Validate the notify tag (if any) carried by an event's tags. +/// +/// Returns `Ok(None)` when the event carries no notify tag, `Ok(Some(mode))` +/// when it carries exactly one well-formed tag on an allowed kind, and an +/// error otherwise. Extra elements past the mode are ignored, matching Nostr's +/// forward-compatible tag convention. +/// +/// This function performs no I/O; the DM-channel rule is applied by the caller +/// that can see the channel row. +pub fn validate_notify_tag<'a, I, T>( + kind: u32, + tags: I, +) -> Result, NotifyTagError> +where + I: IntoIterator, + T: AsRef<[String]> + 'a, +{ + let mut found: Option = None; + for tag in tags { + let parts = tag.as_ref(); + let Some(name) = parts.first() else { + continue; + }; + if name != NOTIFY_TAG { + continue; + } + if found.is_some() { + return Err(NotifyTagError::Duplicate); + } + let value = parts.get(1).ok_or(NotifyTagError::MissingMode)?; + found = Some(value.parse::()?); + } + + if found.is_some() && !NOTIFY_ALLOWED_KINDS.contains(&kind) { + return Err(NotifyTagError::KindNotAllowed(kind)); + } + + Ok(found) +} + +/// Validate the notify tag carried by a signed Nostr event. +/// +/// Thin wrapper over [`validate_notify_tag`] for callers holding an event. +pub fn event_notify_mode(event: &nostr::Event) -> Result, NotifyTagError> { + let tags: Vec<&[String]> = event.tags.iter().map(|tag| tag.as_slice()).collect(); + validate_notify_tag(event.kind.as_u16() as u32, &tags) +} + +/// Whether an accepted notify tag persists a `channel_notifications` feed row. +/// +/// Only `mode = channel` persists, and only on the kinds that create new +/// content: edits (`40003`) re-carry the tag for rendering but must not +/// re-notify, and `here` is live-only by design. +pub fn persists_channel_notification(kind: u32, mode: NotifyMode) -> bool { + mode == NotifyMode::Channel + && matches!( + kind, + KIND_STREAM_MESSAGE | KIND_FORUM_POST | KIND_FORUM_COMMENT + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tags(raw: &[&[&str]]) -> Vec> { + raw.iter() + .map(|tag| tag.iter().map(|s| s.to_string()).collect()) + .collect() + } + + #[test] + fn no_notify_tag_is_ok() { + let t = tags(&[&["h", "abc"], &["p", "deadbeef"]]); + assert_eq!(validate_notify_tag(KIND_STREAM_MESSAGE, &t), Ok(None)); + } + + #[test] + fn parses_both_modes() { + for (value, expected) in [("channel", NotifyMode::Channel), ("here", NotifyMode::Here)] { + let t = tags(&[&["notify", value]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Ok(Some(expected)) + ); + } + } + + #[test] + fn rejects_unknown_mode() { + let t = tags(&[&["notify", "everyone"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::InvalidMode("everyone".into())) + ); + } + + #[test] + fn mode_is_case_sensitive() { + let t = tags(&[&["notify", "Channel"]]); + assert!(matches!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::InvalidMode(_)) + )); + } + + #[test] + fn rejects_missing_mode() { + let t = tags(&[&["notify"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::MissingMode) + ); + } + + #[test] + fn rejects_duplicate_tags() { + let t = tags(&[&["notify", "channel"], &["notify", "here"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::Duplicate) + ); + let same = tags(&[&["notify", "channel"], &["notify", "channel"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &same), + Err(NotifyTagError::Duplicate) + ); + } + + #[test] + fn duplicate_check_precedes_kind_check() { + let t = tags(&[&["notify", "channel"], &["notify", "channel"]]); + assert_eq!( + validate_notify_tag(1, &t), + Err(NotifyTagError::Duplicate), + "shape errors are reported before the kind gate" + ); + } + + #[test] + fn allows_only_the_four_kinds() { + let t = tags(&[&["notify", "channel"]]); + for kind in NOTIFY_ALLOWED_KINDS { + assert!(validate_notify_tag(kind, &t).is_ok(), "kind {kind}"); + } + for kind in [1u32, 7, 40002, 45002, 9735] { + assert_eq!( + validate_notify_tag(kind, &t), + Err(NotifyTagError::KindNotAllowed(kind)), + "kind {kind}" + ); + } + } + + #[test] + fn disallowed_kind_without_tag_is_fine() { + let t = tags(&[&["e", "abc"]]); + assert_eq!(validate_notify_tag(1, &t), Ok(None)); + } + + #[test] + fn extra_tag_elements_are_ignored() { + let t = tags(&[&["notify", "here", "future-field"]]); + assert_eq!( + validate_notify_tag(KIND_FORUM_POST, &t), + Ok(Some(NotifyMode::Here)) + ); + } + + #[test] + fn only_channel_mode_persists_and_never_on_edits() { + assert!(persists_channel_notification( + KIND_STREAM_MESSAGE, + NotifyMode::Channel + )); + assert!(persists_channel_notification( + KIND_FORUM_POST, + NotifyMode::Channel + )); + assert!(persists_channel_notification( + KIND_FORUM_COMMENT, + NotifyMode::Channel + )); + assert!(!persists_channel_notification( + KIND_STREAM_MESSAGE_EDIT, + NotifyMode::Channel + )); + for kind in NOTIFY_ALLOWED_KINDS { + assert!( + !persists_channel_notification(kind, NotifyMode::Here), + "here is live-only (kind {kind})" + ); + } + } + + #[test] + fn mode_round_trips_through_str() { + for mode in [NotifyMode::Channel, NotifyMode::Here] { + assert_eq!(mode.as_str().parse::(), Ok(mode)); + } + } + + #[test] + fn reserved_tokens_are_case_insensitive() { + for token in ["channel", "Channel", "HERE", "here"] { + assert!(is_reserved_mention_token(token), "{token}"); + } + for token in ["chan", "everyone", "here2", ""] { + assert!(!is_reserved_mention_token(token), "{token}"); + } + } + + #[test] + fn event_helper_reads_tags_from_signed_event() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags([Tag::parse(["notify", "channel"]).expect("tag")]) + .sign_with_keys(&keys) + .expect("sign"); + assert_eq!(event_notify_mode(&event), Ok(Some(NotifyMode::Channel))); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..5fbc2c636f 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// NIP-CM channel-wide mentions — the `["notify", …]` marker tag. +pub mod channel_mentions; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 511a2a6083..869d998054 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -1,7 +1,8 @@ //! Feed-specific DB queries for the Home Feed feature. //! //! Aggregates three categories of data: -//! - **Mentions**: Events where the user's pubkey appears in a `p` tag. +//! - **Mentions**: Events where the user's pubkey appears in a `p` tag, plus +//! NIP-CM `["notify", "channel"]` events in channels the user belongs to. //! - **Needs Action**: Approval requests (kind 46010) and reminders (kind 40007) tagged to the user. //! - **Activity**: Recent events from channels the user can access. //! @@ -14,6 +15,22 @@ //! full-table scan with an indexed lookup, keeping feed queries //! sub-millisecond at scale (>100k events). //! +//! The NIP-CM branch of `query_mentions` has a deliberately different shape. +//! `channel_notifications` holds one row per `@channel` *event* (never one per +//! member), so recipients are resolved at read time by joining the caller's +//! roster — which means `channel_id` is join-derived, not an equality literal, +//! and the `(community_id, channel_id, event_created_at DESC)` index cannot +//! yield community-wide `created_at` order. That branch is therefore a +//! roster-driven gather plus a bounded top-N sort, not the early-terminating +//! ordered scan branch 1 gets. Accepted on purpose: the table grows one row per +//! `@channel` message (`@here` and edits store nothing), so the candidate set +//! stays far smaller than branch 1's row-per-p-tag index. If `@channel` volume +//! ever grows large, the escape hatch is an additional +//! `(community_id, event_created_at DESC)` index, which lets the planner scan +//! in order and stop at `LIMIT`; it is not added now because it does not +//! dominate — for a caller in few channels of a busy community it scans past +//! many non-roster rows. +//! //! **Phase 2 implemented**: the `event_mentions` table is populated by //! [`crate::insert_mentions`] on every event insert. `query_mentions` and //! `query_needs_action` now use `INNER JOIN event_mentions` instead of @@ -92,8 +109,9 @@ fn build_mentions_query( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); + // Branch 1 — direct `p`-tag mentions. let mut qb: QueryBuilder = QueryBuilder::new(format!( - "SELECT {EVENT_COLS} FROM events e \ + "SELECT * FROM ((SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \ WHERE e.community_id = " )); @@ -112,16 +130,81 @@ fn build_mentions_query( if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") + // `e.id ASC` is a deterministic tie-break: equal-timestamp rows would + // otherwise order arbitrarily, making `LIMIT` pagination unstable. + qb.push(" ORDER BY m.event_created_at DESC, e.id ASC LIMIT ") + .push_bind(limit); + + // Branch 2 — NIP-CM `["notify", "channel"]` events in channels the caller + // is still a member of and had already joined when the relay admitted the + // event. `UNION` (not `UNION ALL`) collapses an event that + // both p-tags the caller and notifies the channel into one feed row: both + // branches project exactly `EVENT_COLS`, so identical event rows collapse. + // `@here` is never stored, so it can never surface here. + qb.push(format!( + ") UNION (SELECT {EVENT_COLS} FROM events e \ + INNER JOIN channel_notifications n ON e.community_id = n.community_id \ + AND e.id = n.event_id \ + INNER JOIN channel_members cm ON cm.community_id = n.community_id \ + AND cm.channel_id = n.channel_id \ + WHERE e.community_id = " + )); + qb.push_bind(*community.as_uuid()); + qb.push(" AND n.community_id = ") + .push_bind(*community.as_uuid()); + qb.push(" AND cm.pubkey = ") + .push_bind(pubkey_bytes.to_vec()); + qb.push(" AND cm.removed_at IS NULL"); + // `@channel` addresses the members present at post time: joining later + // grants access to the message, not a retroactive mention. The bound is + // the relay's admission time (`received_at`), not the client-authored + // `created_at`, which is second-truncated and skewable within the + // accepted window. The two sides live on different clocks (`joined_at` + // is a Postgres transaction timestamp, `received_at` the relay process + // clock), so a join landing within clock-skew of an admission can fall + // on either side — tolerable, because the bound exists to stop wholesale + // history backfill, not to adjudicate millisecond ties. A member who + // leaves and rejoins keeps the original `joined_at` (membership upserts + // preserve it for roster ordering), so rows from an absence window + // reappear on rejoin — accepted trade-off; exact absence accounting + // would need membership-interval history. + qb.push(" AND cm.joined_at <= e.received_at"); + qb.push(" AND e.deleted_at IS NULL"); + // The caller's own announcement is not a mention of the caller. + qb.push(" AND e.pubkey <> ") + .push_bind(pubkey_bytes.to_vec()); + qb.push(format!( + " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + )); + push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); + if let Some(s) = since { + qb.push(" AND n.event_created_at >= ").push_bind(s); + } + qb.push(" ORDER BY n.event_created_at DESC, e.id ASC LIMIT ") + .push_bind(limit); + + qb.push(")) u ORDER BY created_at DESC, id ASC LIMIT ") .push_bind(limit); qb } -/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). +/// Find events that mention the given pubkey. +/// +/// Two sources, unioned and deduplicated by event id: +/// - direct `["p", pubkey_hex]` mentions, via the `event_mentions` index; +/// - NIP-CM `["notify", "channel"]` events (`channel_notifications`) in +/// channels where the caller is a current member whose `joined_at` is no +/// later than the event's `received_at` — joining a channel later never +/// backfills older `@channel` rows into the feed. `["notify", "here"]` is +/// live-only and never persisted, so it never appears in this feed. /// -/// Joins against the `event_mentions` table -- Phase 2 implementation. -/// **Performance**: community-leading indexed lookup on -/// `(community_id, pubkey_hex, event_created_at DESC)`. +/// **Performance**: community-leading indexed lookups on +/// `(community_id, pubkey_hex, event_created_at DESC)` and +/// `(community_id, channel_id, event_created_at DESC)`. The join-time bound +/// is evaluated on the joined `events` row, so a reader who joined after most +/// of a channel's `@channel` history walks and rejects those candidates +/// before `LIMIT` is satisfied — bounded in practice by `@channel` volume, +/// which is one row per broadcast, never per member. /// /// Only returns community-global events and events from `accessible_channel_ids`. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. @@ -171,7 +254,8 @@ fn build_needs_action_query( if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") + // `e.id ASC` keeps equal-timestamp rows in a stable order across pages. + qb.push(" ORDER BY m.event_created_at DESC, e.id ASC LIMIT ") .push_bind(limit); qb } @@ -225,7 +309,9 @@ fn build_activity_query( if let Some(s) = since { qb.push(" AND created_at >= ").push_bind(s); } - qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); + // `id ASC` keeps equal-timestamp rows in a stable order across pages. + qb.push(" ORDER BY created_at DESC, id ASC LIMIT ") + .push_bind(limit); qb } @@ -318,6 +404,432 @@ mod tests { event } + /// Store an event authored by `keys` and run both denormalized indexes + /// (`event_mentions`, `channel_notifications`) exactly as the relay does. + async fn store_feed_event_as( + pool: &PgPool, + community: CommunityId, + keys: &Keys, + kind: u32, + content: &str, + channel_id: Option, + tags: Vec, + ) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(kind as u16), content) + .tags(tags) + .sign_with_keys(keys) + .expect("sign event"); + crate::event::insert_event(pool, community, &event, channel_id) + .await + .expect("insert feed event"); + crate::insert_mentions(pool, community, &event, channel_id) + .await + .expect("insert mentions"); + crate::insert_channel_notification(pool, community, &event, channel_id) + .await + .expect("insert channel notification"); + event + } + + async fn add_channel_member( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) { + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .execute(pool) + .await + .expect("insert channel member"); + } + + /// Re-anchor a stored event's `received_at` one hour into the *database* + /// clock's past. The branch-2 join bound compares `cm.joined_at` (a DB + /// transaction timestamp) with `e.received_at` (the relay process clock), + /// so tests asserting on the bound must pin both sides to the DB clock — + /// otherwise app-vs-DB clock skew larger than the test's runtime flips + /// the assertions. + async fn rewind_received_at_one_hour( + pool: &PgPool, + community: CommunityId, + event_id: &nostr::EventId, + ) { + sqlx::query( + "UPDATE events SET received_at = NOW() - INTERVAL '1 hour' \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(event_id.as_bytes().as_slice()) + .execute(pool) + .await + .expect("rewind event received_at"); + } + + /// Pin a member's `joined_at` relative to a stored event's `received_at` + /// (negative = joined before the relay admitted the event). Same clock + /// domain as [`rewind_received_at_one_hour`]. + async fn pin_joined_at_relative_to_event( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + event_id: &nostr::EventId, + offset_hours: i32, + ) { + sqlx::query( + "UPDATE channel_members cm \ + SET joined_at = e.received_at + ($5 * INTERVAL '1 hour') \ + FROM events e \ + WHERE e.community_id = $1 AND e.id = $4 \ + AND cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .bind(event_id.as_bytes().as_slice()) + .bind(offset_hours) + .execute(pool) + .await + .expect("pin member joined_at"); + } + + fn notify_tag(mode: &str) -> Tag { + Tag::parse(["notify", mode]).expect("notify tag") + } + + // -- NIP-CM channel-wide mentions ----------------------------------------- + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_reaches_members_and_only_members() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + let outsider_bytes = outsider.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "ship it @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + rewind_received_at_one_hour(&pool, community, &event.id).await; + pin_joined_at_relative_to_event(&pool, community, channel, &member_bytes, &event.id, -1) + .await; + + let member_feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert!( + member_feed.iter().any(|row| row.event.id == event.id), + "channel members must see the @channel event in their mentions feed" + ); + + let outsider_feed = query_mentions(&pool, community, &outsider_bytes, &[channel], None, 10) + .await + .expect("outsider mentions feed"); + assert!( + outsider_feed.iter().all(|row| row.event.id != event.id), + "non-members must not see the @channel event" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_is_not_backfilled_to_later_joiners() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let early_member = Keys::generate(); + let late_joiner = Keys::generate(); + let early_bytes = early_member.public_key().to_bytes().to_vec(); + let late_bytes = late_joiner.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &early_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "all hands @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + rewind_received_at_one_hour(&pool, community, &event.id).await; + pin_joined_at_relative_to_event(&pool, community, channel, &early_bytes, &event.id, -1) + .await; + + // Joins after the relay admitted the event: the fresh row's DB-clock + // `joined_at` (NOW()) postdates the rewound `received_at` + // (NOW() - 1 hour) in the same clock domain. + add_channel_member(&pool, community, channel, &late_bytes).await; + + let early_feed = query_mentions(&pool, community, &early_bytes, &[channel], None, 10) + .await + .expect("early member mentions feed"); + assert!( + early_feed.iter().any(|row| row.event.id == event.id), + "a member who had joined before the event must see the @channel row" + ); + + let late_feed = query_mentions(&pool, community, &late_bytes, &[channel], None, 10) + .await + .expect("late joiner mentions feed"); + assert!( + late_feed.iter().all(|row| row.event.id != event.id), + "joining a channel must not backfill older @channel rows into the feed" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_from_absence_window_reappears_after_rejoin() { + // Locks the documented trade-off: membership upserts preserve the + // original `joined_at` (it orders rosters), so a member who leaves + // and rejoins sees @channel rows posted during the absence. Exact + // absence accounting would need membership-interval history. The + // rejoin goes through the production `add_member` upsert: if that + // ever starts refreshing `joined_at`, the rejoined row lands at DB + // NOW(), after the rewound `received_at` (NOW() - 1 hour on the same + // clock), and this test fails — a deliberate semantics change that + // must update NIP-CM.md alongside it. + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + sqlx::query( + "UPDATE channel_members SET removed_at = NOW() \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(&member_bytes) + .execute(&pool) + .await + .expect("remove member"); + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "posted while away @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + rewind_received_at_one_hour(&pool, community, &event.id).await; + pin_joined_at_relative_to_event(&pool, community, channel, &member_bytes, &event.id, -1) + .await; + + // Rejoin through the production path: removed_at clears, joined_at + // keeps its original (pre-event) value. + crate::channel::add_member( + &pool, + community, + channel, + &member_bytes, + crate::channel::MemberRole::Member, + None, + ) + .await + .expect("rejoin member through production add_member"); + + let feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("rejoined member mentions feed"); + assert!( + feed.iter().any(|row| row.event.id == event.id), + "rejoin keeps the original joined_at, so absence-window @channel rows reappear (documented trade-off)" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn here_and_edits_never_persist_a_channel_notification() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let here = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "standup now @here", + Some(channel), + vec![notify_tag("here")], + ) + .await; + let edit = store_feed_event_as( + &pool, + community, + &author, + buzz_core::kind::KIND_STREAM_MESSAGE_EDIT, + "edited @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + + let feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert!( + feed.iter().all(|row| row.event.id != here.id), + "@here is live-only and must never reach the feed" + ); + assert!( + feed.iter().all(|row| row.event.id != edit.id), + "edits carry the tag for rendering only and must not re-notify" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_is_deduped_with_a_direct_mention_and_excludes_the_author() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let author_bytes = author.public_key().to_bytes().to_vec(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + add_channel_member(&pool, community, channel, &author_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "heads up @channel", + Some(channel), + vec![ + notify_tag("channel"), + Tag::parse(["p", &member.public_key().to_hex()]).expect("p tag"), + ], + ) + .await; + + let member_feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert_eq!( + member_feed + .iter() + .filter(|row| row.event.id == event.id) + .count(), + 1, + "an event that is both p-tagged and @channel must appear once" + ); + + let author_feed = query_mentions(&pool, community, &author_bytes, &[channel], None, 10) + .await + .expect("author mentions feed"); + assert!( + author_feed.iter().all(|row| row.event.id != event.id), + "the author's own @channel event is not a mention of the author" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_respects_visible_channel_scoping() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_FORUM_POST, + "forum @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + + let scoped_out = query_mentions(&pool, community, &member_bytes, &[], None, 10) + .await + .expect("mentions feed with no accessible channels"); + assert!( + scoped_out.iter().all(|row| row.event.id != event.id), + "an empty accessible-channel list means global-only, never all channels" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_is_scoped_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let channel_a = insert_test_channel(&pool, community_a).await; + let channel_b = insert_test_channel(&pool, community_b).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community_a, channel_a, &member_bytes).await; + add_channel_member(&pool, community_b, channel_b, &member_bytes).await; + + let event_b = store_feed_event_as( + &pool, + community_b, + &author, + KIND_STREAM_MESSAGE, + "community-b @channel", + Some(channel_b), + vec![notify_tag("channel")], + ) + .await; + + let feed_a = query_mentions( + &pool, + community_a, + &member_bytes, + &[channel_a, channel_b], + None, + 10, + ) + .await + .expect("community A mentions feed"); + assert!( + feed_a.iter().all(|row| row.event.id != event_b.id), + "community B channel mention must not appear in community A feed" + ); + } + // -- Postgres tenant-scope regressions ------------------------------------ #[tokio::test] @@ -788,6 +1300,86 @@ mod tests { ); } + #[test] + fn mentions_query_unions_channel_notifications_for_member_channels() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("INNER JOIN channel_notifications n ON e.community_id = n.community_id"), + "mentions must union NIP-CM channel notifications on the composite tenant/event key: {sql}" + ); + assert!( + sql.contains("INNER JOIN channel_members cm ON cm.community_id = n.community_id"), + "channel notifications must resolve recipients through channel_members: {sql}" + ); + assert!( + sql.contains("AND cm.removed_at IS NULL"), + "removed members must not receive channel mentions: {sql}" + ); + assert!( + sql.contains("AND cm.joined_at <= e.received_at"), + "@channel rows must be bounded to members who joined before the relay admitted the event: {sql}" + ); + assert!( + sql.contains(") UNION (") + && sql.contains(")) u ORDER BY created_at DESC, id ASC LIMIT "), + "both branches must be deduplicated and ordered together: {sql}" + ); + assert!( + !sql.contains(" AS feed_created_at"), + "both branches must project exactly EVENT_COLS or UNION cannot dedupe: {sql}" + ); + assert!( + !sql.contains("UNION ALL"), + "UNION (not UNION ALL) is what dedupes an event that is both p-tagged and @channel: {sql}" + ); + assert_eq!( + sql.matches("LIMIT ").count(), + 3, + "each branch and the outer query must carry the feed limit: {sql}" + ); + } + + #[test] + fn feed_queries_order_by_created_at_with_an_id_tie_break() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + + let mut mentions = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let mentions_query = mentions.build(); + let mentions_sql = sqlx::Execute::sql(mentions_query).as_str().to_string(); + assert!( + mentions_sql.contains("ORDER BY m.event_created_at DESC, e.id ASC") + && mentions_sql.contains("ORDER BY n.event_created_at DESC, e.id ASC") + && mentions_sql.contains("ORDER BY created_at DESC, id ASC"), + "every mentions ordering needs an id tie-break for stable pagination: {mentions_sql}" + ); + + let mut needs_action = + build_needs_action_query(community, &pubkey, &[channel_id], None, 10); + let needs_action_query = needs_action.build(); + let needs_action_sql = sqlx::Execute::sql(needs_action_query).as_str().to_string(); + assert!( + needs_action_sql.contains("ORDER BY m.event_created_at DESC, e.id ASC"), + "needs_action ordering needs an id tie-break: {needs_action_sql}" + ); + + let mut activity = build_activity_query(community, &[channel_id], None, 10); + let activity_query = activity.build(); + let activity_sql = sqlx::Execute::sql(activity_query).as_str().to_string(); + assert!( + activity_sql.contains("ORDER BY created_at DESC, id ASC"), + "activity ordering needs an id tie-break: {activity_sql}" + ); + } + #[test] fn needs_action_query_is_tenant_scoped_and_joins_mentions_by_composite_key() { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2a3ba9a63e..fd166f2684 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -168,6 +168,53 @@ pub async fn insert_mentions( Ok(()) } +/// Record a NIP-CM `["notify", "channel"]` event in `channel_notifications`. +/// +/// One row per event — the member roster is resolved at read time by the +/// mentions feed, never denormalized here. No-ops unless the event carries a +/// valid notify tag whose mode persists (see +/// [`buzz_core::channel_mentions::persists_channel_notification`]) and the +/// event is channel-scoped. Like [`insert_mentions`], this is a denormalized +/// index: callers log failures rather than failing the event insert. +pub async fn insert_channel_notification( + pool: &PgPool, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + use buzz_core::channel_mentions::{event_notify_mode, persists_channel_notification}; + + let Some(channel_id) = channel_id else { + return Ok(()); + }; + let kind = event.kind.as_u16() as u32; + let Ok(Some(mode)) = event_notify_mode(event) else { + return Ok(()); + }; + if !persists_channel_notification(kind, mode) { + return Ok(()); + } + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; + + sqlx::query( + "INSERT INTO channel_notifications \ + (community_id, channel_id, event_id, mode, event_created_at) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(event.id.as_bytes().as_slice()) + .bind(mode.as_str()) + .bind(created_at) + .execute(pool) + .await?; + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -1089,6 +1136,11 @@ impl Db { if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } + if let Err(e) = + insert_channel_notification(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert channel notification: {e}"); + } } Ok(result) } @@ -1397,6 +1449,11 @@ impl Db { if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } + if let Err(e) = + insert_channel_notification(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert channel notification: {e}"); + } } Ok(result) } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d1b7e05d4..e388ff9d2b 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,8 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + // Local-dev-only credentials from .env.example, not a secret. + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -560,7 +561,20 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 25); + // Contiguity instead of a hardcoded count: every new migration used to + // require bumping an exact `len()` here, and two branches racing for + // the same next number surfaced only as a checksum error at relay + // startup. This loop needs no edit when a migration is added and names + // the actual failure when two files claim the same version. + for (index, migration) in migrations.iter().enumerate() { + assert_eq!( + migration.version, + index as i64 + 1, + "migration versions must be contiguous from 1; a duplicate or \ + skipped number usually means two branches raced for the same \ + slot — rename the newer file to the next free number", + ); + } assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,7 +893,6 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); - // Use-limited invite links: durable relay_invites table stores only // the SHA-256 of an opaque v2 code, scoped by community_id. Never // listed in _operator_global_tables — it is community-scoped. @@ -904,6 +917,19 @@ mod tests { desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", ); + + // NIP-CM: @channel mentions persist one row per event (never one per + // member) and @here never persists at all. Looked up by content, not + // index, so renumbering the migration file never touches this test. + let channel_notifications = migrations + .iter() + .map(|migration| migration.sql.as_str()) + .find(|sql| sql.contains("CREATE TABLE channel_notifications")) + .expect("embedded migrator includes the channel_notifications migration"); + assert!(channel_notifications.contains("CREATE TABLE channel_notifications")); + assert!(channel_notifications.contains("PRIMARY KEY (community_id, event_id)")); + assert!(channel_notifications.contains("mode IN ('channel')")); + assert!(channel_notifications.contains("idx_channel_notifications_channel_created")); } #[test] @@ -1146,7 +1172,10 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); + // Tail version derived from the MIGRATOR, not hardcoded — a literal + // here has to be bumped by every new migration. + let expected_tail = MIGRATOR.iter().map(|migration| migration.version).max(); + assert_eq!(applied_versions(&pool).await.last().copied(), expected_tail); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 2e00d6bd2f..3bfc8dd2ac 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1064,9 +1064,15 @@ async fn query_events_authed( .since .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); + // Merge semantics: every requested feed type is queried with the FULL + // effective limit, then the results are merged — deduped by event id (an + // event can qualify for two categories), sorted newest-first with `id` + // ascending as a deterministic tie-break, and truncated to the limit. + // A single shared running budget would instead let whichever category + // ran first consume the whole limit and starve the rest. let mut seen_types = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new(); - let mut feed_count = 0i64; + let mut merged = Vec::new(); for feed_type in &feed_types { let canonical = if feed_type == "agent_activity" { "activity" @@ -1076,10 +1082,6 @@ async fn query_events_authed( if !seen_types.insert(canonical) { continue; } - if feed_count >= limit { - break; - } - let remaining = limit - feed_count; let type_events = match canonical { "mentions" => state .db @@ -1088,7 +1090,7 @@ async fn query_events_authed( &pubkey_bytes, &accessible_channels, since, - remaining, + limit, ) .await .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, @@ -1099,13 +1101,13 @@ async fn query_events_authed( &pubkey_bytes, &accessible_channels, since, - remaining, + limit, ) .await .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, "activity" => state .db - .query_feed_activity(tenant.community(), &accessible_channels, since, remaining) + .query_feed_activity(tenant.community(), &accessible_channels, since, limit) .await .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, _ => continue, @@ -1123,12 +1125,21 @@ async fn query_events_authed( if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { continue; } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); - feed_count += 1; - } + merged.push(se); } } + merged.sort_by(|a, b| { + b.event + .created_at + .cmp(&a.event.created_at) + .then_with(|| a.event.id.cmp(&b.event.id)) + }); + merged.truncate(limit as usize); + events.extend( + merged + .iter() + .filter_map(|se| serde_json::to_value(&se.event).ok()), + ); handled.insert(idx); } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..1b57288199 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -677,6 +677,23 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, +) -> Result<(), String> { + use buzz_core::channel_mentions::{event_notify_mode, NotifyTagError}; + + if event_notify_mode(event) + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(()); + } + if channel_row.is_some_and(|row| row.channel_type == buzz_db::channel::ChannelType::Dm.as_str()) + { + return Err(NotifyTagError::DirectMessage.to_string()); + } + Ok(()) +} + +/// NIP-CM: whether this event's notify tag requires the sender to be a member. +/// +/// True only for a well-formed notify tag on a kind that actually notifies. +/// Edits (`40003`) re-carry the tag for render continuity and never notify +/// (see `persists_channel_notification`), so gating them would only break +/// editing a message that was legitimately notified before the author left the +/// channel. A malformed tag returns `false` — [`validate_channel_mention`] +/// rejects those on shape grounds first. +fn notify_requires_membership(event: &Event) -> bool { + event_kind_u32(event) != KIND_STREAM_MESSAGE_EDIT + && buzz_core::channel_mentions::event_notify_mode(event) + .ok() + .flatten() + .is_some() +} + /// Validate kind:40008 diff event metadata tags. fn validate_diff_event(event: &Event) -> Result<(), String> { // Content max 60KB @@ -1568,6 +1608,18 @@ async fn ingest_event_inner( ))); } + // NIP-CM: the pure half of the notify-tag gate — shape, mode spelling, + // at-most-one, allowed kind — must run BEFORE every early-return handler + // dispatch below (commands, product feedback, reports, moderation + // commands). Those handlers answer `accepted: true` without ever reaching + // the channel-row gate further down, so without this the sender would be + // told the channel was notified while the tag was silently stored (product + // feedback and command kinds persist the event's tags verbatim). No kind + // handled by those branches is notify-allowed, so this pure check is their + // complete gate; the channel-row-dependent rules stay below. + buzz_core::channel_mentions::event_notify_mode(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { @@ -1781,6 +1833,35 @@ async fn ingest_event_inner( Some(ch_id) => state.db.get_channel(tenant.community(), ch_id).await.ok(), None => None, }; + // NIP-CM: gate the channel-wide mention tag before anything stores or + // fans out the event. The shape/kind half already ran above the + // early-return handler dispatch, so every kind is covered; this call adds + // the DM-channel rule, which needs the channel row. Re-running the pure + // check here is a tag scan, and keeps the seam readable in one place. + validate_channel_mention(&event, channel_row.as_ref()) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + + // NIP-CM: only current members may notify a whole channel. The + // open-visibility fallback in `check_channel_membership` lets a non-member + // post into an open channel; that fallback deliberately does NOT extend to + // a notify tag, which would otherwise let anyone blast a channel's roster + // without ever appearing on it (no join event, no roster row for + // moderators to act on). Edits are exempt — see + // `notify_requires_membership`. + if notify_requires_membership(&event) { + if let Some(ch_id) = channel_id { + let is_member = state + .is_member_cached(tenant.community(), ch_id, &pubkey_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; + if !is_member { + return Err(IngestError::Rejected( + "restricted: only channel members may use @channel or @here".into(), + )); + } + } + } + // E1 phase-2 (§4.8 phase-2 addendum): resolve the fan-out visibility once, // here, through the same `channel_visibility_cached` gate fan-out uses // (fence 2: cached `private` wins over the prefetched row; a `private` @@ -3096,6 +3177,148 @@ mod tests { ); } + fn make_channel_row(channel_type: &str) -> buzz_db::channel::ChannelRecord { + let now = chrono::Utc::now(); + buzz_db::channel::ChannelRecord { + id: uuid::Uuid::new_v4(), + name: "test".into(), + channel_type: channel_type.into(), + visibility: "open".into(), + description: None, + canvas: None, + created_by: vec![0u8; 32], + created_at: now, + updated_at: now, + archived_at: None, + deleted_at: None, + nip29_group_id: None, + topic_required: false, + max_members: None, + topic: None, + topic_set_by: None, + topic_set_at: None, + purpose: None, + purpose_set_by: None, + purpose_set_at: None, + ttl_seconds: None, + ttl_deadline: None, + } + } + + #[test] + fn channel_mention_accepted_on_allowed_kinds() { + for kind in buzz_core::channel_mentions::NOTIFY_ALLOWED_KINDS { + for mode in ["channel", "here"] { + let event = make_event_with_tags(kind, "hi", &[&["notify", mode]]); + assert!( + validate_channel_mention(&event, Some(&make_channel_row("stream"))).is_ok(), + "kind {kind} mode {mode}" + ); + } + } + } + + #[test] + fn channel_mention_rejected_on_other_kinds() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE_V2, "hi", &[&["notify", "channel"]]); + assert!(validate_channel_mention(&event, Some(&make_channel_row("stream"))).is_err()); + } + + #[test] + fn channel_mention_rejected_for_bad_mode_and_duplicates() { + let bad_mode = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", "everyone"]]); + assert!(validate_channel_mention(&bad_mode, Some(&make_channel_row("stream"))).is_err()); + + let duplicate = make_event_with_tags( + KIND_STREAM_MESSAGE, + "hi", + &[&["notify", "channel"], &["notify", "here"]], + ); + assert!(validate_channel_mention(&duplicate, Some(&make_channel_row("stream"))).is_err()); + + let missing_mode = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify"]]); + assert!( + validate_channel_mention(&missing_mode, Some(&make_channel_row("stream"))).is_err() + ); + } + + #[test] + fn channel_mention_rejected_in_dm_channels() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", "channel"]]); + let err = validate_channel_mention(&event, Some(&make_channel_row("dm"))) + .expect_err("DM channels must reject channel-wide mentions"); + assert!(err.contains("DM"), "{err}"); + } + + #[test] + fn untagged_events_are_unaffected() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["h", "abc"]]); + assert!(validate_channel_mention(&event, Some(&make_channel_row("dm"))).is_ok()); + let other_kind = make_event_with_tags(1, "hi", &[]); + assert!(validate_channel_mention(&other_kind, None).is_ok()); + } + + #[test] + fn notify_tag_rejected_on_kinds_answered_before_the_channel_row_gate() { + // These kinds are answered by their own handlers, which return + // `accepted: true` before `validate_channel_mention` is reached. The + // pure check hoisted above the handler dispatch is therefore their + // only gate — and it is sufficient exactly because none of them is + // notify-allowed. If that ever changes, this test fails first. + for kind in [ + KIND_PRODUCT_FEEDBACK, // 42000 — sidecar table, tags stored verbatim + KIND_REPORT, // 1984 — mod queue + KIND_DM_OPEN, // 41010 — command kind, event stored verbatim + KIND_MODERATION_BAN, // 9040 — moderation command + ] { + assert!( + !buzz_core::channel_mentions::NOTIFY_ALLOWED_KINDS.contains(&kind), + "kind {kind} must stay outside NOTIFY_ALLOWED_KINDS, or the \ + hoisted pure check stops being a complete gate for it" + ); + let event = make_event_with_tags(kind, "hi", &[&["notify", "channel"]]); + assert_eq!( + buzz_core::channel_mentions::event_notify_mode(&event), + Err(buzz_core::channel_mentions::NotifyTagError::KindNotAllowed( + kind + )), + "kind {kind} must reject a notify tag" + ); + } + } + + #[test] + fn notify_tag_demands_membership_on_notifying_kinds_only() { + // Both modes gate: `here` blasts every online member, so it is no more + // open than `channel`. + for mode in ["channel", "here"] { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", mode]]); + assert!( + notify_requires_membership(&event), + "mode {mode} must require membership" + ); + } + for kind in [KIND_FORUM_POST, KIND_FORUM_COMMENT] { + let event = make_event_with_tags(kind, "hi", &[&["notify", "channel"]]); + assert!(notify_requires_membership(&event), "kind {kind}"); + } + + let edit = make_event_with_tags(KIND_STREAM_MESSAGE_EDIT, "hi", &[&["notify", "channel"]]); + assert!( + !notify_requires_membership(&edit), + "edits re-carry the tag for rendering and never notify" + ); + + let untagged = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["h", "abc"]]); + assert!(!notify_requires_membership(&untagged)); + + let malformed = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", "everyone"]]); + assert!( + !notify_requires_membership(&malformed), + "malformed tags are rejected by validate_channel_mention, not by the member gate" + ); + } + #[test] fn diff_validation_rejects_missing_repo() { let event = make_event_with_tags( diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..da49669f86 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -8,6 +8,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Weak}; +use buzz_core::channel_mentions::is_reserved_mention_token; use buzz_core::kind::KIND_STREAM_MESSAGE; use buzz_core::tenant::CommunityId; use buzz_workflow::action_sink::{ActionSink, ActionSinkError}; @@ -40,6 +41,10 @@ use crate::state::AppState; /// - **Ambiguous names wake no one.** If two or more members share the matched /// display name, no `p` tag is emitted for it — arbitrary selection would /// silently misroute and tagging all of them is a false-wake firehose. +/// - **Reserved tokens name nobody.** `@channel` and `@here` are NIP-CM +/// channel-wide mentions; they never resolve to an identity, even when a +/// member is literally named one of them. Workflows emit no notify tag in v1, +/// so such text is inert. /// /// Returns deduplicated pubkey hexes, in first-appearance order in `text`. fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec { @@ -48,7 +53,7 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec> = std::collections::HashMap::new(); for (name, pubkey) in members { - if name.trim().is_empty() { + if name.trim().is_empty() || is_reserved_mention_token(name.trim()) { continue; } by_name @@ -63,7 +68,10 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec = members.iter().collect(); + let mut names: Vec<&(String, String)> = members + .iter() + .filter(|(name, _)| !is_reserved_mention_token(name.trim())) + .collect(); names.sort_by_key(|(name, _)| std::cmp::Reverse(name.chars().count())); let chars: Vec = text.chars().collect(); @@ -401,6 +409,23 @@ mod tests { assert!(resolve_mention_pubkeys("hey @Stranger and @", &members).is_empty()); } + #[test] + fn reserved_channel_wide_tokens_resolve_to_nobody() { + // Even with a member literally named "here", @here is a NIP-CM + // channel-wide mention and must never emit a p tag. + let members = vec![m("here", &pk('a')), m("channel", &pk('b'))]; + assert!(resolve_mention_pubkeys("@here @channel @Here", &members).is_empty()); + } + + #[test] + fn reserved_token_does_not_shadow_a_real_mention() { + let members = vec![m("here", &pk('a')), m("Robby", &pk('b'))]; + assert_eq!( + resolve_mention_pubkeys("@here @Robby take a look", &members), + vec![pk('b')] + ); + } + #[test] fn greedy_longest_binds_full_name_not_prefix() { // Both "Will" and "Will Pfleger" are members. `@Will Pfleger` must bind diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..3ea248d941 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -4,6 +4,7 @@ //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ + channel_mentions::{NotifyMode, NOTIFY_TAG}, kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, @@ -208,6 +209,17 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk Ok(()) } +/// Emit the NIP-CM channel-wide mention tag, if any. +/// +/// Deliberately no `p` tag expansion: the marker tag alone carries the +/// channel-wide mention, so the roster never lands in the event. +fn notify_tag(notify: Option, tags: &mut Vec) -> Result<(), SdkError> { + if let Some(mode) = notify { + tags.push(tag(&[NOTIFY_TAG, mode.as_str()])?); + } + Ok(()) +} + /// Build a stream message (kind 9). /// /// - `channel_id`: target channel UUID @@ -215,6 +227,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `thread_ref`: optional NIP-10 reply context /// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag +/// - `notify`: if set, adds the NIP-CM `["notify", "channel"|"here"]` tag /// - `media_tags`: raw imeta tag vectors pub fn build_message( channel_id: Uuid, @@ -222,6 +235,7 @@ pub fn build_message( thread_ref: Option<&ThreadRef>, mentions: &[&str], broadcast: bool, + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; @@ -233,6 +247,7 @@ pub fn build_message( if broadcast { tags.push(tag(&["broadcast", "1"])?); } + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -275,31 +290,39 @@ pub fn build_agent_observer_frame( } /// Build a forum post thread root (kind 45001). +/// +/// `notify` optionally adds the NIP-CM `["notify", …]` channel-wide mention tag. pub fn build_forum_post( channel_id: Uuid, content: &str, mentions: &[&str], + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; mention_tags(mentions, &mut tags)?; + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags)) } /// Build a forum comment reply (kind 45003). +/// +/// `notify` optionally adds the NIP-CM `["notify", …]` channel-wide mention tag. pub fn build_forum_comment( channel_id: Uuid, content: &str, thread_ref: &ThreadRef, mentions: &[&str], + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; thread_tags(thread_ref, &mut tags)?; mention_tags(mentions, &mut tags)?; + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } @@ -1889,7 +1912,7 @@ mod tests { #[test] fn message_happy_path() { let cid = uuid(); - let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hello", None, &[], false, None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 9); assert_eq!(ev.content, "hello"); assert!(has_tag(&ev, "h", &cid.to_string())); @@ -1947,7 +1970,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, None, &[]).unwrap()); // Direct reply: only one e-tag with "reply" marker let e_tags: Vec<_> = ev .tags @@ -1970,7 +1993,7 @@ mod tests { root_event_id: root, parent_event_id: parent, }; - let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, None, &[]).unwrap()); let e_tags: Vec<_> = ev .tags .iter() @@ -1988,15 +2011,56 @@ mod tests { #[test] fn message_broadcast_flag() { let cid = uuid(); - let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[], true, None, &[]).unwrap()); assert!(has_tag(&ev, "broadcast", "1")); } + #[test] + fn message_notify_tag() { + let cid = uuid(); + for (mode, expected) in [(NotifyMode::Channel, "channel"), (NotifyMode::Here, "here")] { + let ev = sign(build_message(cid, "hi", None, &[], false, Some(mode), &[]).unwrap()); + assert!(has_tag(&ev, "notify", expected)); + assert!( + !ev.tags + .iter() + .any(|t| t.as_slice().first() == Some(&"p".to_string())), + "channel-wide mentions never expand to p tags" + ); + } + } + + #[test] + fn message_without_notify_has_no_notify_tag() { + let cid = uuid(); + let ev = sign(build_message(cid, "hi", None, &[], false, None, &[]).unwrap()); + assert!(!ev + .tags + .iter() + .any(|t| t.as_slice().first() == Some(&"notify".to_string()))); + } + + #[test] + fn forum_builders_carry_notify_tag() { + let cid = uuid(); + let ev = sign(build_forum_post(cid, "post", &[], Some(NotifyMode::Channel), &[]).unwrap()); + assert!(has_tag(&ev, "notify", "channel")); + + let eid = event_id(); + let tr = ThreadRef { + root_event_id: eid, + parent_event_id: eid, + }; + let ev = + sign(build_forum_comment(cid, "c", &tr, &[], Some(NotifyMode::Here), &[]).unwrap()); + assert!(has_tag(&ev, "notify", "here")); + } + #[test] fn message_mentions_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; - let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, None, &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); } @@ -2017,7 +2081,7 @@ mod tests { }) .collect(); let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect(); - let result = build_message(cid, "hi", None, &refs, false, &[]); + let result = build_message(cid, "hi", None, &refs, false, None, &[]); assert!(matches!(result, Err(SdkError::TooManyMentions))); } @@ -2025,7 +2089,7 @@ mod tests { fn message_content_too_large() { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); - let result = build_message(cid, &big, None, &[], false, &[]); + let result = build_message(cid, &big, None, &[], false, None, &[]); assert!(matches!(result, Err(SdkError::ContentTooLarge { .. }))); } @@ -2033,13 +2097,13 @@ mod tests { fn message_max_content_ok() { let cid = uuid(); let max = "x".repeat(64 * 1024); - assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); + assert!(build_message(cid, &max, None, &[], false, None, &[]).is_ok()); } #[test] fn forum_post_happy_path() { let cid = uuid(); - let ev = sign(build_forum_post(cid, "post body", &[], &[]).unwrap()); + let ev = sign(build_forum_post(cid, "post body", &[], None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 45001); assert!(has_tag(&ev, "h", &cid.to_string())); } @@ -2049,7 +2113,7 @@ mod tests { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); assert!(matches!( - build_forum_post(cid, &big, &[], &[]), + build_forum_post(cid, &big, &[], None, &[]), Err(SdkError::ContentTooLarge { .. }) )); } @@ -2062,7 +2126,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_forum_comment(cid, "comment", &tr, &[], &[]).unwrap()); + let ev = sign(build_forum_comment(cid, "comment", &tr, &[], None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 45003); assert!(has_tag(&ev, "h", &cid.to_string())); } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..1095042c97 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -82,6 +82,8 @@ pub use buzz_core::channel::ChannelType as ChannelKind; pub use buzz_core::channel::ChannelVisibility as Visibility; /// Member role. pub use buzz_core::channel::MemberRole; +/// NIP-CM channel-wide mention mode (`@channel` / `@here`). +pub use buzz_core::channel_mentions::NotifyMode; /// Errors returned by SDK builder functions. #[derive(Debug, thiserror::Error)] diff --git a/crates/buzz-sdk/src/mentions.rs b/crates/buzz-sdk/src/mentions.rs index e59580c7ae..6cd6471403 100644 --- a/crates/buzz-sdk/src/mentions.rs +++ b/crates/buzz-sdk/src/mentions.rs @@ -29,6 +29,7 @@ use std::collections::HashSet; +use buzz_core::channel_mentions::is_reserved_mention_token; use nostr::{FromBech32, PublicKey}; /// Maximum number of mention p-tags allowed on a single message. @@ -61,7 +62,31 @@ pub struct MentionProfile<'a> { /// /// Allowed name characters: ASCII alphanumerics, `.`, `-`, `_`. /// Duplicates are removed; first-seen order is preserved. +/// +/// The reserved channel-wide mention tokens (`@channel`, `@here`) are never +/// returned — see [`extract_reserved_mention_tokens`]. pub fn extract_at_names(content: &str) -> Vec { + scan_at_tokens(content) + .into_iter() + .filter(|name| !is_reserved_mention_token(name)) + .collect() +} + +/// Extract the reserved channel-wide mention tokens (`channel`, `here`) that +/// appear as `@tokens` in `content`. +/// +/// Returned lowercased, deduplicated, in first-seen order. Matching is +/// case-insensitive, so `@Here` is reported as `here`. Callers that care about +/// code blocks should pass content through [`strip_code_regions`] first. +pub fn extract_reserved_mention_tokens(content: &str) -> Vec { + scan_at_tokens(content) + .into_iter() + .filter(|name| is_reserved_mention_token(name)) + .collect() +} + +/// Scan single-word `@tokens`, lowercased, deduplicated, first-seen order. +fn scan_at_tokens(content: &str) -> Vec { if content.is_empty() || !content.contains('@') { return vec![]; } @@ -104,6 +129,9 @@ pub fn extract_at_names(content: &str) -> Vec { /// longest-first (case-insensitive, word-boundary-checked), then falls back /// to single-word tokenization. Returns lowercased names in first-seen order, /// deduplicated. Empty/whitespace-only entries in `known_names` are ignored. +/// +/// The reserved channel-wide mention tokens (`channel`, `here`) are never +/// returned, even when a member is literally named one of them. pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Vec { if content.is_empty() || !content.contains('@') { return vec![]; @@ -144,6 +172,9 @@ pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Ve rest[..end].to_ascii_lowercase() }; + if is_reserved_mention_token(&lower) { + continue; + } if seen.insert(lower.clone()) { names.push(lower); } @@ -171,7 +202,9 @@ fn is_word_boundary(s: &str) -> bool { /// rather than text-position ordering. /// /// Profiles whose `content_json` does not parse, or whose `display_name` -/// (and `name`) are absent or non-string, are silently skipped. +/// (and `name`) are absent or non-string, are silently skipped. So are +/// profiles named after a reserved channel-wide mention token (`channel`, +/// `here`) — those tokens never resolve to an identity. /// /// Duplicate display names within a channel will produce multiple matches /// for a single `@name` — this is by design; resolution is bounded to @@ -190,7 +223,7 @@ pub fn match_names_to_profiles(names: &[String], profiles: &[MentionProfile<'_>] .or_else(|| value.get("name")) .and_then(|v| v.as_str()) .unwrap_or(""); - if name.is_empty() { + if name.is_empty() || is_reserved_mention_token(name) { continue; } if names.iter().any(|n| n.eq_ignore_ascii_case(name)) { @@ -236,68 +269,95 @@ pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: Option<&str> .collect() } +/// Length of the run of `marker` characters starting at byte index `i`. +fn marker_run_len(content: &str, i: usize, marker: char) -> usize { + content[i..].chars().take_while(|&c| c == marker).count() +} + +/// Whether byte index `i` is preceded only by whitespace on its line. +fn at_line_start(content: &str, i: usize) -> bool { + let before = &content[..i]; + match before.rsplit_once('\n') { + Some((_, after_nl)) => after_nl.chars().all(|c| c.is_ascii_whitespace()), + None => before.chars().all(|c| c.is_ascii_whitespace()), + } +} + +/// If a fenced code block opens at `i`, return the byte index just past it. +/// +/// A fence is a run of three or more `marker` characters (`` ` `` or `~`) at +/// the start of a line. It is closed by a run of at least the same length at +/// the start of a later line, per CommonMark, or by the end of the content. +fn fence_close_end(content: &str, i: usize, marker: char) -> Option { + let open_len = marker_run_len(content, i, marker); + if open_len < 3 || !at_line_start(content, i) { + return None; + } + + // Skip the rest of the opening fence line (the info string). + let after_fence = i + open_len; + let mut search_from = content[after_fence..] + .find('\n') + .map_or(content.len(), |p| after_fence + p + 1); + + loop { + let Some(pos) = content[search_from..].find(marker) else { + return Some(content.len()); + }; + let abs = search_from + pos; + let run = marker_run_len(content, abs, marker); + if run >= open_len && at_line_start(content, abs) { + // Consume the rest of the closing fence line. + let after_close = abs + run; + return Some( + content[after_close..] + .find('\n') + .map_or(content.len(), |p| after_close + p + 1), + ); + } + search_from = abs + run; + } +} + +/// If an inline code span opens at `i`, return the byte index just past it. +/// +/// A span opens with a run of `n` backticks and closes with the next run of +/// *exactly* `n` backticks on the same line (CommonMark's equal-length rule), +/// so ``` ``@here`` ``` masks its contents just as `` `@here` `` does. +fn code_span_close_end(content: &str, i: usize) -> Option { + let open_len = marker_run_len(content, i, '`'); + let after_open = i + open_len; + let mut search_from = after_open; + + while let Some(pos) = content[search_from..].find('`') { + let abs = search_from + pos; + if content[after_open..abs].contains('\n') { + return None; + } + let run = marker_run_len(content, abs, '`'); + if run == open_len { + return Some(abs + run); + } + search_from = abs + run; + } + None +} + /// Remove fenced code blocks and inline code spans from content. /// -/// Returns a copy of `content` with ` ```…``` ` blocks and `` `…` `` spans -/// replaced by spaces. Used only for mention scanning — the original +/// Returns a copy of `content` with fenced blocks (three or more backticks +/// **or** tildes at line start) and backtick code spans of any delimiter +/// length replaced by spaces. Used only for mention scanning — the original /// content is stored verbatim. Preserves valid UTF-8 throughout. pub fn strip_code_regions(content: &str) -> String { let mut out = String::with_capacity(content.len()); let mut chars = content.char_indices().peekable(); while let Some(&(i, ch)) = chars.peek() { - // Fenced code block: ``` at line start (possibly after whitespace) - if ch == '`' && content[i..].starts_with("```") { - let is_fence_start = if i == 0 { - true - } else { - let before = &content[..i]; - before.ends_with('\n') - || before.chars().all(|c| c.is_ascii_whitespace()) - || before.rsplit_once('\n').is_some_and(|(_, after_nl)| { - after_nl.chars().all(|c| c.is_ascii_whitespace()) - }) - }; - - if is_fence_start { - // Find end of opening fence line - let after_fence = i + 3; - let rest = &content[after_fence..]; - let line_end = rest - .find('\n') - .map_or(content.len(), |p| after_fence + p + 1); - - // Find closing fence - let mut search_from = line_end; - let close_end = loop { - if search_from >= content.len() { - break content.len(); - } - if let Some(pos) = content[search_from..].find("```") { - let abs_pos = search_from + pos; - let at_line_start = abs_pos == 0 - || content.as_bytes()[abs_pos - 1] == b'\n' - || content[..abs_pos] - .rsplit_once('\n') - .is_some_and(|(_, after_nl)| { - after_nl.chars().all(|c| c.is_ascii_whitespace()) - }); - if at_line_start { - // Skip to end of closing fence line - let after_close = abs_pos + 3; - let end = content[after_close..] - .find('\n') - .map_or(content.len(), |p| after_close + p + 1); - break end; - } - search_from = abs_pos + 3; - } else { - break content.len(); - } - }; - + // Fenced code block: ``` or ~~~ (3+) at line start. + if ch == '`' || ch == '~' { + if let Some(close_end) = fence_close_end(content, i, ch) { out.push(' '); - // Advance chars iterator past the fenced block while let Some(&(ci, _)) = chars.peek() { if ci >= close_end { break; @@ -308,26 +368,17 @@ pub fn strip_code_regions(content: &str) -> String { } } - // Inline code span: `…` + // Inline code span: `…`, ``…``, and longer. if ch == '`' { - let after_tick = i + 1; - if after_tick < content.len() { - // Find closing backtick on same line - if let Some(rel_end) = content[after_tick..].find('`') { - let close_pos = after_tick + rel_end; - // Only treat as code span if no newline between the backticks - if !content[after_tick..close_pos].contains('\n') { - out.push(' '); - // Advance past closing backtick - while let Some(&(ci, _)) = chars.peek() { - if ci > close_pos { - break; - } - chars.next(); - } - continue; + if let Some(close_end) = code_span_close_end(content, i) { + out.push(' '); + while let Some(&(ci, _)) = chars.peek() { + if ci >= close_end { + break; } + chars.next(); } + continue; } } @@ -426,6 +477,54 @@ mod tests { assert!(extract_at_names("hello @").is_empty()); } + #[test] + fn reserved_tokens_are_never_extracted_as_names() { + assert!(extract_at_names("@channel ship it").is_empty()); + assert!(extract_at_names("heads up @Here").is_empty()); + assert_eq!( + extract_at_names("@channel and @alice"), + vec!["alice"], + "regular names alongside a reserved token still resolve" + ); + } + + #[test] + fn reserved_tokens_lose_to_no_one_even_a_member_named_here() { + // A member whose display name is literally "here" must not be pulled in + // by @here — the reserved token wins in every parser. + let names = extract_at_mentions_with_known("ping @here now", &["here", "Alice"]); + assert!(names.is_empty(), "got {names:?}"); + + let profiles = [MentionProfile { + pubkey: "aa", + content_json: r#"{"display_name":"here"}"#, + }]; + assert!(match_names_to_profiles(&["here".to_string()], &profiles).is_empty()); + } + + #[test] + fn reserved_token_prefixes_are_still_ordinary_names() { + assert_eq!( + extract_at_names("@channels @herer"), + vec!["channels", "herer"] + ); + } + + #[test] + fn extract_reserved_mention_tokens_reports_lowercased_tokens() { + assert_eq!( + extract_reserved_mention_tokens("hey @Channel and @here"), + vec!["channel", "here"] + ); + assert!(extract_reserved_mention_tokens("hey @alice").is_empty()); + assert!(extract_reserved_mention_tokens("user@channel.com").is_empty()); + assert_eq!( + extract_reserved_mention_tokens("@here @HERE"), + vec!["here"], + "deduplicated case-insensitively" + ); + } + #[test] fn known_multiword_name_matches_fully() { // "Will Pfleger" should match @Will Pfleger, not just @Will. @@ -699,6 +798,46 @@ mod tests { assert!(stripped.contains("world")); } + #[test] + fn strip_code_regions_removes_tilde_fenced_block() { + let stripped = strip_code_regions("before\n~~~\n@here\n~~~\nafter"); + assert!(!stripped.contains("@here"), "{stripped:?}"); + assert!(stripped.contains("before") && stripped.contains("after")); + + // A longer closer still closes the fence; a shorter one does not. + let longer = strip_code_regions("~~~\n@channel\n~~~~\nafter"); + assert!(!longer.contains("@channel"), "{longer:?}"); + assert!(longer.contains("after")); + let shorter = strip_code_regions("~~~~\n@channel\n~~~\nstill code"); + assert!(!shorter.contains("@channel"), "{shorter:?}"); + assert!(!shorter.contains("still code"), "{shorter:?}"); + + // An unclosed fence masks to end of content. + let unclosed = strip_code_regions("~~~\n@here never closed"); + assert!(!unclosed.contains("@here"), "{unclosed:?}"); + + // Strikethrough is two tildes — not a fence. + let strike = strip_code_regions("~~@here~~ shipped"); + assert!(strike.contains("@here"), "{strike:?}"); + } + + #[test] + fn strip_code_regions_removes_multi_backtick_span() { + let stripped = strip_code_regions("see ``@here`` there"); + assert!(!stripped.contains("@here"), "{stripped:?}"); + assert!(stripped.contains("see") && stripped.contains("there")); + + // A shorter run inside the span does not close it. + let nested = strip_code_regions("``a `@channel` c`` tail"); + assert!(!nested.contains("@channel"), "{nested:?}"); + assert!(nested.contains("tail")); + + // Single-backtick spans keep their existing behavior. + let single = strip_code_regions("a `@here` b"); + assert!(!single.contains("@here"), "{single:?}"); + assert!(single.contains('a') && single.contains('b')); + } + const TEST_NPUB1: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; const TEST_HEX1: &str = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; const TEST_NPUB2: &str = "npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw"; diff --git a/crates/buzz-test-client/tests/e2e_channel_mentions.rs b/crates/buzz-test-client/tests/e2e_channel_mentions.rs new file mode 100644 index 0000000000..c95717c4f2 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_channel_mentions.rs @@ -0,0 +1,424 @@ +//! End-to-end integration tests for NIP-CM channel-wide mentions (`@channel` / `@here`). +//! +//! These tests cover the relay write path — the accept/reject matrix for the +//! `["notify", "channel"|"here"]` marker tag — and the read path, where an +//! accepted `@channel` event surfaces in the mentions feed of every channel +//! member (and of nobody else). +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_channel_mentions -- --ignored +//! ``` + +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::Value; +use uuid::Uuid; + +const KIND_STREAM_MESSAGE: u16 = 9; +const KIND_STREAM_MESSAGE_V2: u16 = 40002; +const KIND_CREATE_GROUP: u16 = 9007; +const KIND_JOIN_REQUEST: u16 = 9021; +const KIND_REPORT: u16 = 1984; +const KIND_PRODUCT_FEEDBACK: u16 = 42000; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Submit a signed event over `POST /events` and return the parsed body. +/// +/// The bridge answers 4xx for rejections, so the status is folded into the +/// returned tuple instead of asserted here. +async fn post_event(keys: &Keys, event: &nostr::Event) -> (reqwest::StatusCode, Value) { + let response = reqwest::Client::new() + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).expect("serialize event")) + .send() + .await + .expect("submit event"); + let status = response.status(); + let text = response.text().await.expect("read event response"); + let body = serde_json::from_str(&text).unwrap_or(Value::String(text)); + (status, body) +} + +fn accepted(body: &Value) -> bool { + body.get("accepted") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn rejection_message(status: reqwest::StatusCode, body: &Value) -> String { + match body { + Value::String(text) => format!("{status}: {text}"), + other => format!("{status}: {other}"), + } +} + +async fn create_channel(keys: &Keys, channel_type: &str) -> Uuid { + let channel_id = Uuid::new_v4(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_GROUP), "") + .tags(vec![ + Tag::parse(["h", &channel_id.to_string()]).expect("h tag"), + Tag::parse(["name", &format!("cm-e2e-{channel_id}")]).expect("name tag"), + Tag::parse(["channel_type", channel_type]).expect("channel_type tag"), + Tag::parse(["visibility", "open"]).expect("visibility tag"), + ]) + .sign_with_keys(keys) + .expect("sign create-group event"); + let (status, body) = post_event(keys, &event).await; + assert!( + status.is_success() && accepted(&body), + "channel creation failed: {}", + rejection_message(status, &body) + ); + channel_id +} + +async fn join_channel(keys: &Keys, channel_id: Uuid) { + let event = EventBuilder::new(Kind::Custom(KIND_JOIN_REQUEST), "") + .tags(vec![ + Tag::parse(["h", &channel_id.to_string()]).expect("h tag") + ]) + .sign_with_keys(keys) + .expect("sign join event"); + let (status, body) = post_event(keys, &event).await; + assert!( + status.is_success() && accepted(&body), + "join failed: {}", + rejection_message(status, &body) + ); +} + +fn message( + keys: &Keys, + kind: u16, + channel_id: Uuid, + content: &str, + tags: &[&[&str]], +) -> nostr::Event { + let mut all = vec![Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]; + all.extend( + tags.iter() + .map(|t| Tag::parse(t.iter().copied()).expect("tag")), + ); + EventBuilder::new(Kind::Custom(kind), content) + .tags(all) + .sign_with_keys(keys) + .expect("sign message") +} + +/// Query the caller's mentions feed over `POST /query`. +async fn mentions_feed(keys: &Keys) -> Vec { + // `POST /query` takes an array of Nostr filters, as the CLI sends them. + let filters = serde_json::json!([{ + "#p": [keys.public_key().to_hex()], + "feed_types": ["mentions"], + "limit": 50 + }]); + let response = reqwest::Client::new() + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filters).expect("serialize filters")) + .send() + .await + .expect("query mentions feed"); + assert!( + response.status().is_success(), + "mentions feed query failed: {}", + response.status() + ); + response.json().await.expect("parse mentions feed") +} + +fn feed_contains(feed: &[Value], event_id: &str) -> bool { + feed.iter() + .any(|e| e.get("id").and_then(Value::as_str) == Some(event_id)) +} + +// -- Write path: accept/reject matrix ----------------------------------------- + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_accepted_on_stream_messages_in_both_modes() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + for mode in ["channel", "here"] { + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + &format!("heads up @{mode}"), + &[&["notify", mode]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + status.is_success() && accepted(&body), + "mode {mode} must be accepted: {}", + rejection_message(status, &body) + ); + } +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_for_invalid_mode() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "everyone"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "an unknown notify mode must be rejected: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_when_missing_a_mode() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message(&author, KIND_STREAM_MESSAGE, channel, "hi", &[&["notify"]]); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "a bare notify tag must be rejected: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn duplicate_notify_tags_are_rejected() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "channel"], &["notify", "here"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "at most one notify tag is allowed: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_on_disallowed_kind() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE_V2, + channel, + "hi", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "kind 40002 may not carry a notify tag: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_in_dm_channels() { + let author = Keys::generate(); + let channel = create_channel(&author, "dm").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "DM channels must reject channel-wide mentions: {}", + rejection_message(status, &body) + ); + + // Control: the same message without the tag is fine in the same channel. + let plain = message(&author, KIND_STREAM_MESSAGE, channel, "hi", &[]); + let (status, body) = post_event(&author, &plain).await; + assert!( + status.is_success() && accepted(&body), + "untagged DM messages must still be accepted: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_on_kinds_with_their_own_handlers() { + // Product feedback (42000) and reports (1984) are answered by dedicated + // handlers that report success before the channel-row gate is reached. + // The tag must still be rejected — and rejected *as* a notify tag, which + // only happens if the gate runs ahead of the handler dispatch. + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + for kind in [KIND_PRODUCT_FEEDBACK, KIND_REPORT] { + let event = message(&author, kind, channel, "hi", &[&["notify", "channel"]]); + let (status, body) = post_event(&author, &event).await; + let detail = rejection_message(status, &body); + assert!( + !status.is_success() || !accepted(&body), + "kind {kind} must not accept a notify tag: {detail}" + ); + assert!( + detail.contains("notify tag"), + "kind {kind} must be rejected as a notify tag, not by its own handler: {detail}" + ); + } +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn non_members_may_not_notify_an_open_channel() { + let owner = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&owner, "stream").await; + + // Control: an open channel accepts an ordinary message from a non-member. + let plain = message(&outsider, KIND_STREAM_MESSAGE, channel, "just passing", &[]); + let (status, body) = post_event(&outsider, &plain).await; + assert!( + status.is_success() && accepted(&body), + "open channels accept untagged writes from non-members: {}", + rejection_message(status, &body) + ); + + // The open-posting fallback does not extend to the notify tag. + for mode in ["channel", "here"] { + let event = message( + &outsider, + KIND_STREAM_MESSAGE, + channel, + &format!("blast @{mode}"), + &[&["notify", mode]], + ); + let (status, body) = post_event(&outsider, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "non-members must not use @{mode} in an open channel: {}", + rejection_message(status, &body) + ); + } + + // Joining the roster unlocks it. + join_channel(&outsider, channel).await; + let event = message( + &outsider, + KIND_STREAM_MESSAGE, + channel, + "now a member @channel", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&outsider, &event).await; + assert!( + status.is_success() && accepted(&body), + "members may notify the channel: {}", + rejection_message(status, &body) + ); +} + +// -- Read path: mentions feed -------------------------------------------------- + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn channel_mention_surfaces_in_member_feeds_and_here_never_does() { + let author = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&author, "stream").await; + join_channel(&member, channel).await; + + let channel_event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "deploy window closes in 10 @channel", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &channel_event).await; + assert!( + status.is_success() && accepted(&body), + "@channel message must be accepted: {}", + rejection_message(status, &body) + ); + + let here_event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "standup now @here", + &[&["notify", "here"]], + ); + let (status, body) = post_event(&author, &here_event).await; + assert!( + status.is_success() && accepted(&body), + "@here message must be accepted: {}", + rejection_message(status, &body) + ); + + let channel_event_id = channel_event.id.to_hex(); + let here_event_id = here_event.id.to_hex(); + + let member_feed = mentions_feed(&member).await; + assert!( + feed_contains(&member_feed, &channel_event_id), + "channel members must see the @channel event in their mentions feed" + ); + assert!( + !feed_contains(&member_feed, &here_event_id), + "@here is live-only and must never reach the mentions feed" + ); + + let outsider_feed = mentions_feed(&outsider).await; + assert!( + !feed_contains(&outsider_feed, &channel_event_id), + "non-members must not see the @channel event" + ); + + let author_feed = mentions_feed(&author).await; + assert!( + !feed_contains(&author_feed, &channel_event_id), + "the author's own @channel event is not a mention of the author" + ); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 459fa75743..a86064ec55 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -63,6 +63,8 @@ export default defineConfig({ "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/team-mentions.spec.ts", + "**/channel-mentions.spec.ts", + "**/channel-mentions-screenshots.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..387d0c6dc4 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -41,6 +41,54 @@ const TIMELINE_KINDS: [u32; 11] = [ buzz_core_pkg::kind::KIND_HUDDLE_STARTED, ]; +/// `FeedItem.category` value for mention rows. +/// +/// Singular — this is the frontend contract (`desktop/src/shared/api/tauri.ts` +/// declares `category: "mention" | ...` and consumers compare `=== "mention"`). +/// The plural `"mentions"` used in [`mentions_feed_filter`] is the relay-side +/// `feed_types` value, a different namespace. +const MENTION_CATEGORY: &str = "mention"; + +/// Filter for the Inbox mentions section. +/// +/// `feed_types` routes the query to the relay's bounded mentions feed — +/// direct `p`-tag mentions UNION NIP-CM `@channel` notifications, membership- +/// and visibility-scoped server-side. Without it the bridge treats this as a +/// raw `#p` filter, and marker-only `@channel` events (which carry no `p` +/// tag) never produce an Inbox row. The raw `kinds`/`#p` fields stay as a +/// graceful fallback: the bridge ignores them when `feed_types` is present, +/// while a relay that predates the extension drops the unknown field and +/// still serves direct mentions. +pub(crate) fn mentions_feed_filter( + my_pubkey: &str, + cap: u32, + since: Option, +) -> serde_json::Value { + let mut filter = serde_json::json!({ + "feed_types": ["mentions"], + "kinds": [ + 9, + 40002, + 1, + 45001, + 45003, + buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, + buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, + buzz_core_pkg::kind::KIND_GIT_ISSUE, + buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, + buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, + buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, + buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, + ], + "#p": [my_pubkey], + "limit": cap, + }); + if let Some(s) = since { + filter["since"] = serde_json::json!(s); + } + filter +} + #[tauri::command] pub async fn get_feed( since: Option, @@ -66,28 +114,7 @@ pub async fn get_feed( keys.public_key().to_hex() }; - // Mentions: messages that reference me via #p. - let mut mention_filter = serde_json::json!({ - "kinds": [ - 9, - 40002, - 1, - 45001, - 45003, - buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, - buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, - buzz_core_pkg::kind::KIND_GIT_ISSUE, - buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, - buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, - buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, - buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, - ], - "#p": [my_pubkey], - "limit": cap, - }); - if let Some(s) = since { - mention_filter["since"] = serde_json::json!(s); - } + let mention_filter = mentions_feed_filter(&my_pubkey, cap, since); // Needs-action: workflow approval-request events sent to me. let mut approval_filter = serde_json::json!({ "kinds": [46010, 46011, 46012], @@ -115,7 +142,7 @@ pub async fn get_feed( let mentions: Vec = mention_events .iter() - .map(|ev| feed_item_from_event(ev, "mentions")) + .map(|ev| feed_item_from_event(ev, MENTION_CATEGORY)) .collect(); let needs_action: Vec = approval_events .iter() @@ -537,6 +564,7 @@ pub async fn send_channel_message( mention_tags: Option>>, mention_pubkeys: Option>, kind: Option, + notify: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -547,6 +575,8 @@ pub async fn send_channel_message( let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + // NIP-CM channel-wide mention marker; validated by the builder. + let notify_mode = notify.as_deref().map(str::trim).filter(|m| !m.is_empty()); let mut resolved_root: Option = None; @@ -555,6 +585,7 @@ pub async fn send_channel_message( channel_uuid, content.trim(), &mention_refs, + notify_mode, &media, &mention_refs_only, )?, @@ -569,6 +600,7 @@ pub async fn send_channel_message( content.trim(), &thread_ref, &mention_refs, + notify_mode, &media, &mention_refs_only, )? @@ -587,6 +619,7 @@ pub async fn send_channel_message( content.trim(), thread_ref.as_ref(), &mention_refs, + notify_mode, &media, &emoji, &mention_refs_only, @@ -753,6 +786,7 @@ fn build_managed_agent_channel_message( content, thread_ref, &mention_refs, + None, &[], &[], &[], diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index a907a3dff1..4bfeef76fe 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -217,3 +217,44 @@ fn legacy_managed_agent_auth_tag_skips_self_attestation() { assert_eq!(tag, None); } + +#[test] +fn mention_feed_items_use_the_singular_frontend_category() { + // The frontend contract is `category: "mention"` (singular) — see + // `desktop/src/shared/api/tauri.ts`. The plural "mentions" belongs to the + // relay-side `feed_types` namespace only. + assert_eq!(MENTION_CATEGORY, "mention"); + + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .sign_with_keys(&Keys::generate()) + .expect("event should sign"); + + let item = feed_item_from_event(&event, MENTION_CATEGORY); + assert_eq!(item.category, "mention"); +} + +#[test] +fn mentions_feed_filter_requests_bounded_feed() { + let pubkey = "aa".repeat(32); + + let filter = mentions_feed_filter(&pubkey, 25, None); + assert_eq!( + filter["feed_types"], + serde_json::json!(["mentions"]), + "the Inbox must route to the bounded mentions feed so marker-only @channel rows appear" + ); + assert_eq!( + filter["#p"], + serde_json::json!([pubkey.clone()]), + "#p stays as graceful fallback for relays predating the feed_types extension" + ); + assert_eq!(filter["limit"], serde_json::json!(25)); + assert!( + filter.get("since").is_none(), + "no since -> no since key in the filter" + ); + + let with_since = mentions_feed_filter(&pubkey, 10, Some(1_700_000_000)); + assert_eq!(with_since["since"], serde_json::json!(1_700_000_000)); + assert_eq!(with_since["limit"], serde_json::json!(10)); +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..18e30294a0 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -9,6 +9,9 @@ //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. +use std::str::FromStr; + +use buzz_core_pkg::channel_mentions::{NotifyMode, NOTIFY_TAG}; use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; @@ -93,6 +96,20 @@ fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Resu Ok(()) } +/// Validate and append the NIP-CM channel-wide mention marker. +/// +/// At most one `["notify", mode]` tag per event; the mode is parsed by +/// `buzz_core::channel_mentions` so the desktop and the relay accept exactly +/// the same spellings (lowercase `channel` / `here`). +fn notify_tag(notify: Option<&str>, tags: &mut Vec) -> Result<(), String> { + let Some(raw) = notify else { + return Ok(()); + }; + let mode = NotifyMode::from_str(raw).map_err(|e| e.to_string())?; + tags.push(tag(vec![NOTIFY_TAG, mode.as_str()])?); + Ok(()) +} + /// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" /// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { @@ -295,11 +312,13 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], @@ -309,6 +328,7 @@ pub fn build_message( content, thread_ref, mentions, + notify, media_tags, custom_emoji_tags, mention_ref_tags, @@ -327,6 +347,7 @@ pub fn build_message_with_client_tags( content: &str, thread_ref: Option<&ThreadRef>, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], @@ -338,6 +359,7 @@ pub fn build_message_with_client_tags( tags.extend(thread_tags(tr)?); } tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; @@ -367,23 +389,27 @@ pub fn build_forum_post( channel_id: Uuid, content: &str, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], mention_ref_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags)) } /// Kind 45003 — forum comment. +#[allow(clippy::too_many_arguments)] pub fn build_forum_comment( channel_id: Uuid, content: &str, thread_ref: &ThreadRef, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], mention_ref_tags: &[Vec], ) -> Result { @@ -391,6 +417,7 @@ pub fn build_forum_comment( let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; tags.extend(thread_tags(thread_ref)?); tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) @@ -847,153 +874,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..4ddd02ae26 --- /dev/null +++ b/desktop/src-tauri/src/events_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use nostr::Keys; +fn builder_tags(builder: EventBuilder) -> Vec> { + let keys = Keys::generate(); + let event = builder.sign_with_keys(&keys).expect("sign event"); + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +#[test] +fn build_message_emits_one_notify_tag_per_mode() { + for mode in ["channel", "here"] { + let builder = build_message( + Uuid::new_v4(), + "heads up", + None, + &[], + Some(mode), + &[], + &[], + &[], + ) + .expect("build_message"); + let notify: Vec> = builder_tags(builder) + .into_iter() + .filter(|t| t.first().map(String::as_str) == Some("notify")) + .collect(); + assert_eq!(notify, vec![vec!["notify".to_string(), mode.to_string()]]); + } +} + +#[test] +fn build_message_omits_notify_tag_when_absent() { + let builder = + build_message(Uuid::new_v4(), "hi", None, &[], None, &[], &[], &[]).expect("build_message"); + assert!(builder_tags(builder) + .iter() + .all(|t| t.first().map(String::as_str) != Some("notify"))); +} + +#[test] +fn build_message_rejects_unknown_notify_mode() { + for mode in ["Channel", "everyone", ""] { + let err = build_message(Uuid::new_v4(), "hi", None, &[], Some(mode), &[], &[], &[]) + .expect_err("mode must be rejected"); + assert!(err.contains("notify mode"), "unexpected error: {err}"); + } +} + +#[test] +fn forum_builders_carry_notify_tag() { + let post = build_forum_post(Uuid::new_v4(), "ship it", &[], Some("channel"), &[], &[]) + .expect("build_forum_post"); + assert!(builder_tags(post).contains(&vec!["notify".into(), "channel".into()])); + + let event_id = EventId::all_zeros(); + let thread_ref = ThreadRef { + root_event_id: event_id, + parent_event_id: event_id, + }; + let comment = build_forum_comment( + Uuid::new_v4(), + "agreed", + &thread_ref, + &[], + Some("here"), + &[], + &[], + ) + .expect("build_forum_comment"); + assert!(builder_tags(comment).contains(&vec!["notify".into(), "here".into()])); +} + +#[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 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-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6..b6ebb42498 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -298,7 +298,7 @@ pub(crate) fn spawn_transcription_task( let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { + match events::build_message(channel_uuid, &t, None, &p_tags, None, &[], &[], &[]) { Ok(b) => b, Err(e) => { eprintln!("buzz-desktop: STT build_message: {e}"); diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..ac7f2e6bb9 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -5,6 +5,10 @@ import { toSearchHit, } from "@/app/AppShell.helpers"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { + channelNotifyEscalates, + notifyModeForTags, +} from "@/features/notifications/lib/channelNotifyEscalation"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -40,10 +44,50 @@ export function useAppShellDesktopNotifications({ ) => Promise; pubkey?: string; }) { + // `@here` is live-only, so it never reaches the home feed the `@channel` + // toast comes from — this is its only notification path. Callers have + // already applied mute, author, and freshness filters. + const notifyForLiveChannelMention = React.useEffectEvent( + (channelId: string, event: RelayEvent) => { + if (!notificationSettings.slotAlertsEnabled.mention) return; + if (notifyModeForTags(event.tags) !== "here") return; + if (!channelNotifyEscalates(event, pubkey?.trim().toLowerCase() ?? "")) { + return; + } + + const resolvedChannel = channels.find((c) => c.id === channelId); + const channelName = resolvedChannel?.name?.trim() ?? null; + + void sendDesktopNotification({ + title: formatNotificationTitle({ + prefix: "@here", + channelLabel: channelName ? `#${channelName}` : null, + }), + body: truncateNotificationBody(event.content, "New message"), + target: { + channelId, + channelName, + content: event.content, + createdAt: event.created_at, + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + threadRootId: getThreadReference(event.tags).rootId ?? null, + }, + }).then((didSend) => { + if (!didSend) return; + playNotificationSound( + resolveSlotSound(notificationSettings, "mention"), + ); + }); + }, + ); + const handleChannelNotification = React.useEffectEvent( - (_channelId: string, event: RelayEvent) => { - if (!shouldBounceForChannelNotification(event.tags)) return; + (channelId: string, event: RelayEvent) => { if (!notificationSettings.desktopEnabled) return; + notifyForLiveChannelMention(channelId, event); + if (!shouldBounceForChannelNotification(event.tags)) return; void requestDockBounce(); }, ); @@ -93,8 +137,13 @@ export function useAppShellDesktopNotifications({ // Replies that @-mention the user are owned by the home-feed mention // path — skip them here so they don't notify (and sound) twice. + // Channel-wide mentions are owned by the feed (`@channel`) and the live + // channel path (`@here`) for the same reason. const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; - if (hasMentionForEvent(event, normalizedPubkey)) { + if ( + hasMentionForEvent(event, normalizedPubkey) || + notifyModeForTags(event.tags) !== null + ) { return; } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs new file mode 100644 index 0000000000..faf6eebe7d --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs @@ -0,0 +1,319 @@ +/** + * Convergence regression test for useLiveChannelUpdates. + * + * One event can match BOTH per-channel live subscriptions at once: the general + * `#h` subscription and the `#p` mention subscription (HOME_MENTION_EVENT_KINDS + * ⊆ CHANNEL_EVENT_KINDS). The relay fans out one frame per subscription, so a + * message that p-tags the reader *and* carries a NIP-CM notify tag arrives + * twice on the same connection and both frames land in handleIncomingMessage. + * Without a shared seen-set there, onChannelMessage fires twice and the reader + * gets two identical OS notifications with two mention sounds. + * + * These tests mount the REAL hook against stubbed relayClient subscriptions and + * a real QueryClientProvider, capture the two subscription callbacks, and drive + * the same event through both. They fail if the dedup guard at the top of + * handleIncomingMessage is removed. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Minimal DOM shim (react-dom/client needs a document) ───────────────────── + +function installDOMShim() { + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter( + (current) => current !== listener, + ), + ); + } + + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) ?? []) + listener(event); + return true; + } + } + + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + } + + get ownerDocument() { + return globalThis.document; + } + + get firstChild() { + return this.children[0] ?? null; + } + + get lastChild() { + return this.children.at(-1) ?? null; + } + + get nextSibling() { + return null; + } + + get nodeValue() { + return null; + } + + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } + + insertBefore(child, reference) { + if (!reference) return this.appendChild(child); + const index = this.children.indexOf(reference); + if (index < 0) return this.appendChild(child); + this.children.splice(index, 0, child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + } + + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + + createElement(tagName) { + return new NodeShim(tagName); + } + + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + + get activeElement() { + return null; + } + } + + globalThis.document = new DocumentShim(); + const windowEvents = new EventTargetShim(); + globalThis.addEventListener = + windowEvents.addEventListener.bind(windowEvents); + globalThis.removeEventListener = + windowEvents.removeEventListener.bind(windowEvents); + globalThis.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents); + globalThis.HTMLElement = NodeShim; + globalThis.HTMLIFrameElement = NodeShim; + globalThis.Node = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + globalThis.localStorage = { + getItem: () => null, + removeItem: () => {}, + setItem: () => {}, + }; + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); +} + +installDOMShim(); + +// ── Production imports (after the shim) ────────────────────────────────────── + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import { useLiveChannelUpdates } from "./useLiveChannelUpdates.ts"; + +const CHANNEL_ID = "11111111-1111-4111-8111-111111111111"; +const READER = "b".repeat(64); +const AUTHOR = "a".repeat(64); + +const channels = [ + { id: CHANNEL_ID, name: "general", channelType: "stream", createdAt: 0 }, +]; + +/** A message that both p-tags the reader and carries an @here marker. */ +function mentionAndNotifyEvent(id) { + return { + id, + kind: KIND_STREAM_MESSAGE, + pubkey: AUTHOR, + created_at: Math.floor(Date.now() / 1000), + content: "@reader @here ship it", + sig: "s".repeat(128), + tags: [ + ["h", CHANNEL_ID], + ["p", READER], + ["notify", "here"], + ], + }; +} + +/** + * Mount the hook with stubbed relay subscriptions. Returns the captured + * subscription callbacks plus the callback invocation logs. + */ +async function mountHook() { + const generalCallbacks = []; + const mentionCallbacks = []; + const channelMessages = []; + const liveMentions = []; + + const noopDispose = async () => {}; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.subscribeLive = async (_filter, onEvent) => { + generalCallbacks.push(onEvent); + return noopDispose; + }; + relayClient.subscribeToChannelMentionEvents = async ( + _channelId, + _pubkey, + onEvent, + ) => { + mentionCallbacks.push(onEvent); + return noopDispose; + }; + + function Harness() { + useLiveChannelUpdates(channels, null, { + currentPubkey: READER, + onChannelMessage: (channelId, event) => + channelMessages.push([channelId, event.id]), + onLiveMention: () => liveMentions.push(true), + }); + return null; + } + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness), + ), + ); + }); + + assert.equal(generalCallbacks.length, 1, "general subscription installed"); + assert.equal(mentionCallbacks.length, 1, "mention subscription installed"); + + return { + channelMessages, + deliverGeneral: generalCallbacks[0], + deliverMention: mentionCallbacks[0], + liveMentions, + unmount: async () => { + await act(async () => root.unmount()); + queryClient.clear(); + }, + }; +} + +test("both subscriptions delivering one event notify the channel once", async () => { + const harness = await mountHook(); + const event = mentionAndNotifyEvent("e".repeat(64)); + + await act(async () => { + harness.deliverGeneral(event); + harness.deliverMention(event); + }); + + assert.deepEqual(harness.channelMessages, [[CHANNEL_ID, event.id]]); + assert.equal(harness.liveMentions.length, 1); + + await harness.unmount(); +}); + +test("mention-first delivery order also notifies once", async () => { + const harness = await mountHook(); + const event = mentionAndNotifyEvent("f".repeat(64)); + + await act(async () => { + harness.deliverMention(event); + harness.deliverGeneral(event); + }); + + assert.deepEqual(harness.channelMessages, [[CHANNEL_ID, event.id]]); + assert.equal(harness.liveMentions.length, 1); + + await harness.unmount(); +}); + +test("distinct events are not swallowed by the dedup set", async () => { + const harness = await mountHook(); + const first = mentionAndNotifyEvent("1".repeat(64)); + const second = mentionAndNotifyEvent("2".repeat(64)); + + await act(async () => { + harness.deliverGeneral(first); + harness.deliverMention(first); + harness.deliverGeneral(second); + }); + + assert.deepEqual(harness.channelMessages, [ + [CHANNEL_ID, first.id], + [CHANNEL_ID, second.id], + ]); + assert.equal(harness.liveMentions.length, 1); + + await harness.unmount(); +}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb905..1b2df470dd 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -135,6 +135,12 @@ export function useLiveChannelUpdates( const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; const seenMentionEventIdsRef = React.useRef(new Set()); + // One event can match both the general `#h` subscription and the `#p` + // mention subscription (a message that p-tags the reader and carries a + // notify tag, say). The relay delivers one frame per subscription, so both + // converge on handleIncomingMessage — this set makes its side effects + // (unread recording, notification callbacks) run once per event. + const seenIncomingEventIdsRef = React.useRef(new Set()); const channelsInvalidateRef = React.useRef(null); if (channelsInvalidateRef.current === null) { channelsInvalidateRef.current = createTrailingDebounce(() => { @@ -221,6 +227,10 @@ export function useLiveChannelUpdates( }); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { + if (!trackSeenEvent(seenIncomingEventIdsRef.current, event.id)) { + return; + } + const channelId = getChannelIdFromTags(event.tags); if (!channelId) { return; diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index abb49485d2..9008248683 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import { resetSelfPresenceStatus } from "@/features/presence/lib/selfPresence"; import { relayClient } from "@/shared/api/relayClient"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; import { @@ -63,6 +64,7 @@ function resetCommunityState({ resetRenderScopedReactionHydration(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetSelfPresenceStatus(); } type CommunityInitResult = diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0f7c851643..90254b60c8 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -799,20 +799,17 @@ export function HomeView({ const itemToReply = selectedItem; setIsSendingReply(true); try { - const { - mediaTags: imetaTags, - emojiTags, - mentionTags, - } = splitOutgoingTags(mediaTags); + const split = splitOutgoingTags(mediaTags); const result = await sendChannelMessage( channelId, content, parentEventId, - imetaTags, + split.mediaTags, mentionPubkeys, undefined, - emojiTags, - mentionTags, + split.emojiTags, + split.mentionTags, + split.notifyMode, ); const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; const reply: InboxReply = { @@ -838,7 +835,7 @@ export function HomeView({ id: result.eventId, parentId: result.parentEventId, rootId: result.rootEventId, - tags: emojiTags, + tags: split.emojiTags, timeLabel: formatTime(result.createdAt), }; setLocalRepliesByItemId((current) => ({ diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..d06c2b4eea 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -458,6 +458,9 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + notifyTags, + // NIP-CM: at most one marker; the Rust command re-validates the mode. + notifyMode: notify, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -468,7 +471,14 @@ export function useSendMessageMutation( // 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) { + // A notify tag also forces the REST path: the WebSocket send emits no + // extra tags, so the marker would be silently dropped. + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + notify !== null + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -482,6 +492,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + notify, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -519,6 +530,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...notifyTags, ], content: content.trim(), sig: "", diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts index 5b19a3c8f6..aa3b4f6080 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts @@ -7,9 +7,10 @@ export type Tag = string[]; /** - * Merge an event's tags with an edit's tags: imeta + NIP-30 emoji tags from the - * edit (full new attachment + custom-emoji set), all other tag kinds from the - * original. Pass-through when `editTags` is `undefined`. + * Merge an event's tags with an edit's tags: imeta from the edit (full new + * attachment set), NIP-30 emoji and NIP-CM notify tags from the edit when it + * supplies any (otherwise the original's are preserved), all other tag kinds + * from the original. Pass-through when `editTags` is `undefined`. */ export function applyEditTagOverlay( originalTags: Tag[], diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs index becd3203be..fa1a3f2094 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -22,6 +22,16 @@ * `:shortcode:` that the original rendered fine. Preserving on empty is * strictly safe: an orphaned emoji tag whose shortcode is no longer in the * body resolves nothing, so it can't cause a stale render. + * - `notify` (NIP-CM `@channel`/`@here`) tags follow the same supplied-wins, + * preserve-on-empty rule, for the same reasons. The relay accepts a notify + * tag on an edit for render continuity only (NIP-CM D9), so an edit that + * adds `@channel` to the body must be able to chip it; an edit from a + * client that doesn't emit notify tags must not strip a chip the original + * rendered fine. A supplied tag also replaces the original's, keeping the + * at-most-one-notify invariant. Preserving on empty cannot escalate a + * notification: notification and unread paths read the raw live/feed + * event, never this overlay, and an orphaned notify tag whose token is no + * longer in the body chips nothing. * - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively * from the original — the edit can't rewrite channel membership, * thread refs, or mention targets. @@ -31,13 +41,20 @@ export function applyEditTagOverlay(originalTags, editTags) { if (!editTags) return originalTags; const editEmoji = editTags.filter((t) => t[0] === "emoji"); - // imeta is always fully replaced by the edit. emoji is replaced only when - // the edit actually supplies emoji tags; otherwise the original's are kept. - const droppedFromOriginal = - editEmoji.length > 0 - ? (t) => t[0] !== "imeta" && t[0] !== "emoji" - : (t) => t[0] !== "imeta"; - const baseFromOriginal = originalTags.filter(droppedFromOriginal); + const editNotify = editTags.filter((t) => t[0] === "notify"); + // imeta is always fully replaced by the edit. emoji and notify are replaced + // only when the edit actually supplies them; otherwise the original's are + // kept. + const keptFromOriginal = (t) => + t[0] !== "imeta" && + (editEmoji.length === 0 || t[0] !== "emoji") && + (editNotify.length === 0 || t[0] !== "notify"); + const baseFromOriginal = originalTags.filter(keptFromOriginal); const overlaidFromEdit = editTags.filter((t) => t[0] === "imeta"); - return [...baseFromOriginal, ...overlaidFromEdit, ...editEmoji]; + return [ + ...baseFromOriginal, + ...overlaidFromEdit, + ...editEmoji, + ...editNotify, + ]; } diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs index 783586be68..2768032a50 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -5,6 +5,10 @@ import test from "node:test"; // post-edit cache-update (useEditMessageMutation) use. No inlined copy → no // drift risk between test expectations and production behaviour. import { applyEditTagOverlay } from "./applyEditTagOverlay.mjs"; +// The render half of the D9 continuity check: the same helpers MessageRow uses +// to turn effective tags + body text into mention chips. +import { buildMentionPattern } from "@/shared/lib/mentionPattern"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; const IMETA = (url) => ["imeta", `url ${url}`, "m image/png", "x x", "size 1"]; @@ -178,6 +182,90 @@ test("a tag-less edit still fully replaces imeta (attachments), unlike emoji", ( assert.equal(out.filter((t) => t[0] === "emoji").length, 1); }); +const NOTIFY = (mode) => ["notify", mode]; + +// NIP-CM D9: the relay accepts a notify tag on a kind-40003 edit for render +// continuity only. The chip renders off the *effective* tag set, so the tag +// has to survive the overlay or an edit that adds `@channel` renders plain. + +test("an edit's notify tag reaches the merged set (D9 render continuity)", () => { + const original = [ + ["h", "uuid"], + ["p", "mention1"], + ]; + const edit = [["h", "uuid"], ["e", "x"], NOTIFY("channel")]; + + const out = applyEditTagOverlay(original, edit); + + assert.deepEqual( + out.filter((t) => t[0] === "notify"), + [NOTIFY("channel")], + ); + assert.ok(out.some((t) => t[0] === "p" && t[1] === "mention1")); +}); + +test("a tag-less edit PRESERVES the original's notify tag", () => { + // Same cross-client hazard as emoji: first-party edit paths emit no notify + // tags, so replacing-always would strip the chip from every edited + // `@channel` message. + const original = [["h", "uuid"], NOTIFY("channel")]; + const edit = [ + ["h", "uuid"], + ["e", "x"], + ]; + + const out = applyEditTagOverlay(original, edit); + + assert.deepEqual( + out.filter((t) => t[0] === "notify"), + [NOTIFY("channel")], + ); +}); + +test("an edit's notify tag replaces the original's (one notify per event)", () => { + const original = [["h", "uuid"], NOTIFY("here")]; + const edit = [["h", "uuid"], ["e", "x"], NOTIFY("channel")]; + + const out = applyEditTagOverlay(original, edit); + + assert.deepEqual( + out.filter((t) => t[0] === "notify"), + [NOTIFY("channel")], + ); +}); + +/** Chips the renderer would produce for an effective tag set + edited body. */ +function mentionChips(tags, body) { + const { mentionNames } = resolveMentionProps(tags, {}); + return body.match(buildMentionPattern(mentionNames ?? [])) ?? []; +} + +test("an edit that adds @channel chips it, and removing the token un-chips it", () => { + const editAdding = [["h", "uuid"], ["e", "x"], NOTIFY("channel")]; + assert.deepEqual( + mentionChips( + applyEditTagOverlay([["h", "uuid"]], editAdding), + "@channel ship it", + ), + ["@channel"], + ); + + // Removal direction: the edited body drops the token while the (now + // orphaned) notify tag is preserved — and chips nothing, exactly like an + // orphaned emoji tag whose shortcode left the body. + const editRemoving = [ + ["h", "uuid"], + ["e", "x"], + ]; + assert.deepEqual( + mentionChips( + applyEditTagOverlay([["h", "uuid"], NOTIFY("channel")], editRemoving), + "ship it", + ), + [], + ); +}); + test("imeta and emoji are overlaid together from the edit", () => { const original = [ ["h", "uuid"], diff --git a/desktop/src/features/messages/lib/channelNotify.test.mjs b/desktop/src/features/messages/lib/channelNotify.test.mjs new file mode 100644 index 0000000000..b6472c97ba --- /dev/null +++ b/desktop/src/features/messages/lib/channelNotify.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildNotifyTags, + detectNotifyMode, + isReservedMentionName, + reservedMentionToken, +} from "./channelNotify.ts"; + +test("reserved tokens are matched exactly and case-insensitively", () => { + assert.equal(reservedMentionToken("channel"), "channel"); + assert.equal(reservedMentionToken("Here"), "here"); + assert.equal(reservedMentionToken(" CHANNEL "), "channel"); + assert.equal(reservedMentionToken("channels"), null); + assert.equal(reservedMentionToken("hereford"), null); + assert.equal(isReservedMentionName("HERE"), true); + assert.equal(isReservedMentionName("Herelia"), false); +}); + +test("detectNotifyMode finds either mode in ordinary prose", () => { + assert.equal(detectNotifyMode("heads up @channel"), "channel"); + assert.equal(detectNotifyMode("@here can someone look?"), "here"); + assert.equal(detectNotifyMode("**@here** please"), "here"); + assert.equal(detectNotifyMode("no mention at all"), null); + assert.equal(detectNotifyMode("mail me at foo@here.example"), null); +}); + +test("detectNotifyMode prefers @channel when both appear", () => { + assert.equal(detectNotifyMode("@here and @channel"), "channel"); + assert.equal(detectNotifyMode("@channel plus @here"), "channel"); +}); + +test("detectNotifyMode ignores tokens inside code", () => { + assert.equal(detectNotifyMode("use `@here` in a message"), null); + assert.equal(detectNotifyMode("```\n@channel\n```"), null); + assert.equal(detectNotifyMode(" @channel"), null); + // A real mention alongside a code sample still notifies. + assert.equal(detectNotifyMode("@channel see `@here`"), "channel"); +}); + +test("buildNotifyTags emits at most one marker tag", () => { + assert.deepEqual(buildNotifyTags("channel"), [["notify", "channel"]]); + assert.deepEqual(buildNotifyTags("here"), [["notify", "here"]]); + assert.deepEqual(buildNotifyTags(null), []); +}); diff --git a/desktop/src/features/messages/lib/channelNotify.ts b/desktop/src/features/messages/lib/channelNotify.ts new file mode 100644 index 0000000000..22f7c2823f --- /dev/null +++ b/desktop/src/features/messages/lib/channelNotify.ts @@ -0,0 +1,47 @@ +/** + * Compose-side helpers for channel-wide mentions (`@channel` / `@here`). + * + * `channel` and `here` are reserved mention tokens: they never resolve to a + * member pubkey, even when somebody's display name is literally "here". What + * they do produce is a single `["notify", mode]` tag on the outgoing event. + */ + +import { + NOTIFY_MODES, + NOTIFY_TAG, + type NotifyMode, +} from "@/shared/constants/notify"; +import { hasMention } from "./hasMention"; + +export type { NotifyMode }; + +/** + * Resolve a display name to the notify mode it reserves, if any. Matching is + * exact and case-insensitive: `@Channel` is reserved, `@channels` is not. + */ +export function reservedMentionToken(name: string): NotifyMode | null { + const normalized = name.trim().toLowerCase(); + return NOTIFY_MODES.find((mode) => mode === normalized) ?? null; +} + +/** Whether `name` is a reserved mention token and so never maps to a pubkey. */ +export function isReservedMentionName(name: string): boolean { + return reservedMentionToken(name) !== null; +} + +/** + * Detect the notify mode an outgoing message body asks for, or null. + * + * Uses the shared `@mention` matcher, so tokens inside code fences, indented + * blocks, or backtick spans are masked and never notify. `@channel` wins over + * `@here` when both appear: it reaches the broader set of recipients, so + * confirming it also covers everyone `@here` would have reached. + */ +export function detectNotifyMode(text: string): NotifyMode | null { + return NOTIFY_MODES.find((mode) => hasMention(text, mode)) ?? null; +} + +/** Outgoing tag set for a notify mode — empty when there is nothing to notify. */ +export function buildNotifyTags(mode: NotifyMode | null): string[][] { + return mode ? [[NOTIFY_TAG, mode]] : []; +} diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index a2edaa6f8c..4ae79b31b6 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -667,14 +667,33 @@ const MENTION_REF = [ "1111111111111111111111111111111111111111111111111111111111111111", ]; -test("splitOutgoingTags: undefined input yields three empty arrays", () => { +test("splitOutgoingTags: undefined input yields four empty arrays", () => { assert.deepEqual(splitOutgoingTags(undefined), { mediaTags: [], emojiTags: [], mentionTags: [], + notifyTags: [], + notifyMode: null, }); }); +test("splitOutgoingTags: separates the channel-wide notify marker", () => { + const notify = ["notify", "channel"]; + const { mediaTags, emojiTags, mentionTags, notifyTags, notifyMode } = + splitOutgoingTags([IMETA, notify]); + assert.deepEqual(mediaTags, [IMETA]); + assert.deepEqual(emojiTags, []); + assert.deepEqual(mentionTags, []); + assert.deepEqual(notifyTags, [notify]); + assert.equal(notifyMode, "channel"); +}); + +test("splitOutgoingTags: a malformed notify marker reads as no mention", () => { + const { notifyTags, notifyMode } = splitOutgoingTags([["notify", "all"]]); + assert.deepEqual(notifyTags, [["notify", "all"]]); + assert.equal(notifyMode, null); +}); + test("splitOutgoingTags: separates emoji tags from imeta tags", () => { const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ IMETA, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 3e16cb332d..6e67c3a89f 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -26,6 +26,11 @@ */ import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + NOTIFY_TAG, + type NotifyMode, + notifyModeFromTags, +} from "@/shared/constants/notify"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export type ImetaMedia = BlobDescriptor & { @@ -349,28 +354,43 @@ export function mergeOutgoingTags( /** * Inverse of `mergeOutgoingTags`: split a merged outgoing tag set back into - * imeta media tags, NIP-30 `["emoji", ...]` tags, and reference-only mention - * tags, so the send path can route each to its own validated Tauri arg. Emoji - * and mention tags must never ride the imeta-only `media` channel (its guard - * rejects any non-imeta prefix). Any other prefix stays with `mediaTags` — the - * imeta guard will reject it, which is the intended injection defense. + * imeta media tags, NIP-30 `["emoji", ...]` tags, reference-only mention tags, + * and the channel-wide `["notify", mode]` marker, so the send path can route + * each to its own validated Tauri arg. Emoji, mention, and notify tags must + * never ride the imeta-only `media` channel (its guard rejects any non-imeta + * prefix). Any other prefix stays with `mediaTags` — the imeta guard will + * reject it, which is the intended injection defense. + * + * `notifyMode` is the validated mode senders pass to the Tauri command; + * `notifyTags` is the raw marker, which optimistic cache echoes replay as-is. */ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + notifyTags: string[][]; + notifyMode: NotifyMode | null; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const notifyTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === NOTIFY_TAG) { + notifyTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { + mediaTags, + emojiTags, + mentionTags, + notifyTags, + notifyMode: notifyModeFromTags(notifyTags), + }; } diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 355b56dfea..8c307e4e06 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildChannelMentionCandidates, buildTeamMentionCandidates, formatTeamMention, } from "./mentionCandidates.ts"; @@ -56,6 +57,36 @@ function identity(personaId, displayName, overrides = {}) { }; } +test("channel-wide rows carry no pubkey and describe who they notify", () => { + const [channel, here] = buildChannelMentionCandidates(3); + + assert.deepEqual( + [channel.kind, channel.displayName, channel.pubkey, channel.isMember], + ["special", "channel", undefined, false], + ); + assert.equal( + channel.description, + "Notify everyone in this channel · 3 members", + ); + assert.deepEqual( + [here.kind, here.displayName, here.pubkey], + ["special", "here", undefined], + ); + assert.equal(here.description, "Notify members who are online"); +}); + +test("the member count is omitted when it is not available", () => { + for (const count of [undefined, null, 0]) { + const [channel] = buildChannelMentionCandidates(count); + assert.equal(channel.description, "Notify everyone in this channel"); + } + const [single] = buildChannelMentionCandidates(1); + assert.equal( + single.description, + "Notify everyone in this channel · 1 member", + ); +}); + test("team mentions preserve team order and prefer concrete managed agents", () => { const personas = [ persona("planner", "Planner"), diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..9e957b0f66 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -10,12 +10,14 @@ export type TeamMentionMember = { }; export type MentionCandidate = { - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; pubkey?: string; personaId?: string; teamId?: string; teamMembers?: TeamMentionMember[]; displayName: string | null; + /** Static subtitle for `special` rows; ordinary rows derive theirs. */ + description?: string; avatarUrl?: string | null; isMember: boolean; role?: ChannelRole | null; @@ -27,6 +29,40 @@ export type MentionCandidate = { isGlobalSearchResult?: boolean; }; +/** + * Autocomplete rows for the channel-wide mentions. They resolve to a notify + * tag rather than to identities, so they carry no pubkey. + * + * `memberCount` is only rendered when the caller already has the member list + * loaded; the online count for `@here` is not cheaply available, so that row + * stays count-free. Omitted entirely in DMs, where the relay rejects the tag. + */ +export function buildChannelMentionCandidates( + memberCount?: number | null, +): MentionCandidate[] { + const members = + typeof memberCount === "number" && memberCount > 0 + ? ` · ${memberCount} ${memberCount === 1 ? "member" : "members"}` + : ""; + + return [ + { + kind: "special", + displayName: "channel", + description: `Notify everyone in this channel${members}`, + isMember: false, + isAgent: false, + }, + { + kind: "special", + displayName: "here", + description: "Notify members who are online", + isMember: false, + isAgent: false, + }, + ]; +} + export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de7..39045d631e 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -4,7 +4,7 @@ export type MentionCandidateForRanking = { displayName: string | null; isAgent: boolean; isMember: boolean; - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; personaId?: string | null; personaName?: string | null; pubkey?: string; @@ -23,6 +23,8 @@ function getMentionCandidateGroupRank( candidate: MentionCandidateForRanking, activePersonaIds: ReadonlySet, ) { + // Channel-wide rows sort with members: they address the whole channel. + if (candidate.kind === "special") return 0; if (candidate.isMember) return 0; const isRunnablePersona = diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..8a9d5af293 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -6,7 +6,8 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import type { TeamMentionMember } from "./mentionCandidates"; export type MentionSuggestionCandidate = { - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; + description?: string; pubkey?: string; personaId?: string | null; teamId?: string; @@ -44,6 +45,7 @@ export function mapMentionCandidateToSuggestion(opts: { teamId: candidate.teamId, teamMembers: candidate.teamMembers, kind: candidate.kind, + description: candidate.description, displayName: label, avatarUrl: candidate.avatarUrl ?? @@ -54,6 +56,7 @@ export function mapMentionCandidateToSuggestion(opts: { isAgent: candidate.isAgent, notInChannel: candidate.kind !== "team" && + candidate.kind !== "special" && channelType !== "dm" && candidate.isMember === false, ownerLabel, diff --git a/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs new file mode 100644 index 0000000000..13a4dfd661 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveMentionPubkeys } from "./resolveMentionPubkeys.ts"; + +const ALICE = "a".repeat(64); +const HERE_MEMBER = "b".repeat(64); + +function member(displayName, pubkey, overrides = {}) { + return { displayName, pubkey, isMember: true, ...overrides }; +} + +test("selected names resolve to their pubkey", () => { + const pubkeys = resolveMentionPubkeys( + "hi @Alice", + new Map([["Alice", ALICE]]), + [], + [], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("members are matched by literal display name without a selection", () => { + const pubkeys = resolveMentionPubkeys( + "hi @Alice", + new Map(), + [], + [member("Alice", ALICE)], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("@channel and @here never resolve to a pubkey", () => { + assert.deepEqual( + resolveMentionPubkeys( + "@channel ship it", + new Map(), + [], + [member("channel", ALICE)], + ), + [], + ); + assert.deepEqual( + resolveMentionPubkeys( + "@here ship it", + new Map(), + [], + [member("here", HERE_MEMBER)], + ), + [], + ); +}); + +test("a member literally named here loses to the reserved token", () => { + const pubkeys = resolveMentionPubkeys( + "@here and @Alice", + new Map([ + ["here", HERE_MEMBER], + ["Alice", ALICE], + ]), + [], + [member("here", HERE_MEMBER)], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("non-members and duplicate pubkeys are dropped", () => { + const pubkeys = resolveMentionPubkeys( + "@Alice @Bob", + new Map(), + [], + [ + member("Alice", ALICE), + member("Alice", ALICE), + member("Bob", "c".repeat(64), { isMember: false }), + ], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("persona names already selected are not re-matched as members", () => { + const pubkeys = resolveMentionPubkeys( + "@Planner", + new Map(), + ["Planner"], + [member("Planner", ALICE)], + ); + assert.deepEqual(pubkeys, []); +}); diff --git a/desktop/src/features/messages/lib/resolveMentionPubkeys.ts b/desktop/src/features/messages/lib/resolveMentionPubkeys.ts new file mode 100644 index 0000000000..4440357017 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveMentionPubkeys.ts @@ -0,0 +1,55 @@ +/** + * Resolve the `p`-tag recipients an outgoing message body mentions. + * + * Two sources, in order: names the author picked from autocomplete (which + * carry an exact pubkey), then channel members whose display name still + * matches literally. Reserved tokens (`@channel`, `@here`) are excluded from + * both — a channel-wide mention never expands into per-member pubkeys, and a + * member who happens to be named "here" is not the target of `@here`. + * + * Extracted from `useMentions` so the precedence rules stay unit-testable. + */ + +import { isReservedMentionName } from "./channelNotify"; +import { hasMention } from "./hasMention"; + +export type MentionPubkeyCandidate = { + displayName: string | null; + isMember: boolean; + pubkey?: string; +}; + +export function resolveMentionPubkeys( + text: string, + mentionMap: ReadonlyMap, + personaMentionNames: Iterable, + candidates: readonly MentionPubkeyCandidate[], +): string[] { + const pubkeys: string[] = []; + const selectedDisplayNames = new Set( + [...mentionMap.keys(), ...personaMentionNames].map((name) => + name.trim().toLowerCase(), + ), + ); + + for (const [displayName, pubkey] of mentionMap) { + if (isReservedMentionName(displayName)) continue; + if (hasMention(text, displayName)) { + pubkeys.push(pubkey); + } + } + + for (const candidate of candidates) { + if (!candidate.pubkey) continue; + if (!candidate.isMember) continue; + if (pubkeys.includes(candidate.pubkey)) continue; + const name = candidate.displayName; + if (!name || isReservedMentionName(name)) continue; + if (selectedDisplayNames.has(name.trim().toLowerCase())) continue; + if (hasMention(text, name)) { + pubkeys.push(candidate.pubkey); + } + } + + return [...new Set(pubkeys)]; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..9a6dd70c55 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -41,12 +41,15 @@ import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { + buildChannelMentionCandidates, buildTeamMentionCandidates, formatTeamMention, globalSearchIdentityKey, type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; +import { reservedMentionToken } from "./channelNotify"; +import { resolveMentionPubkeys } from "./resolveMentionPubkeys"; const MENTION_DEBOUNCE_MS = 120; const MENTION_SUGGESTION_LIMIT = 50; export type PersonaMentionTarget = { @@ -439,8 +442,18 @@ export function useMentions( personasQuery.data ?? [], mentionCandidates, ), + // Channel-wide mentions are rejected by the relay in DMs. + ...(options?.channelType === "dm" + ? [] + : buildChannelMentionCandidates(members?.length)), + ], + [ + members?.length, + mentionCandidates, + options?.channelType, + personasQuery.data, + teamsQuery.data, ], - [mentionCandidates, personasQuery.data, teamsQuery.data], ); const ownerPubkeys = React.useMemo( @@ -614,6 +627,26 @@ export function useMentions( debounceTimerRef.current = null; } + const startIndex = + flushedMentionStartIndexRef.current ?? mentionStartIndex; + flushedMentionStartIndexRef.current = null; + setMentionQuery(null); + setMentionSelectedIndex(0); + + // Reserved tokens insert literally and notify via the event's notify + // tag; they never enter the pubkey mention map (D16 precedence). + const reserved = reservedMentionToken(suggestion.displayName); + if (reserved) { + setSelectedMentionNames((current) => + appendUniqueName(current, reserved), + ); + return { + replaceFromOffset: startIndex, + replaceToOffset: selectionEnd, + insertText: `@${reserved} `, + }; + } + const displayName = suggestion.displayName; const teamMembers = suggestion.kind === "team" ? suggestion.teamMembers : null; @@ -664,12 +697,7 @@ export function useMentions( } trimMapToSize(mentions, 200); trimMapToSize(personaMentions, 200); - setMentionQuery(null); - setMentionSelectedIndex(0); - const startIndex = - flushedMentionStartIndexRef.current ?? mentionStartIndex; - flushedMentionStartIndexRef.current = null; return { replaceFromOffset: startIndex, replaceToOffset: selectionEnd, @@ -792,42 +820,13 @@ export function useMentions( ); const extractMentionPubkeys = React.useCallback( - (text: string): string[] => { - const pubkeys: string[] = []; - const selectedDisplayNames = new Set( - [ - ...mentionMapRef.current.keys(), - ...personaMentionMapRef.current.keys(), - ].map((name) => name.trim().toLowerCase()), - ); - - for (const [displayName, pubkey] of mentionMapRef.current) { - if (hasMention(text, displayName)) { - pubkeys.push(pubkey); - } - } - - for (const candidate of mentionCandidates) { - if (!candidate.pubkey) { - continue; - } - if (!candidate.isMember) { - continue; - } - if (pubkeys.includes(candidate.pubkey)) { - continue; - } - const name = candidate.displayName; - if (name && selectedDisplayNames.has(name.trim().toLowerCase())) { - continue; - } - if (name && hasMention(text, name)) { - pubkeys.push(candidate.pubkey); - } - } - - return [...new Set(pubkeys)]; - }, + (text: string): string[] => + resolveMentionPubkeys( + text, + mentionMapRef.current, + personaMentionMapRef.current.keys(), + mentionCandidates, + ), [mentionCandidates], ); diff --git a/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx b/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx new file mode 100644 index 0000000000..96953f4be8 --- /dev/null +++ b/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx @@ -0,0 +1,81 @@ +import type { NotifyMode } from "@/features/messages/lib/channelNotify"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type ChannelNotifyDialogProps = { + isSendPending: boolean; + /** Channel member count, used to size the `@channel` prompt. */ + memberCount: number; + mode: NotifyMode | null; + onCancel: () => void; + onConfirm: () => void; +}; + +/** + * Confirmation shown before a message that carries `@channel` or `@here` is + * sent. Mirrors the non-member mention prompt's seam in the composer. + */ +export function ChannelNotifyDialog({ + isSendPending, + memberCount, + mode, + onCancel, + onConfirm, +}: ChannelNotifyDialogProps) { + const isChannel = mode === "channel"; + + return ( + { + if (!nextOpen) { + onCancel(); + } + }} + open={mode !== null} + > + + + + {isChannel + ? memberCount > 0 + ? `Notify all ${memberCount} members?` + : "Notify everyone in this channel?" + : "Notify members who are online?"} + + + {isChannel + ? "@channel notifies every member of this channel, even when they are away. Members who muted the channel are not notified." + : "@here notifies only the members who are online right now. Members who muted the channel are not notified."} + + + + + + + + + ); +} diff --git a/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx b/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx new file mode 100644 index 0000000000..3f539cc6a7 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx @@ -0,0 +1,41 @@ +import { ChannelNotifyDialog } from "./ChannelNotifyDialog"; +import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; +import type { UseMentionSendFlowResult } from "./useMentionSendFlow"; + +type ComposerMentionDialogsProps = { + /** Channel member count, used to size the `@channel` prompt. */ + memberCount: number; + sendFlow: UseMentionSendFlowResult; +}; + +/** + * The prompts the send flow can interpose before a message goes out, in the + * order the flow raises them: confirm a channel-wide mention, then decide what + * to do about mentioned non-members. At most one is open at a time. + */ +export function ComposerMentionDialogs({ + memberCount, + sendFlow, +}: ComposerMentionDialogsProps) { + return ( + <> + + + + + ); +} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..783c1f3ba8 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Bot, Users } from "lucide-react"; +import { Bot, Megaphone, Users } from "lucide-react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; import { Badge } from "@/shared/ui/badge"; @@ -18,8 +18,10 @@ export type MentionSuggestion = { personaId?: string; teamId?: string; teamMembers?: TeamMentionMember[]; - kind?: "identity" | "persona" | "team"; + kind?: "identity" | "persona" | "team" | "special"; displayName: string; + /** Static subtitle, used by the channel-wide (`special`) rows. */ + description?: string; avatarUrl?: string | null; isAgent?: boolean; notInChannel?: boolean; @@ -101,6 +103,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? suggestion.displayName; const agentLabel = "agent"; + // Channel-wide rows read as the token the author is inserting. + const label = + suggestion.kind === "special" + ? `@${suggestion.displayName}` + : suggestion.displayName; const hasNameCollision = (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; const collisionNpub = @@ -125,9 +132,13 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ tabIndex={-1} type="button" > - {suggestion.kind === "team" ? ( + {suggestion.kind === "team" || suggestion.kind === "special" ? ( - ) : ( - {suggestion.displayName} + {label} - {suggestion.kind === "team" || + {suggestion.description || + suggestion.kind === "team" || suggestion.isAgent || suggestion.role || suggestion.ownerLabel || @@ -157,7 +169,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ : "text-muted-foreground", )} > - {suggestion.kind === "team" ? ( + {suggestion.description ? ( + + {suggestion.description} + + ) : suggestion.kind === "team" ? (