diff --git a/VISION.md b/VISION.md index 900e5a9475..66a106bdeb 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by member | Any member | +| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..9d15fccfc8 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -371,7 +371,9 @@ async fn acquire_channel_membership_lock( /// Role enforcement: /// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of /// what the caller passes — callers cannot self-assign elevated roles. -/// - Private channels: requires an `invited_by` who is an active owner/admin. +/// - Private channels: requires an `invited_by` who is an active owner/admin, the channel +/// creator bootstrapping their own first membership, or the target adding themselves +/// (idempotent re-add — an active member's *role* still cannot change this way). /// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin, /// even on open channels. /// @@ -419,10 +421,14 @@ pub async fn add_member( DbError::InvalidData(format!("invalid role in database: {inviter_role_str}")) })?; - // Any member can invite others, but only owners/admins may grant elevated roles. - if role.is_elevated() && !inviter_role.is_elevated() { + // Only owners/admins may extend private-channel access to another + // identity. `inviter == pubkey` keeps a member's own idempotent + // re-add working; it is not a role-escalation hole, because the + // active-role-change guard below still rejects a self-targeted + // promotion from any non-elevated caller. + if !inviter_role.is_elevated() && inviter != pubkey { return Err(DbError::AccessDenied( - "only owners/admins may grant elevated roles".to_string(), + "only owners/admins may add private-channel members".to_string(), )); } } diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..98f8a9aa84 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -355,28 +355,28 @@ pub async fn validate_admin_event( .iter() .find(|m| m.pubkey == actor_bytes) .and_then(|m| m.role.parse().ok()); - - // PUT_USER: open channels allow any authenticated user; private channels - // require the actor to be an existing member (any role can invite). - if channel.visibility == "private" { - if actor_role.is_none() { - return Err(anyhow::anyhow!("actor not authorized")); - } - - // Only owners/admins may grant elevated roles. - if requested_role.is_some_and(|r| r.is_elevated()) - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may grant elevated roles" - )); - } - } - - // Extract target pubkey from p tag let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + // PUT_USER: open channels allow any authenticated user. Private + // channels only let owners/admins add another identity; otherwise + // any compromised member could extend access to channel history. + // + // A self-targeted add skips this check so an idempotent re-add + // still works. That is not a way into a private channel: ingest's + // `check_channel_membership` rejects a non-member (and a + // soft-removed member) before this validator runs, and `add_member` + // independently requires the self-inviter to hold an active role. + // Self-promotion is caught by the role-change guard below. + if channel.visibility == "private" + && target_pubkey != actor_bytes + && !actor_role.is_some_and(|r| r.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may add private-channel members" + )); + } + // Changing an ACTIVE existing member's role is privileged in both // directions, on every visibility. `get_members` filters // `removed_at IS NULL`, so a soft-removed row is deliberately not an diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..5b9a50b5b1 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2201,6 +2201,10 @@ async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> } /// Submit a kind:9000 PUT_USER event over WebSocket. +/// +/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise +/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the +/// event fails as "missing p tag" instead of exercising the authority check. async fn add_member_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2210,6 +2214,7 @@ async fn add_member_ws( let h_tag = Tag::parse(["h", channel_id]).unwrap(); let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag]) .sign_with_keys(signer) .unwrap(); @@ -2219,6 +2224,8 @@ async fn add_member_ws( } /// Submit a kind:9000 PUT_USER event with a role tag over WebSocket. +/// +/// See [`add_member_ws`] for why `allow_self_tagging` is required. async fn add_member_with_role_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2230,6 +2237,7 @@ async fn add_member_with_role_ws( let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let role_tag = Tag::parse(["role", role]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag, role_tag]) .sign_with_keys(signer) .unwrap(); @@ -2241,10 +2249,10 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Any member of a private channel can invite another user (Slack model). +/// Only owners/admins can add another identity to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_any_member_can_invite() { +async fn test_private_channel_member_cannot_invite() { let url = relay_url(); let owner_keys = Keys::generate(); let member_keys = Keys::generate(); @@ -2271,7 +2279,7 @@ async fn test_private_channel_any_member_can_invite() { .await .expect("connect as member"); - // Regular member invites a third user — this should succeed. + // Regular member tries to invite a third user. let (accepted, msg) = add_member_ws( &mut member_client, &channel_id, @@ -2279,15 +2287,77 @@ async fn test_private_channel_any_member_can_invite() { &member_keys, ) .await; + assert!( + !accepted, + "regular member must not add another private-channel identity: {msg}" + ); + assert!( + msg.contains("owners/admins"), + "rejection should name the owner/admin requirement, got: {msg}" + ); + + // The same member re-adding *themselves* stays idempotent — the huddle + // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_ws( + &mut member_client, + &channel_id, + &member_keys.public_key().to_hex(), + &member_keys, + ) + .await; assert!( accepted, - "regular member should be able to invite to private channel, got: {msg}" + "self-targeted re-add must stay idempotent, got: {msg}" ); owner_client.disconnect().await.expect("disconnect owner"); member_client.disconnect().await.expect("disconnect member"); } +/// An admin — not just the owner — can still add to a private channel. +#[tokio::test] +#[ignore] +async fn test_private_channel_admin_can_invite() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let admin_keys = Keys::generate(); + let invitee_keys = Keys::generate(); + + let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; + + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &admin_keys.public_key().to_hex(), + "admin", + &owner_keys, + ) + .await; + assert!(accepted, "owner should add an admin, got: {msg}"); + + let mut admin_client = BuzzTestClient::connect(&url, &admin_keys) + .await + .expect("connect as admin"); + + let (accepted, msg) = add_member_ws( + &mut admin_client, + &channel_id, + &invitee_keys.public_key().to_hex(), + &admin_keys, + ) + .await; + assert!( + accepted, + "admin should be able to add to a private channel, got: {msg}" + ); + + owner_client.disconnect().await.expect("disconnect owner"); + admin_client.disconnect().await.expect("disconnect admin"); +} + /// A non-member cannot invite someone to a private channel. #[tokio::test] #[ignore] diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 8829edab39..51297b915b 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -32,7 +32,9 @@ import type { SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; +import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission"; import { readChannelSnapshot, writeChannelSnapshot, @@ -501,6 +503,32 @@ export function useDeleteChannelMutation(channelId: string | null) { }); } +/** + * Whether the signed-in identity may add *another* identity to this channel, + * per {@link canAddChannelMembers}. Both queries are the ones the channel UI + * already holds, so this shares their cache rather than fetching again. + */ +export function useCanAddChannelMembers(channelId: string | null) { + const channelsQuery = useChannelsQuery(); + const membersQuery = useChannelMembersQuery(channelId); + const identityQuery = useIdentityQuery(); + + const channel = + channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null; + const selfPubkey = identityQuery.data?.pubkey ?? null; + const selfRole = selfPubkey + ? (membersQuery.data?.find( + (member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(), + )?.role ?? null) + : null; + + return canAddChannelMembers({ + channelType: channel?.channelType, + visibility: channel?.visibility, + selfRole, + }); +} + export function useAddChannelMembersMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs new file mode 100644 index 0000000000..0af22459f4 --- /dev/null +++ b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs @@ -0,0 +1,79 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import { canAddChannelMembers } from "./channelMemberAdmission.ts"; + +test("open channels accept adds from anyone, member or not", () => { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "open", + selfRole: null, + }), + true, + ); + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "open", + selfRole: "member", + }), + true, + ); +}); + +test("private channels accept adds only from owners/admins", () => { + for (const selfRole of ["owner", "admin"]) { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "private", + selfRole, + }), + true, + `${selfRole} should be able to add`, + ); + } + + for (const selfRole of ["member", "bot", "guest", null]) { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "private", + selfRole, + }), + false, + `${selfRole} must not be able to add`, + ); + } +}); + +test("DMs never accept adds, even from an owner", () => { + assert.equal( + canAddChannelMembers({ + channelType: "dm", + visibility: "private", + selfRole: "owner", + }), + false, + ); + assert.equal( + canAddChannelMembers({ + channelType: "dm", + visibility: "open", + selfRole: "owner", + }), + false, + ); +}); + +test("unknown visibility fails closed for non-elevated callers", () => { + assert.equal( + canAddChannelMembers({ channelType: "stream", selfRole: "member" }), + false, + ); + assert.equal( + canAddChannelMembers({ channelType: "stream", selfRole: "owner" }), + true, + ); +}); diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.ts b/desktop/src/features/channels/lib/channelMemberAdmission.ts new file mode 100644 index 0000000000..b01c6f5216 --- /dev/null +++ b/desktop/src/features/channels/lib/channelMemberAdmission.ts @@ -0,0 +1,36 @@ +/** + * Client mirror of the relay's kind:9000 authority for adding *another* + * identity to a channel (`validate_admin_event` + `buzz_db::channel::add_member`): + * + * - DMs: nobody — membership is fixed at creation. + * - Open channels: anyone, member or not. + * - Private channels: owners/admins only. A plain member extending access to + * channel history is exactly what the relay now rejects, so the affordance + * must not be offered. + * + * Unknown visibility fails closed — the relay is the authority and a hidden + * button is cheaper than an opaque rejection. + */ +export function canAddChannelMembers({ + channelType, + visibility, + selfRole, +}: { + channelType?: string | null; + visibility?: string | null; + selfRole?: string | null; +}): boolean { + if (channelType === "dm") { + return false; + } + + if (visibility === "open") { + return true; + } + + return selfRole === "owner" || selfRole === "admin"; +} + +/** Explains a denied add so the user isn't left guessing at a missing button. */ +export const PRIVATE_CHANNEL_ADD_DENIED_MESSAGE = + "Only channel owners and admins can add people to a private channel."; diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..5e9e6740c3 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -14,6 +14,10 @@ import { import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; +import { + canAddChannelMembers, + PRIVATE_CHANNEL_ADD_DENIED_MESSAGE, +} from "@/features/channels/lib/channelMemberAdmission"; import { useFlattenedUserSearchResults, useInfiniteUserSearchQuery, @@ -240,9 +244,18 @@ export function MembersSidebar({ () => new Set(rawMembers.map((member) => normalizePubkey(member.pubkey))), [rawMembers], ); - const canAddMembers = - (selfMember !== null || channel?.visibility === "open") && - channel?.channelType !== "dm"; + const canAddMembers = canAddChannelMembers({ + channelType: channel?.channelType, + visibility: channel?.visibility, + selfRole: selfMember?.role, + }); + // Distinguish "you can't add here" from "nothing to add" so a plain member of + // a private channel gets the reason instead of a silently missing affordance. + const showPrivateAddDeniedNotice = + !canAddMembers && + selfMember !== null && + channel?.channelType !== "dm" && + channel?.visibility !== "open"; const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { allowEmpty: false, enabled: @@ -723,6 +736,14 @@ export function MembersSidebar({ value={searchQuery} /> + {showPrivateAddDeniedNotice ? ( +

+ {PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} +

+ ) : null}
diff --git a/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts new file mode 100644 index 0000000000..14ba5ec4e8 --- /dev/null +++ b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts @@ -0,0 +1,57 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { ChannelType } from "@/shared/api/types"; + +export const DM_THREAD_AGENT_MENTION_ERROR = + "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; +export const DM_THREAD_MEMBERS_LOADING_ERROR = + "Checking conversation members. Try again in a moment."; + +/** + * Why a DM thread reply may not mention an agent, or null when it may. + * + * A DM's participant set is fixed at creation, so a thread reply can only + * mention agents already in it — persona mentions (which would create a new + * agent) are always refused. + */ +export function dmThreadAgentMentionError({ + trimmed, + isThreadReply, + channelType, + extractMentionPersonas, + extractMentionPubkeys, + isAgentPubkey, + hasResolvedMembers, + memberPubkeys, +}: { + trimmed: string; + isThreadReply: boolean; + channelType: ChannelType | null; + extractMentionPersonas: (text: string) => unknown[]; + extractMentionPubkeys: (text: string) => string[]; + isAgentPubkey: (pubkey: string) => boolean; + hasResolvedMembers: boolean; + memberPubkeys: ReadonlySet; +}): string | null { + if (channelType !== "dm" || !isThreadReply) { + return null; + } + + if (extractMentionPersonas(trimmed).length > 0) { + return DM_THREAD_AGENT_MENTION_ERROR; + } + + const agentPubkeys = extractMentionPubkeys(trimmed).filter(isAgentPubkey); + if (agentPubkeys.length === 0) { + return null; + } + + if (!hasResolvedMembers) { + return DM_THREAD_MEMBERS_LOADING_ERROR; + } + + return agentPubkeys.some( + (pubkey) => !memberPubkeys.has(normalizePubkey(pubkey)), + ) + ? DM_THREAD_AGENT_MENTION_ERROR + : null; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6f79daa606..58601f452f 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1007,15 +1007,7 @@ function MessageComposerImpl({
- + {linkEditor.card} {linkEditor.dialog} diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index 20f50924fb..c72686a642 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -7,8 +7,11 @@ import { AlertDialogTitle, } from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; type NonMemberMentionDialogProps = { + /** False in a private channel the viewer doesn't own/administer. */ + canInvite: boolean; error: string | null; isInvitePending: boolean; names: string[]; @@ -19,6 +22,7 @@ type NonMemberMentionDialogProps = { }; export function NonMemberMentionDialog({ + canInvite, error, isInvitePending, names, @@ -43,7 +47,10 @@ export function NonMemberMentionDialog({ {names.join(", ")} {names.length === 1 ? "is" : "are"} not in this - channel. Invite them to the channel, or send without inviting them. + channel.{" "} + {canInvite + ? "Invite them to the channel, or send without inviting them." + : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`} {error ? ( @@ -59,16 +66,18 @@ export function NonMemberMentionDialog({ type="button" variant="outline" > - Do nothing - - + {canInvite ? ( + + ) : null} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6ba9f69050..647ad4cbe8 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -10,7 +10,12 @@ import { useStartManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; -import { useAddChannelMembersMutation } from "@/features/channels/hooks"; +import { + useAddChannelMembersMutation, + useCanAddChannelMembers, +} from "@/features/channels/hooks"; +import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; +import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import { prepareBackgroundMediaUpload, @@ -87,10 +92,6 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; -const DM_THREAD_AGENT_MENTION_ERROR = - "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; -const DM_THREAD_MEMBERS_LOADING_ERROR = - "Checking conversation members. Try again in a moment."; export function useMentionSendFlow({ channelId, channelLinks, @@ -136,6 +137,7 @@ export function useMentionSendFlow({ }; }, []); const addMembersMutation = useAddChannelMembersMutation(channelId); + const canInviteNonMembers = useCanAddChannelMembers(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = useCreateChannelManagedAgentMutation(channelId); @@ -684,32 +686,17 @@ export function useMentionSendFlow({ ( trimmed: string, capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"], - ) => { - if (channelType !== "dm" || capturedThreadContext == null) { - return null; - } - - if (mentions.extractMentionPersonas(trimmed).length > 0) { - return DM_THREAD_AGENT_MENTION_ERROR; - } - - const agentPubkeys = mentions - .extractMentionPubkeys(trimmed) - .filter(mentions.isAgentPubkey); - if (agentPubkeys.length === 0) { - return null; - } - - if (!mentions.hasResolvedMembers) { - return DM_THREAD_MEMBERS_LOADING_ERROR; - } - - return agentPubkeys.some( - (pubkey) => !mentions.memberPubkeys.has(normalizePubkey(pubkey)), - ) - ? DM_THREAD_AGENT_MENTION_ERROR - : null; - }, + ) => + dmThreadAgentMentionError({ + trimmed, + isThreadReply: capturedThreadContext != null, + channelType, + extractMentionPersonas: mentions.extractMentionPersonas, + extractMentionPubkeys: mentions.extractMentionPubkeys, + isAgentPubkey: mentions.isAgentPubkey, + hasResolvedMembers: mentions.hasResolvedMembers, + memberPubkeys: mentions.memberPubkeys, + }), [ channelType, mentions.extractMentionPersonas, @@ -889,6 +876,12 @@ export function useMentionSendFlow({ const handleInviteNonMembers = React.useCallback(() => { if (!pendingNonMemberSend) return; + // The dialog hides Invite in this case; this guards the keyboard/programmatic + // path so we surface the reason instead of a raw relay rejection. + if (!canInviteNonMembers) { + setNonMemberPromptError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE); + return; + } const invitedPubkeys = new Set( pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey), @@ -963,6 +956,7 @@ export function useMentionSendFlow({ }); }, [ addMembersMutation, + canInviteNonMembers, completeSend, getManagedAgentsByPubkey, mentions.isAgentPubkey, @@ -975,25 +969,29 @@ export function useMentionSendFlow({ }, []); return { - dismissNonMemberPrompt, - isInvitePending: - isMentionSendPending || - isCompleteSendPending || - addMembersMutation.isPending || - attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, isPreparingMentionSend: isMentionSendPending || isCompleteSendPending || attachAgentMutation.isPending || createPersonaAgentMutation.isPending || startAgentMutation.isPending, - nonMemberPromptError, - pendingNonMemberNames, - pendingNonMemberSend, + /** Spread straight into `NonMemberMentionDialog`. */ + nonMemberPromptProps: { + canInvite: canInviteNonMembers, + error: nonMemberPromptError, + isInvitePending: + isMentionSendPending || + isCompleteSendPending || + addMembersMutation.isPending || + attachAgentMutation.isPending || + createPersonaAgentMutation.isPending || + startAgentMutation.isPending, + names: pendingNonMemberNames, + onDismiss: dismissNonMemberPrompt, + onDoNothing: handleSendWithoutInviting, + onInvite: handleInviteNonMembers, + open: pendingNonMemberSend !== null, + }, sendMessageWithMentionFlow, - sendWithoutInviting: handleSendWithoutInviting, - inviteNonMembers: handleInviteNonMembers, }; } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0bf65c16da..858f6ac3b9 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3750,7 +3750,7 @@ test("members sidebar collapses same-persona managed agents", async ({ await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1); }); -test("private-channel members can add people and managed agents without admin", async ({ +test("private-channel members cannot add people without owner/admin", async ({ page, }) => { await installMockBridge(page, { @@ -3764,14 +3764,51 @@ test("private-channel members can add people and managed agents without admin", }); await page.goto("/"); // secret-projects is a private (non-DM) channel where the current user is a - // plain member, not owner/admin. They should still be able to add members - // and bots — only granting elevated roles is reserved for owners/admins. + // plain member. The relay rejects their kind:9000, so the affordance is + // withheld and the reason shown instead of failing after the fact. await openMembersSidebar(page, "secret-projects"); - // The invite card is shown to any member, not just owners/admins. + await expect(page.getByTestId("members-sidebar-add-denied")).toBeVisible(); + // The field stays, but only as a filter over existing members. + await expect( + page.getByTestId("channel-management-search-users"), + ).toHaveAttribute("placeholder", "Search people and agents"); + + await page.getByTestId("channel-management-search-users").fill("char"); + await expect(page.getByText("Not in this channel")).toHaveCount(0); + await expect( + page.getByTestId( + `channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`, + ), + ).toHaveCount(0); + await expect( + page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`), + ).toHaveCount(0); +}); + +test("open-channel members can add people and managed agents without admin", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); + await page.goto("/"); + // random is open and the current user is a plain member there, so the + // owner/admin requirement must not leak outside private channels. + await openMembersSidebar(page, "random"); + + // The invite card is shown to any member of an open channel, not just + // owners/admins. await expect( page.getByTestId("channel-management-search-users"), ).toBeVisible(); + await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0); await page.getByTestId("channel-management-search-users").fill("char"); await page .getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`) diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 3104e90335..29db1f96c5 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -2,6 +2,11 @@ import 'package:flutter/foundation.dart'; const Object _sentinel = Object(); +/// Shown when a private-channel add is refused, so a missing Invite action +/// reads as a rule rather than a bug. +const privateChannelAddDeniedMessage = + 'Only channel owners and admins can add people to a private channel.'; + @immutable class Channel { final String id; @@ -77,6 +82,17 @@ class Channel { bool get isForum => channelType == 'forum'; bool get isDm => channelType == 'dm'; bool get isPrivate => visibility == 'private'; + + /// Whether [selfRole] may add *another* identity here, mirroring the relay's + /// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never, + /// open channels always, private channels owners/admins only. An unknown + /// visibility fails closed — the relay is the authority. + bool canAddMembers(String? selfRole) { + if (isDm) return false; + if (visibility == 'open') return true; + return selfRole == 'owner' || selfRole == 'admin'; + } + bool get isArchived => archivedAt != null; String displayLabel({String? currentPubkey}) { diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 286a931db1..7f9615d4ab 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -13,6 +13,31 @@ import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; +String _relayErrorMessage(Object error) => + error.toString().replaceFirst('Exception: ', ''); + +/// Raised when one or more kind:9000 adds were rejected, keyed by pubkey. +/// +/// Callers surface [message] to the user — a relay rejection here (e.g. a plain +/// member trying to add someone to a private channel) is a real outcome, not a +/// crash to swallow. +@immutable +class AddMembersException implements Exception { + final Map failures; + + const AddMembersException(this.failures); + + String get message => failures.entries + .map( + (entry) => + '${entry.key.length > 8 ? '${entry.key.substring(0, 8)}…' : entry.key}: ${entry.value}', + ) + .join('; '); + + @override + String toString() => 'AddMembersException($message)'; +} + @immutable class ChannelMember { final String pubkey; @@ -565,21 +590,34 @@ class ChannelActions { if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(), }; _ensureCommunityValid(); + // Per-pubkey failures are collected rather than thrown on the spot: one + // relay rejection must not skip the remaining adds or the invalidation + // below, which would leave the members list stale for the adds that landed. + final failures = {}; for (final pubkey in normalizedPubkeys) { + // Outside the catch: a community switch mid-loop must abort the whole + // add, not be recorded as this pubkey's rejection. _ensureCommunityValid(); - await _signedEventRelay.submit( - kind: 9000, - content: '', - tags: [ - ['h', channelId], - ['p', pubkey], - ['role', normalizedRole], - ], - ); + try { + await _signedEventRelay.submit( + kind: 9000, + content: '', + tags: [ + ['h', channelId], + ['p', pubkey], + ['role', normalizedRole], + ], + ); + } catch (error) { + failures[pubkey] = _relayErrorMessage(error); + } } _ensureCommunityValid(); _ref.invalidate(channelMembersProvider(channelId)); _ref.invalidate(channelBotPubkeysProvider(channelId)); + if (failures.isNotEmpty) { + throw AddMembersException(failures); + } } void _ensureCommunityValid() { diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 3cac205abc..99af48d44b 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -416,79 +416,37 @@ class ComposeBar extends HookConsumerWidget { for (final entry in mentionMap.value.entries) if (hasMention(text, entry.key)) entry.value, ]; - final pubkeys = LinkedHashSet.from( - selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), - ).toList(); - final nonMemberAgentPubkeys = []; - final nonMemberHumans = []; - if (selectedMentions.isNotEmpty) { - final currentChannel = (await ref.read( - channelsProvider.future, - )).firstWhere((channel) => channel.id == channelId); - if (!currentChannel.isDm) { - final memberPubkeys = (await ref.read( - channelMembersProvider(channelId).future, - )).map((member) => member.pubkey.toLowerCase()).toSet(); - final seenNonMembers = {}; - for (final candidate in selectedMentions) { - final pk = candidate.pubkey.toLowerCase(); - if (memberPubkeys.contains(pk)) continue; - if (!seenNonMembers.add(pk)) continue; - if (candidate.isAgent) { - nonMemberAgentPubkeys.add(pk); - } else { - nonMemberHumans.add(candidate); - } - } - } - } + final outgoing = _OutgoingMentions(selectedMentions); + final scan = await _scanNonMemberMentions( + ref, + channelId: channelId, + selectedMentions: selectedMentions, + currentPubkey: currentPubkey, + ); // Mentioning humans outside the channel prompts "Invite" / "Do // nothing" (send without inviting) — mirrors desktop's // NonMemberMentionDialog. Agents keep the existing silent auto-add. - var mentionPubkeys = pubkeys; - final referenceMentionTags = >[]; - var inviteHumanPubkeys = const []; - if (nonMemberHumans.isNotEmpty) { + if (scan.humans.isNotEmpty) { if (!context.mounted) return; final choice = await _promptNonMemberMention( context, - names: [for (final candidate in nonMemberHumans) candidate.label], + names: [for (final candidate in scan.humans) candidate.label], + canInvite: scan.canAddMembers, ); - switch (choice) { - case null: - return; // Dismissed — keep the draft, send nothing. - case _NonMemberMentionChoice.invite: - inviteHumanPubkeys = [ - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - ]; - case _NonMemberMentionChoice.sendWithoutInviting: - // Strip their p-tags (no channel notification) but keep a - // `mention` reference tag so their name still renders — - // mirrors desktop's mergeOutgoingTagsWithReferenceMentions. - final excluded = { - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - }; - mentionPubkeys = [ - for (final pk in pubkeys) - if (!excluded.contains(pk)) pk, - ]; - referenceMentionTags.addAll([ - for (final pk in excluded) ['mention', pk], - ]); - } + if (choice == null) return; // Dismissed — keep the draft, send nothing. + outgoing.resolveHumanChoice(choice, scan.humans); } final queuedAttachments = List<_PendingAttachment>.of(attachments.value); final channelActions = ref.read(channelActionsProvider); - Future addMentionedNonMembers() => _addMentionedNonMembers( + // An add that was refused doesn't block the message: it is reported and + // the un-added mentions are demoted to reference tags so the send lands. + Future addMentionedNonMembers() => outgoing.addNonMembers( channelActions, - channelId: channelId, - agentPubkeys: nonMemberAgentPubkeys, - humanPubkeys: inviteHumanPubkeys, + scan: scan, + messenger: messenger, ); isSending.value = true; @@ -503,12 +461,19 @@ class ComposeBar extends HookConsumerWidget { ); await onSend( payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], + outgoing.pubkeys, + mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); if (context.mounted) clearComposer(); } on StateError { _reportSendCancelledByCommunitySwitch(messenger); + } catch (error) { + // send() runs unawaited, so a relay rejection or publish timeout + // would otherwise vanish with the composer looking idle. The draft + // is kept (clearComposer never ran) so the user can retry. + messenger?.showSnackBar( + SnackBar(content: Text(_composeSendErrorMessage(error))), + ); } return; } @@ -561,8 +526,8 @@ class ComposeBar extends HookConsumerWidget { if (queueGeneration != uploadGeneration.value) return; await delivery( payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], + outgoing.pubkeys, + mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); } catch (error) { if (cancellation.isCancelled) return; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index c630accdff..65d236d1ad 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -143,11 +143,21 @@ bool hasMention(String text, String name) { /// cancels the send and keeps the draft. enum _NonMemberMentionChoice { invite, sendWithoutInviting } +/// User-facing text for a failed add or send. +String _composeSendErrorMessage(Object error) { + if (error is AddMembersException) return error.message; + return error.toString().replaceFirst('Exception: ', ''); +} + /// Ask whether to invite mentioned humans who aren't channel members, or /// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`. +/// [canInvite] false (a private channel the sender doesn't own/administer) +/// drops the Invite action — the relay rejects that add, so offering it would +/// only produce an error. Future<_NonMemberMentionChoice?> _promptNonMemberMention( BuildContext context, { required List names, + required bool canInvite, }) { final verb = names.length == 1 ? 'is' : 'are'; return showDialog<_NonMemberMentionChoice>( @@ -155,21 +165,26 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention( builder: (dialogContext) => AlertDialog( title: const Text('Mention people outside this channel?'), content: Text( - '${names.join(', ')} $verb not in this channel. Invite them to ' - 'the channel, or send without inviting them.', + canInvite + ? '${names.join(', ')} $verb not in this channel. Invite them to ' + 'the channel, or send without inviting them.' + : '${names.join(', ')} $verb not in this channel. ' + '$privateChannelAddDeniedMessage You can still send without ' + 'inviting them.', ), actions: [ TextButton( onPressed: () => Navigator.of( dialogContext, ).pop(_NonMemberMentionChoice.sendWithoutInviting), - child: const Text('Do nothing'), - ), - TextButton( - onPressed: () => - Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite), - child: const Text('Invite'), + child: Text(canInvite ? 'Do nothing' : 'Send anyway'), ), + if (canInvite) + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite), + child: const Text('Invite'), + ), ], ), ); @@ -232,27 +247,204 @@ void _reportSendCancelledByCommunitySwitch(ScaffoldMessengerState? messenger) { ); } +/// What an add attempt left undone: who is still a non-member, and why. +@immutable +class _NonMemberAddOutcome { + final List notAdded; + final List errors; + + const _NonMemberAddOutcome({required this.notAdded, required this.errors}); + + static const empty = _NonMemberAddOutcome(notAdded: [], errors: []); +} + /// Adds mentioned non-members to the channel before a send. /// /// Agents are added silently with the `bot` role; humans are only passed here /// after they have been explicitly invited from the mention prompt. -Future _addMentionedNonMembers( +/// +/// A rejection is reported, never thrown: the send is fire-and-forget, so an +/// escaping error would drop the message with nothing shown. [StateError] still +/// propagates — a community switch must cancel the whole send. +Future<_NonMemberAddOutcome> _addMentionedNonMembers( ChannelActions channelActions, { required String channelId, required List agentPubkeys, required List humanPubkeys, + required bool canAddMembers, }) async { - if (agentPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: agentPubkeys, - role: 'bot', + final pending = [ + if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'), + if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'), + ]; + if (pending.isEmpty) return _NonMemberAddOutcome.empty; + + // A plain member of a private channel cannot add anyone: skip the doomed + // kind:9000 rather than trading it for a relay rejection. + if (!canAddMembers) { + return _NonMemberAddOutcome( + notAdded: [for (final (pubkeys, _) in pending) ...pubkeys], + errors: const [privateChannelAddDeniedMessage], ); } - if (humanPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: humanPubkeys, + + final notAdded = []; + final errors = []; + for (final (pubkeys, role) in pending) { + try { + await channelActions.addMembers( + channelId: channelId, + pubkeys: pubkeys, + role: role, + ); + } on StateError { + rethrow; + } catch (error) { + notAdded.addAll( + error is AddMembersException ? error.failures.keys : pubkeys, + ); + errors.add(_composeSendErrorMessage(error)); + } + } + return _NonMemberAddOutcome(notAdded: notAdded, errors: errors); +} + +/// Mentioned identities that aren't in the channel yet, plus whether the sender +/// is allowed to add them at all. +@immutable +class _NonMemberMentionScan { + final String channelId; + final List agentPubkeys; + final List humans; + final bool canAddMembers; + + const _NonMemberMentionScan({ + required this.channelId, + required this.agentPubkeys, + required this.humans, + required this.canAddMembers, + }); +} + +/// Resolves which mentioned identities are non-members, and whether this +/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their +/// participant set is fixed at creation. +Future<_NonMemberMentionScan> _scanNonMemberMentions( + WidgetRef ref, { + required String channelId, + required List selectedMentions, + required String? currentPubkey, +}) async { + final none = _NonMemberMentionScan( + channelId: channelId, + agentPubkeys: const [], + humans: const [], + canAddMembers: true, + ); + if (selectedMentions.isEmpty) return none; + + final channel = (await ref.read( + channelsProvider.future, + )).firstWhere((candidate) => candidate.id == channelId); + if (channel.isDm) return none; + + final members = await ref.read(channelMembersProvider(channelId).future); + final memberPubkeys = { + for (final member in members) member.pubkey.toLowerCase(), + }; + String? selfRole; + if (currentPubkey != null) { + final self = currentPubkey.toLowerCase(); + for (final member in members) { + if (member.pubkey.toLowerCase() == self) { + selfRole = member.role; + break; + } + } + } + + final agentPubkeys = []; + final humans = []; + final seen = {}; + for (final candidate in selectedMentions) { + final pubkey = candidate.pubkey.toLowerCase(); + if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue; + if (candidate.isAgent) { + agentPubkeys.add(pubkey); + } else { + humans.add(candidate); + } + } + + return _NonMemberMentionScan( + channelId: channelId, + agentPubkeys: agentPubkeys, + humans: humans, + canAddMembers: channel.canAddMembers(selfRole), + ); +} + +/// The p-tags and `mention` reference tags an outgoing message should carry. +/// +/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so +/// their name still renders without notifying a non-member — mirrors desktop's +/// `mergeOutgoingTagsWithReferenceMentions`. +class _OutgoingMentions { + List pubkeys; + final List> referenceTags = []; + List _invitedHumanPubkeys = const []; + + _OutgoingMentions(List selectedMentions) + : pubkeys = LinkedHashSet.from( + selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); + + void demote(Iterable demoted) { + final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()}; + if (excluded.isEmpty) return; + pubkeys = [ + for (final pubkey in pubkeys) + if (!excluded.contains(pubkey)) pubkey, + ]; + referenceTags.addAll([ + for (final pubkey in excluded) ['mention', pubkey], + ]); + } + + /// Applies the mention prompt's outcome: invite them, or send without. + void resolveHumanChoice( + _NonMemberMentionChoice choice, + List humans, + ) { + final humanPubkeys = [ + for (final candidate in humans) candidate.pubkey.toLowerCase(), + ]; + switch (choice) { + case _NonMemberMentionChoice.invite: + _invitedHumanPubkeys = humanPubkeys; + case _NonMemberMentionChoice.sendWithoutInviting: + demote(humanPubkeys); + } + } + + /// Adds the scanned non-members, demoting and reporting whatever didn't land. + Future addNonMembers( + ChannelActions channelActions, { + required _NonMemberMentionScan scan, + required ScaffoldMessengerState? messenger, + }) async { + final outcome = await _addMentionedNonMembers( + channelActions, + channelId: scan.channelId, + agentPubkeys: scan.agentPubkeys, + humanPubkeys: _invitedHumanPubkeys, + canAddMembers: scan.canAddMembers, ); + demote(outcome.notAdded); + if (outcome.errors.isNotEmpty) { + messenger?.showSnackBar( + SnackBar(content: Text(outcome.errors.join(' '))), + ); + } } } diff --git a/mobile/test/features/channels/channel_test.dart b/mobile/test/features/channels/channel_test.dart index 9f36760861..9673eda28e 100644 --- a/mobile/test/features/channels/channel_test.dart +++ b/mobile/test/features/channels/channel_test.dart @@ -195,4 +195,50 @@ void main() { expect(updated.archivedAt, newDate); }); }); + + group('Channel.canAddMembers', () { + Channel make({required String channelType, required String visibility}) => + Channel( + id: '1', + name: 'c', + channelType: channelType, + visibility: visibility, + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + ); + + test('open channels accept adds from anyone', () { + final channel = make(channelType: 'stream', visibility: 'open'); + expect(channel.canAddMembers(null), isTrue); + expect(channel.canAddMembers('member'), isTrue); + }); + + test('private channels accept adds only from owners/admins', () { + final channel = make(channelType: 'stream', visibility: 'private'); + expect(channel.canAddMembers('owner'), isTrue); + expect(channel.canAddMembers('admin'), isTrue); + expect(channel.canAddMembers('member'), isFalse); + expect(channel.canAddMembers('bot'), isFalse); + expect(channel.canAddMembers(null), isFalse); + }); + + test('DMs never accept adds', () { + expect( + make(channelType: 'dm', visibility: 'open').canAddMembers('owner'), + isFalse, + ); + expect( + make(channelType: 'dm', visibility: 'private').canAddMembers('owner'), + isFalse, + ); + }); + + test('unknown visibility fails closed for non-elevated callers', () { + final channel = make(channelType: 'stream', visibility: 'mystery'); + expect(channel.canAddMembers('member'), isFalse); + expect(channel.canAddMembers('owner'), isTrue); + }); + }); } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 11bf306825..d58555f45a 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -3138,6 +3138,81 @@ void main() { expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); }); + testWidgets( + 'skips the agent add in a private channel when not owner/admin', + (tester) async { + final agentPubkey = 'a' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + var didSend = false; + List sentMentionPubkeys = const []; + List> sentMediaTags = const >[]; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + // Plain member of a private channel: the relay rejects any add, so + // the composer must not attempt one — and must still send. + members: [ + ChannelMember( + pubkey: signer.public, + role: 'member', + joinedAt: DateTime(2024), + ), + ], + relayAgents: [_testAgent(agentPubkey)], + channels: [ + _makeCurrentChannel(visibility: 'private'), + _makeSharedMemberChannel(), + ], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + didSend = true; + sentMentionPubkeys = mentionPubkeys; + sentMediaTags = mediaTags; + }, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + final socket = _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ); + session.debugAttachSocketForTest(socket); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + + expect(didSend, isTrue); + expect( + publishedEvents.where((event) => event['kind'] == 9000), + isEmpty, + ); + // The un-added agent is demoted from p-tag to a reference mention. + expect(sentMentionPubkeys, isEmpty); + expect( + sentMediaTags, + contains(orderedEquals(['mention', agentPubkey])), + ); + expect(find.text(privateChannelAddDeniedMessage), findsOneWidget); + }, + ); + testWidgets('adds a sanitized animated PNG attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; @@ -3489,12 +3564,15 @@ List<({String text, TextStyle style})> _flattenStyledTextSpans( return result; } -Channel _makeCurrentChannel({String channelType = 'stream'}) { +Channel _makeCurrentChannel({ + String channelType = 'stream', + String visibility = 'open', +}) { return Channel( id: 'channel-1', name: 'current', channelType: channelType, - visibility: 'open', + visibility: visibility, description: '', createdBy: 'pubkey123', createdAt: DateTime(2024),