diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..77fda5153cc 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -9,7 +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, strip_code_regions, MENTION_CAP, + contains_all_mention, extract_at_mentions_with_known, extract_nostr_uris, + resolve_all_mention_pubkeys, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -220,11 +221,18 @@ async fn resolve_content_mentions( } let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect(); - let names = extract_at_mentions_with_known(&stripped, &known_refs); + let names = filter_reserved_all_name(extract_at_mentions_with_known(&stripped, &known_refs)); + if names.is_empty() { + return Ok((member_pubkeys, vec![])); + } let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?; Ok((member_pubkeys, resolved)) } +fn filter_reserved_all_name(names: Vec) -> Vec { + names.into_iter().filter(|name| name != "all").collect() +} + fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { let mut normalized = Vec::new(); for value in values { @@ -248,6 +256,20 @@ fn merge_message_mentions( uri_pubkeys: &[String], auto_resolved: &[String], ) -> Result, CliError> { + let mentions = merge_message_mentions_unchecked(explicit, uri_pubkeys, auto_resolved); + if mentions.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many unique message mentions (max {MENTION_CAP})" + ))); + } + Ok(mentions) +} + +fn merge_message_mentions_unchecked( + explicit: &[String], + uri_pubkeys: &[String], + auto_resolved: &[String], +) -> Vec { let mut mentions = Vec::new(); for pubkey in explicit .iter() @@ -258,12 +280,18 @@ fn merge_message_mentions( mentions.push(pubkey.clone()); } } - if mentions.len() > MENTION_CAP { - return Err(CliError::Usage(format!( - "too many unique message mentions (max {MENTION_CAP})" - ))); - } - Ok(mentions) + mentions +} + +fn finalize_message_mentions( + content: &str, + existing_mentions: &[String], + member_pubkeys: &[String], + sender_pubkey: &str, +) -> Result, CliError> { + resolve_all_mention_pubkeys(content, existing_mentions, member_pubkeys, sender_pubkey) + .map(|resolved| resolved.unwrap_or_else(|| existing_mentions.to_vec())) + .map_err(|error| CliError::Usage(error.to_string())) } fn missing_members(mentions: &[String], members: &[String]) -> Vec { @@ -596,7 +624,14 @@ pub async fn cmd_send_message( let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); let (member_pubkeys, auto_resolved) = resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; - let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + let base_mentions = if contains_all_mention(&p.content) { + merge_message_mentions_unchecked(&explicit_mentions, &uri_pubkeys, &auto_resolved) + } else { + merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)? + }; + let sender_pubkey = client.keys().public_key().to_hex(); + let mention_pubkeys = + finalize_message_mentions(&p.content, &base_mentions, &member_pubkeys, &sender_pubkey)?; let missing = missing_members(&mention_pubkeys, &member_pubkeys); if !missing.is_empty() { @@ -993,9 +1028,9 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + event_mention_pubkeys, filter_reserved_all_name, finalize_message_mentions, + find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1183,6 +1218,53 @@ mod tests { assert!(names.is_empty()); } + #[test] + fn cli_reserved_all_precedence_removes_profile_name_resolution() { + let names = vec!["all".to_string(), "alice".to_string()]; + assert_eq!(filter_reserved_all_name(names), vec!["alice"]); + } + + #[test] + fn cli_finalize_all_mentions_uses_sdk_resolution() { + let existing = vec![PK_VALID_A.to_ascii_uppercase()]; + let members = vec![ + PK_VALID_A.to_string(), + PK_VALID_B.to_string(), + PK_VALID_C.to_string(), + ]; + + let mentions = finalize_message_mentions("ping @ALL", &existing, &members, PK_VALID_B) + .expect("group expansion should fit"); + + assert_eq!(mentions, vec![PK_VALID_A, PK_VALID_C]); + } + + #[test] + fn cli_finalize_without_all_preserves_existing_mentions() { + let existing = vec![PK_VALID_A.to_string()]; + let mentions = finalize_message_mentions( + "ping @alice", + &existing, + &[PK_VALID_B.to_string()], + PK_VALID_C, + ) + .expect("ordinary mentions should be unchanged"); + assert_eq!(mentions, existing); + } + + #[test] + fn cli_finalize_all_mentions_reports_final_unique_count() { + let members: Vec = (0..=buzz_sdk::mentions::MENTION_CAP) + .map(|index| format!("{index:064x}")) + .collect(); + + let error = finalize_message_mentions("@all", &[], &members, PK_VALID_A) + .expect_err("one over the cap should fail"); + + assert!(error.to_string().contains("51 unique recipients")); + assert!(error.to_string().contains("max 50")); + } + #[test] fn parse_member_pubkeys_ignores_non_p_tags() { let event = json!({ diff --git a/crates/buzz-sdk/src/mentions.rs b/crates/buzz-sdk/src/mentions.rs index e59580c7ae1..4f23d263170 100644 --- a/crates/buzz-sdk/src/mentions.rs +++ b/crates/buzz-sdk/src/mentions.rs @@ -37,6 +37,28 @@ use nostr::{FromBech32, PublicKey}; /// inline implementation. pub const MENTION_CAP: usize = 50; +/// The final unique recipient set for an `@all` expansion exceeded +/// [`MENTION_CAP`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AllMentionOverflow { + /// Number of unique recipients after deduplication and sender exclusion. + pub count: usize, + /// Maximum number of mention p-tags allowed on one message. + pub max: usize, +} + +impl std::fmt::Display for AllMentionOverflow { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "@all resolves to {} unique recipients (max {})", + self.count, self.max + ) + } +} + +impl std::error::Error for AllMentionOverflow {} + /// A channel-member profile, as needed for name matching. /// /// `pubkey` is the lowercase hex public key. `content_json` is the raw @@ -236,6 +258,46 @@ pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: Option<&str> .collect() } +/// Return whether `content` contains an active, exact `@all` mention. +/// +/// Matching is case-insensitive and uses the same token boundaries as +/// [`extract_at_names`]. Inline and fenced code are removed before scanning. +pub fn contains_all_mention(content: &str) -> bool { + let stripped = strip_code_regions(content); + extract_at_names(&stripped).iter().any(|name| name == "all") +} + +/// Expand an active `@all` mention into the final unique recipient set. +/// +/// Existing explicit/name/URI mentions retain priority, followed by the fresh +/// channel membership order. Pubkeys are lowercased, duplicates and the sender +/// are removed, and the cap is checked only after that normalization. `None` +/// means `content` did not contain an active reserved token and callers should +/// keep their existing send path unchanged. +pub fn resolve_all_mention_pubkeys( + content: &str, + existing_mentions: &[String], + fresh_member_pubkeys: &[String], + sender_pubkey: &str, +) -> Result>, AllMentionOverflow> { + if !contains_all_mention(content) { + return Ok(None); + } + + let mut combined = Vec::with_capacity(existing_mentions.len() + fresh_member_pubkeys.len()); + combined.extend_from_slice(existing_mentions); + combined.extend_from_slice(fresh_member_pubkeys); + let recipients = normalize_mention_pubkeys(&combined, Some(sender_pubkey)); + if recipients.len() > MENTION_CAP { + return Err(AllMentionOverflow { + count: recipients.len(), + max: MENTION_CAP, + }); + } + + Ok(Some(recipients)) +} + /// Remove fenced code blocks and inline code spans from content. /// /// Returns a copy of `content` with ` ```…``` ` blocks and `` `…` `` spans @@ -817,4 +879,66 @@ mod tests { let result = extract_nostr_uris(&content); assert_eq!(result, vec![TEST_HEX1]); } + + #[test] + fn all_mention_detects_exact_token_case_insensitively() { + let cases: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/at_all_detection.json")) + .expect("shared detection corpus must be valid JSON"); + + for case in cases.as_array().expect("corpus root must be an array") { + let name = case["name"].as_str().expect("case must have a name"); + let content = case["content"] + .as_str() + .expect("case must have string content"); + let active = case["active"] + .as_bool() + .expect("case must have boolean active state"); + assert_eq!(contains_all_mention(content), active, "{name}"); + } + } + + #[test] + fn all_mention_resolution_merges_normalizes_dedupes_and_excludes_sender() { + let existing = vec!["EXPLICIT".to_string(), "member-a".to_string()]; + let members = vec![ + "SENDER".to_string(), + "MEMBER-A".to_string(), + "member-b".to_string(), + "MEMBER-B".to_string(), + ]; + + let resolved = resolve_all_mention_pubkeys("hello @ALL", &existing, &members, "sender") + .expect("resolution should fit") + .expect("reserved token should be present"); + + assert_eq!(resolved, vec!["explicit", "member-a", "member-b"]); + } + + #[test] + fn all_mention_resolution_returns_none_when_token_is_absent() { + let resolved = resolve_all_mention_pubkeys( + "ordinary message", + &["explicit".to_string()], + &["member".to_string()], + "sender", + ) + .expect("absence is not an error"); + assert_eq!(resolved, None); + } + + #[test] + fn all_mention_cap_counts_final_unique_recipients() { + let at_cap: Vec = (0..MENTION_CAP).map(|i| format!("member-{i}")).collect(); + let resolved = resolve_all_mention_pubkeys("@all", &[], &at_cap, "sender") + .expect("exactly the cap should pass") + .expect("reserved token should be present"); + assert_eq!(resolved.len(), MENTION_CAP); + + let over_cap: Vec = (0..=MENTION_CAP).map(|i| format!("member-{i}")).collect(); + let error = resolve_all_mention_pubkeys("@all", &[], &over_cap, "sender") + .expect_err("one over the cap should fail"); + assert_eq!(error.count, MENTION_CAP + 1); + assert_eq!(error.max, MENTION_CAP); + } } diff --git a/crates/buzz-sdk/tests/fixtures/at_all_detection.json b/crates/buzz-sdk/tests/fixtures/at_all_detection.json new file mode 100644 index 00000000000..86b87f23ddf --- /dev/null +++ b/crates/buzz-sdk/tests/fixtures/at_all_detection.json @@ -0,0 +1,47 @@ +[ + { + "name": "lowercase top-level token", + "content": "@all please review", + "active": true + }, + { + "name": "uppercase token", + "content": "ping @ALL now", + "active": true + }, + { + "name": "mixed-case token with punctuation", + "content": "ping @AlL, now", + "active": true + }, + { + "name": "longer mention token", + "content": "@alligator", + "active": false + }, + { + "name": "email-like text", + "content": "user@all", + "active": false + }, + { + "name": "inline code only", + "content": "show `@all` literally", + "active": false + }, + { + "name": "fenced code only", + "content": "before\n```text\n@all\n```\nafter", + "active": false + }, + { + "name": "code example followed by active token", + "content": "`@all` then @all", + "active": true + }, + { + "name": "ordinary plain text", + "content": "plain text", + "active": false + } +] diff --git a/desktop/src-tauri/src/commands/message_mentions.rs b/desktop/src-tauri/src/commands/message_mentions.rs new file mode 100644 index 00000000000..e8f59011af7 --- /dev/null +++ b/desktop/src-tauri/src/commands/message_mentions.rs @@ -0,0 +1,95 @@ +use nostr::Keys; + +use crate::{app_state::AppState, nostr_convert, relay::query_relay}; + +pub(super) fn resolve( + content: &str, + existing_mentions: &[String], + fresh_member_pubkeys: &[String], + sender_pubkey: &str, +) -> Result, String> { + buzz_sdk_pkg::mentions::resolve_all_mention_pubkeys( + content, + existing_mentions, + fresh_member_pubkeys, + sender_pubkey, + ) + .map(|resolved| resolved.unwrap_or_else(|| existing_mentions.to_vec())) + .map_err(|error| error.to_string()) +} + +async fn from_relay( + state: &AppState, + channel_id: &str, + content: &str, + existing_mentions: Option>, + sender_pubkey: &str, +) -> Result, String> { + let existing_mentions = existing_mentions.unwrap_or_default(); + if !buzz_sdk_pkg::mentions::contains_all_mention(content) { + return Ok(existing_mentions); + } + + let membership_events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1, + })], + ) + .await?; + let membership = membership_events + .first() + .map(nostr_convert::channel_members_from_event) + .transpose()? + .ok_or_else(|| "channel members not found for @all mention".to_string())?; + let member_pubkeys: Vec = membership + .members + .into_iter() + .map(|member| member.pubkey) + .collect(); + + resolve(content, &existing_mentions, &member_pubkeys, sender_pubkey) +} + +pub(super) async fn human( + state: &AppState, + channel_id: &str, + content: &str, + existing_mentions: Option>, +) -> Result, String> { + let sender_pubkey = { + state + .keys + .lock() + .map_err(|error| error.to_string())? + .public_key() + .to_hex() + }; + from_relay( + state, + channel_id, + content, + existing_mentions, + &sender_pubkey, + ) + .await +} + +pub(super) async fn agent( + state: &AppState, + channel_id: &str, + content: &str, + existing_mentions: Option>, + keys: &Keys, +) -> Result, String> { + from_relay( + state, + channel_id, + content, + existing_mentions, + &keys.public_key().to_hex(), + ) + .await +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 168be1ecf60..c22b6bca6c1 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -1,8 +1,8 @@ use nostr::{Event, EventId, Keys, PublicKey}; use tauri::{AppHandle, State}; - mod forum; - +#[path = "message_mentions.rs"] +mod mentions; use forum::{ apply_link_preview_suppression, fetch_agent_owner_pubkeys, link_preview_suppression_targets, }; @@ -497,7 +497,7 @@ pub async fn send_channel_message( ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let mentions = mention_pubkeys.unwrap_or_default(); + let mentions = mentions::human(&state, &channel_id, &content, mention_pubkeys).await?; let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); @@ -812,7 +812,7 @@ pub async fn send_managed_agent_channel_message( client_tags.push(vec!["client".to_string(), marker.to_string()]); } } - let mentions = mention_pubkeys.unwrap_or_default(); + let mentions = mentions::agent(&state, &channel_id, trimmed, mention_pubkeys, &keys).await?; let builder = build_managed_agent_channel_message( channel_uuid, trimmed, diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index c0ad03d936b..762e2fadb4b 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -85,6 +85,152 @@ fn managed_agent_message_builder_rejects_invalid_mentions() { .expect_err("invalid mentions should fail"); assert!(error.contains("pubkey must be a 64-character hex string")); } + +fn p_tag_pubkeys(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).cloned()) + .flatten() + }) + .collect() +} + +#[test] +fn desktop_all_resolution_feeds_each_interactive_builder() { + let sender = Keys::generate(); + let first = Keys::generate().public_key().to_hex(); + let second = Keys::generate().public_key().to_hex(); + let members = vec![sender.public_key().to_hex(), first.clone(), second.clone()]; + let resolved = mentions::resolve( + "review @ALL", + std::slice::from_ref(&first), + &members, + &sender.public_key().to_hex(), + ) + .expect("group expansion should fit"); + let mention_refs: Vec<&str> = resolved.iter().map(String::as_str).collect(); + let channel_id = uuid::Uuid::new_v4(); + let root = EventId::from_hex(&"1".repeat(64)).expect("valid event id"); + let parent = EventId::from_hex(&"2".repeat(64)).expect("valid event id"); + let thread_ref = events::ThreadRef { + root_event_id: root, + parent_event_id: parent, + }; + + let events = [ + events::build_message( + channel_id, + "review @ALL", + None, + &mention_refs, + &[], + &[], + &[], + &[], + None, + "https://relay.example", + ) + .expect("stream builder should accept resolved mentions") + .sign_with_keys(&sender) + .expect("stream message should sign"), + events::build_forum_post(channel_id, "review @ALL", &mention_refs, &[], &[]) + .expect("forum-post builder should accept resolved mentions") + .sign_with_keys(&sender) + .expect("forum post should sign"), + events::build_forum_comment( + channel_id, + "review @ALL", + &thread_ref, + &mention_refs, + &[], + &[], + ) + .expect("forum-comment builder should accept resolved mentions") + .sign_with_keys(&sender) + .expect("forum comment should sign"), + ]; + + for event in events { + assert_eq!(p_tag_pubkeys(&event), vec![first.clone(), second.clone()]); + } +} + +#[test] +fn desktop_all_resolution_uses_managed_agent_signer_for_self_exclusion() { + let agent = Keys::generate(); + let teammate = Keys::generate().public_key().to_hex(); + let members = vec![agent.public_key().to_hex(), teammate.clone()]; + let resolved = mentions::resolve("@all", &[], &members, &agent.public_key().to_hex()) + .expect("group expansion should fit"); + + let event = + build_managed_agent_channel_message(uuid::Uuid::new_v4(), "@all", None, &resolved, &[]) + .expect("managed-agent builder should accept resolved mentions") + .sign_with_keys(&agent) + .expect("managed-agent message should sign"); + + assert_eq!(p_tag_pubkeys(&event), vec![teammate]); +} + +#[test] +fn desktop_without_all_preserves_frontend_mentions() { + let existing = vec![Keys::generate().public_key().to_hex()]; + let resolved = mentions::resolve( + "ordinary @name", + &existing, + &[Keys::generate().public_key().to_hex()], + &Keys::generate().public_key().to_hex(), + ) + .expect("ordinary mentions should remain unchanged"); + assert_eq!(resolved, existing); +} + +#[test] +fn edit_literal_all_stays_literal_and_explicit_mentions_still_work() { + let signer = Keys::generate(); + let channel_id = uuid::Uuid::new_v4(); + let target = EventId::from_hex(&"3".repeat(64)).expect("valid event id"); + + let literal = events::build_message_edit( + channel_id, + target, + "@all", + events::MessageEditTags { + media: &[], + custom_emoji: &[], + mentions: &[], + mention_refs: None, + }, + false, + ) + .expect("literal edit should build") + .sign_with_keys(&signer) + .expect("literal edit should sign"); + assert!(p_tag_pubkeys(&literal).is_empty()); + + let mentioned = Keys::generate().public_key().to_hex(); + let explicit = events::build_message_edit( + channel_id, + target, + "@all plus explicit", + events::MessageEditTags { + media: &[], + custom_emoji: &[], + mentions: &[mentioned.as_str()], + mention_refs: None, + }, + false, + ) + .expect("explicit edit should build") + .sign_with_keys(&signer) + .expect("explicit edit should sign"); + assert_eq!(p_tag_pubkeys(&explicit), vec![mentioned]); +} + #[test] fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() { let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"), None, None, None); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d4..255008c5059 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -89,6 +89,8 @@ Write commands are unaffected. `--format json` (default) returns full fields. **Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. +**Channel-wide mention:** `@all` is a reserved, case-insensitive token that expands at send time to every current channel member except the sender. Resolution always uses a fresh membership query; query failure or more than 50 final unique recipients stops the send without truncation. Inline and fenced code suppress the token. A member whose display name is “all” never captures it; address that person with an explicit `--mention` pubkey. Message edits and workflow-authored messages do not expand `@all` in this pilot. + ```bash buzz messages send --channel \ --content "@Alice check this" --mention diff --git a/desktop/src/features/messages/hooks.test.mjs b/desktop/src/features/messages/hooks.test.mjs new file mode 100644 index 00000000000..12a38d9661f --- /dev/null +++ b/desktop/src/features/messages/hooks.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { shouldUseNativeAllMentionTransport } from "./hooks.ts"; + +const detectionCases = JSON.parse( + await readFile( + new URL( + "../../../../crates/buzz-sdk/tests/fixtures/at_all_detection.json", + import.meta.url, + ), + "utf8", + ), +); + +test("the transport gate has no false negatives against SDK-positive cases", () => { + for (const detectionCase of detectionCases.filter(({ active }) => active)) { + assert.equal( + shouldUseNativeAllMentionTransport(detectionCase.content), + true, + detectionCase.name, + ); + } +}); + +test("ordinary plain text retains the WebSocket-eligible path", () => { + assert.equal(shouldUseNativeAllMentionTransport("plain text"), false); +}); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf8..294a1afe47f 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -85,6 +85,12 @@ type MessageQueryContext = { const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); const CHANNEL_AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS); +// Transport-only sentinel: false positives are safe because Rust decides +// whether the reserved token is active and computes all recipient semantics. +export function shouldUseNativeAllMentionTransport(content: string): boolean { + return content.toLowerCase().includes("@all"); +} + export function createOptimisticMessage( channelId: string, content: string, @@ -532,7 +538,8 @@ export function useSendMessageMutation( parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || - linkPreviewTags.length > 0 + linkPreviewTags.length > 0 || + shouldUseNativeAllMentionTransport(content) ) { const cachedMessages = queryClient.getQueryData( diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 355b56dfea6..6912af08aed 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { buildTeamMentionCandidates, formatTeamMention, + withAllMentionCandidate, } from "./mentionCandidates.ts"; function persona(id, displayName, isActive = true) { @@ -170,3 +171,39 @@ test("teams with identity and persona display-name collisions are not suggested" [], ); }); + +test("reserved all candidate is first and suppresses case-insensitive collisions", () => { + const candidates = [ + identity(null, "All", { + isAgent: false, + isMember: true, + pubkey: "1".repeat(64), + }), + identity(null, "Alice", { + isAgent: false, + isMember: true, + pubkey: "2".repeat(64), + }), + { + kind: "team", + teamId: "all-team", + teamMembers: [], + displayName: "aLL", + isMember: false, + isAgent: true, + }, + ]; + + const result = withAllMentionCandidate(candidates); + + assert.deepEqual(result[0], { + kind: "identity", + displayName: "all", + isMember: true, + isAgent: false, + }); + assert.deepEqual( + result.slice(1).map((candidate) => candidate.displayName), + ["Alice"], + ); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 3ad358a0d66..f4eac998a2b 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -73,6 +73,41 @@ export function globalSearchIdentityKey(candidate: MentionCandidate) { return `global-person:${label}:${secondaryLabel}`; } +/** + * Prepend the presentation-only reserved `@all` candidate and remove any + * case-insensitive display-name collision. Delivery resolves the literal token + * in Rust; this candidate deliberately carries no pubkey. + */ +export function withAllMentionCandidate( + candidates: readonly MentionCandidate[], +): MentionCandidate[] { + return [ + { + kind: "identity", + displayName: "all", + isMember: true, + isAgent: false, + }, + ...candidates.filter( + (candidate) => candidate.displayName?.trim().toLowerCase() !== "all", + ), + ]; +} + +/** Compose the channel-aware reserved candidate with ordinary and team entries. */ +export function buildChannelMentionCandidates( + candidates: readonly MentionCandidate[], + teams: readonly AgentTeam[], + personas: AgentPersona[], + includeAll: boolean, +): MentionCandidate[] { + const combined = [ + ...candidates, + ...buildTeamMentionCandidates(teams, personas, candidates), + ]; + return includeAll ? withAllMentionCandidate(combined) : combined; +} + function findTeamMemberTarget( persona: AgentPersona, candidates: readonly MentionCandidate[], diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index fbf59e4c958..e6f1497f762 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -50,7 +50,7 @@ import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { appendUniqueName, - buildTeamMentionCandidates, + buildChannelMentionCandidates, formatSearchUserDisplayName, formatSearchUserSecondaryLabel, formatTeamMention, @@ -439,16 +439,16 @@ export function useMentions( () => getAdmittedAgentPubkeys(mentionCandidates), [mentionCandidates], ); + const includeAll = ["stream", "forum"].includes(options?.channelType ?? ""); const mentionCandidatesWithTeams = React.useMemo( - () => [ - ...mentionCandidates, - ...buildTeamMentionCandidates( + () => + buildChannelMentionCandidates( + mentionCandidates, teamsQuery.data ?? [], personasQuery.data ?? [], - mentionCandidates, + includeAll, ), - ], - [mentionCandidates, personasQuery.data, teamsQuery.data], + [includeAll, mentionCandidates, personasQuery.data, teamsQuery.data], ); const ownerPubkeys = React.useMemo( () => [ diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 801000189f6..361fe8d284e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -8,6 +8,7 @@ import { const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const ALL_ROSTER_UNAVAILABLE_ERROR = "Fresh channel membership is unavailable."; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -143,6 +144,29 @@ async function readOutgoingMentionPubkeys( }, content); } +async function hasOutgoingWebsocketContent( + page: import("@playwright/test").Page, + content: string, +) { + return page.evaluate((expectedContent) => { + const entries = window.__BUZZ_E2E_COMMAND_LOG__ ?? []; + return entries.some((entry) => { + if (entry.command !== "plugin:websocket|send") return false; + const data = ( + entry.payload as { message?: { data?: string } } | undefined + )?.message?.data; + if (!data) return false; + + try { + const frame = JSON.parse(data) as [string, { content?: string }]; + return frame[0] === "EVENT" && frame[1]?.content === expectedContent; + } catch { + return false; + } + }); + }, content); +} + function commandCount(commands: string[], command: string) { return commands.filter((entry) => entry === command).length; } @@ -344,6 +368,121 @@ test("relay-only shared agents emit an outbound mention tag when selected", asyn .toContain(TEST_IDENTITIES.alice.pubkey); }); +test("plain top-level @all uses Tauri and reconciles one stable local echo", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const suffix = `transport-check-${Date.now()}`; + const content = `@all ${suffix}`; + const input = page.getByTestId("message-input"); + await input.fill("@al"); + await autocomplete(page).getByText("all", { exact: true }).click(); + await expect + .poll(() => input.evaluate((element) => element.textContent)) + .toBe("@all "); + await page.keyboard.type(suffix); + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(async () => + (await readCommandPayloadLog(page)).some( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string })?.content === content, + ), + ) + .toBe(true); + expect(await hasOutgoingWebsocketContent(page, content)).toBe(false); + + await waitForTimelineSettled(page); + const sentRows = page.getByTestId("message-row").filter({ hasText: content }); + await expect(sentRows).toHaveCount(1); + await expect(sentRows.first()).toContainText(content); + await expect(sentRows.first().getByTestId("message-send-status")).toHaveCount( + 0, + ); +}); + +test("failed @all roster resolution restores the complete draft without an event", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.evaluate((sendError) => { + const tauriWindow = window as Window & { + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: unknown, + options?: unknown, + ) => Promise; + }; + }; + const originalInvoke = tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!originalInvoke || !tauriWindow.__TAURI_INTERNALS__) { + throw new Error("Mock invoke bridge is unavailable"); + } + tauriWindow.__TAURI_INTERNALS__.invoke = (command, payload, options) => { + if (command === "send_channel_message") { + return Promise.reject(new Error(sendError)); + } + return originalInvoke(command, payload, options); + }; + }, ALL_ROSTER_UNAVAILABLE_ERROR); + + const content = `@all preserve-this-draft-${Date.now()}`; + const input = page.getByTestId("message-input"); + await input.fill(content); + await page.getByTestId("send-message").click(); + + await expect(input).toHaveText(content); + await expect( + page.getByTestId("message-row").filter({ hasText: content }), + ).toHaveCount(0); + await expect(page.getByText("Sending", { exact: true })).toHaveCount(0); +}); + +test("ordinary plain top-level text retains the WebSocket send path", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const content = `ordinary transport-check-${Date.now()}`; + await page.getByTestId("message-input").fill(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => hasOutgoingWebsocketContent(page, content)) + .toBe(true); + expect( + (await readCommandPayloadLog(page)).some( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string })?.content === content, + ), + ).toBe(false); +}); + +test("direct messages do not offer the reserved all candidate", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-bob-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); + + await page.getByTestId("message-input").fill("@al"); + await expect( + autocomplete(page).getByText("all", { exact: true }), + ).toHaveCount(0); +}); + test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, }) => {