From 5a50f271ed96250c2d3ce96753e07b13e9813291 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 9 Nov 2026 16:51:21 -0500 Subject: [PATCH] Explain why a private channel with other members cannot be opened The relay refuses to make a private channel or chat open while it has a member who is neither the person changing it nor one of their own agents. General access and Convert to channel now disable View only and Public in that case and say why. Sharing such a Solo chat stops before the access confirmation with the same reason. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/buzz-help.md | 5 +- .../channels/ChannelGeneralAccess.test.tsx | 34 + src/bundled/channels/ChannelGeneralAccess.tsx | 28 +- .../ChannelMembersDialog.solo.test.tsx | 3 +- src/bundled/channels/ChannelMembersDialog.tsx | 3 + src/features/channels/general-access.test.ts | 74 ++ src/features/channels/general-access.ts | 47 ++ src/features/channels/use-opening-blocked.ts | 29 + src/features/solo/ConvertToChannel.test.tsx | 144 ++++ src/features/solo/ConvertToChannel.tsx | 35 +- src/features/solo/share.test.ts | 28 + src/features/solo/share.ts | 9 +- tests/browser/solo-sharing.spec.mjs | 767 +++++++++--------- 13 files changed, 833 insertions(+), 373 deletions(-) create mode 100644 src/features/channels/general-access.test.ts create mode 100644 src/features/channels/use-opening-blocked.ts diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 9cc9d058d0..55786914c3 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -456,7 +456,10 @@ revokes all of them, and a pairing unused for 30 days expires. - **Manage members** shows **General** access first in the member list. **Public** lets people in the community read and new people participate; **View only** lets them read; **Private** limits access to people added to the channel. - Changing this choice does not change existing members' roles. Ordinary + Changing this choice does not change existing members' roles. A Private + channel or chat with members besides you and your own agents can't be made + Public or View only, and such a chat can't be shared; create a new channel + instead. Ordinary participants have no role caption; Owner, Admin, and Viewing only mark exceptions. Use a person's actions menu to change their role when permitted. - **Message search** treats the last word you type as the start of a word, so diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 07e7d7b6d0..6c936c160c 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -583,3 +583,37 @@ it.each([true, false])( expect(h.save).not.toHaveBeenCalled(); }, ); + +it.each([true, false])( + "offers opening a private channel only when nobody else is a member (blocked: %s)", + async (blocked) => { + const h = harness(); + const user = userEvent.setup(); + h.load.mockResolvedValue({ ...base, visibility: "private" }); + render( + , + ); + const control = screen.getByRole("combobox", { name: "General access" }); + await waitFor(() => expect(control).toBeEnabled()); + await user.click(control); + for (const name of ["Public", "View only"]) { + const option = await screen.findByRole("option", { name }); + if (blocked) { + expect(option).toHaveAttribute("aria-disabled", "true"); + expect(option).toHaveAccessibleDescription( + "Has other members. Create a new public channel instead.", + ); + } else expect(option).not.toHaveAttribute("aria-disabled", "true"); + } + await user.click(screen.getByRole("option", { name: "View only" })); + if (blocked) { + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(h.load).toHaveBeenCalledTimes(1); + } else expect(await screen.findByRole("alertdialog")).toBeVisible(); + expect(h.save).not.toHaveBeenCalled(); + }, +); diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index b44c64643c..c2c864dcbc 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -34,6 +34,7 @@ import { generalAccessOptions, generalAccessPolicy, generalAccessValue, + OPENING_BLOCKED_REASON, } from "../../features/channels/general-access"; /** General access uses the same signed save/readback owner as channel details. @@ -44,6 +45,7 @@ export function ChannelGeneralAccess({ channelId, channel, mayHaveGuestMembers = true, + openingBlocked = false, }: { capability: ChannelDetailsCapability; recovery?: RelayRecovery | undefined; @@ -51,6 +53,8 @@ export function ChannelGeneralAccess({ channel?: ChannelSummary; /** False only when the signed member-role snapshot verifies no Guests. */ mayHaveGuestMembers?: boolean; + /** The relay will refuse to open it: someone else is a member. */ + openingBlocked?: boolean; }) { const place = channel?.channelType === "solo" ? "chat" : "channel"; const Icon = @@ -102,6 +106,7 @@ export function ChannelGeneralAccess({ channelId={channelId} mayHaveGuestMembers={mayHaveGuestMembers} autoJoin={channel?.autoJoin ?? false} + openingBlocked={openingBlocked} /> ); } @@ -112,12 +117,14 @@ function ChannelGeneralAccessEditor({ channelId, mayHaveGuestMembers, autoJoin, + openingBlocked, }: { capability: ChannelDetailsCapability; recovery: RelayRecovery | undefined; channelId: string; mayHaveGuestMembers: boolean; autoJoin: boolean; + openingBlocked: boolean; }) { const attempt = useSyncExternalStore(capability.subscribe, () => capability.snapshot(channelId), @@ -175,6 +182,7 @@ function ChannelGeneralAccessEditor({ const solo = soloLabels(base?.labels); const place = solo ? "chat" : "channel"; const access = base ? generalAccessValue(base) : ""; + const closed = access === "private" && openingBlocked; const Icon = access === "public" ? GlobeIcon @@ -186,7 +194,13 @@ function ChannelGeneralAccessEditor({ const option = Object.values(generalAccessOptions).find( (option) => option.value === value, ); - if (value === access || !option || (solo && value === "public")) return; + if ( + value === access || + !option || + (solo && value === "public") || + (closed && value !== "private") + ) + return; origin.current = section.current?.querySelector('[role="combobox"]') ?? null; setError(""); @@ -318,7 +332,17 @@ function ChannelGeneralAccessEditor({ ...generalAccessOptions["view-only"], leading: , }, - ].filter((option) => !solo || option.value !== "public"), + ] + .filter((option) => !solo || option.value !== "public") + .map((option) => + closed + ? { + ...option, + disabled: true, + description: OPENING_BLOCKED_REASON, + } + : option, + ), }, { label: "", diff --git a/src/bundled/channels/ChannelMembersDialog.solo.test.tsx b/src/bundled/channels/ChannelMembersDialog.solo.test.tsx index e7ff9aad60..d28c9bceeb 100644 --- a/src/bundled/channels/ChannelMembersDialog.solo.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.solo.test.tsx @@ -1427,7 +1427,8 @@ it("shows Shared on cold render from verified members and cached human evidence }); it("offers matching people and channels as labelled results without public keys", async () => { - const t = fixture(true); + // Another person is in the chat only after it was shared as View only. + const t = fixture(true, { visibility: "public", joinRole: "guest" }); const planningPerson = keypair(); t.addProfile(planningPerson, "Planning Person"); t.members.set(source, [t.viewer.pubkey, t.aria.pubkey]); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 385839e733..4adc78a14b 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -114,6 +114,7 @@ import { useReauthorizedRetry } from "../../features/relay/RecoveryNotice"; import { ChannelGeneralAccess } from "./ChannelGeneralAccess"; import { avatarMedia } from "../../shared/avatar-source"; import { channelIcon, channelText } from "../../features/channels/channel-icon"; +import { useOpeningBlocked } from "../../features/channels/use-opening-blocked"; import { writeClipboardText } from "../../shared/clipboard"; const INVITATION_GROUP_SIZE = 4; @@ -812,6 +813,7 @@ export function ChannelMembersDialog({ const channel = list.channels.find((item) => item.id === channelId) ?? session.channels.get?.(channelId); + const openingBlocked = useOpeningBlocked(session, channel?.members); const solo = channel?.channelType === "solo"; const place = solo ? "chat" : "channel"; const readOnlyAccess = @@ -1933,6 +1935,7 @@ export function ChannelMembersDialog({ administration.status !== "ready" || Object.values(administration.authority.roles).includes("guest") } + openingBlocked={openingBlocked} /> ) : null; const dialog = ( diff --git a/src/features/channels/general-access.test.ts b/src/features/channels/general-access.test.ts new file mode 100644 index 0000000000..e824ab63af --- /dev/null +++ b/src/features/channels/general-access.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; +import { openingBlocked } from "./general-access"; + +const viewer = "a".repeat(64); +const ownAgent = "b".repeat(64); +const inventoryAgent = "c".repeat(64); +const person = "d".repeat(64); +const othersAgent = "e".repeat(64); +const claimedAgent = "f".repeat(64); +const unloaded = "1".repeat(64); +const verified = new Map([ + [ownAgent, viewer], + [othersAgent, person], +]); +const session = { + viewer, + profiles: { + snapshot: () => + new Map([ + [ownAgent, { isAgent: true, ownerPubkey: viewer }], + [othersAgent, { isAgent: true, ownerPubkey: person }], + // An agent whose attestation is missing or does not verify. + [claimedAgent, { isAgent: true, ownerPubkey: viewer }], + [person, {}], + ]), + owner: (pubkey: string) => verified.get(pubkey), + }, + agentChoices: { + snapshot: () => ({ + identities: [{ pubkey: inventoryAgent }], + complete: true, + }), + }, +}; + +it.each([ + ["only the viewer", [viewer], false], + ["the viewer and their agents", [viewer, ownAgent, inventoryAgent], false], + ["another person", [viewer, person], true], + ["someone else's verified agent", [viewer, othersAgent], true], + ["an agent with unverified ownership", [viewer, claimedAgent], false], + ["a member whose profile has not loaded", [viewer, unloaded], false], + ["an unknown roster", undefined, false], +] as const)("%s", (_, members, blocked) => { + expect(openingBlocked(session, members)).toBe(blocked); +}); + +it("blocks once a member's profile shows a person", () => { + const loaded = new Map(session.profiles.snapshot()); + const later = { + ...session, + profiles: { ...session.profiles, snapshot: () => loaded }, + }; + expect(openingBlocked(later, [viewer, unloaded])).toBe(false); + loaded.set(unloaded, {}); + expect(openingBlocked(later, [viewer, unloaded])).toBe(true); +}); + +it("leaves an untagged member to the relay while the inventory is incomplete", () => { + const loading = { + ...session, + agentChoices: { + snapshot: () => ({ identities: [], complete: false }), + }, + }; + expect(openingBlocked(loading, [viewer, person])).toBe(false); + expect(openingBlocked(loading, [viewer, othersAgent])).toBe(true); +}); + +it("never blocks without a viewer", () => { + expect(openingBlocked({ ...session, viewer: undefined }, [person])).toBe( + false, + ); +}); diff --git a/src/features/channels/general-access.ts b/src/features/channels/general-access.ts index ee137c4dc4..11c4e62343 100644 --- a/src/features/channels/general-access.ts +++ b/src/features/channels/general-access.ts @@ -44,3 +44,50 @@ export function generalAccessPolicy( : "member", }; } + +export const OPENING_BLOCKED_REASON = + "Has other members. Create a new public channel instead."; + +/** Whether the relay will refuse to open this private channel: it has a member + * who is neither the viewer nor one of the viewer's own agents (NIP-OA). The + * relay decides; this only predicts it, so missing or unverified ownership + * never blocks. A member blocks only with evidence: a verified owner who is + * someone else, or a loaded profile that claims no agent at all, read as a + * person once the viewer's agent inventory is complete. That reading is a + * guess: an untagged agent of the viewer's missing from the inventory also + * blocks here, though the relay would allow it. */ +export function openingBlocked( + session: { + viewer?: string | undefined; + profiles: { + snapshot(): ReadonlyMap< + string, + { isAgent?: true | undefined; ownerPubkey?: string | undefined } + >; + owner?(pubkey: string): string | undefined; + }; + agentChoices: { + snapshot(): { + identities: readonly { pubkey: string }[]; + complete: boolean; + }; + }; + }, + members: readonly string[] | undefined, +) { + const viewer = session.viewer; + const others = members?.filter((pubkey) => pubkey !== viewer) ?? []; + if (!viewer || !others.length) return false; + const profiles = session.profiles.snapshot(); + const choices = session.agentChoices.snapshot(); + const inventory = new Set(choices.identities.map((agent) => agent.pubkey)); + return others.some((pubkey) => { + if (inventory.has(pubkey)) return false; + const owner = session.profiles.owner?.(pubkey); + if (owner) return owner !== viewer; + const profile = profiles.get(pubkey); + return ( + choices.complete && !!profile && !profile.isAgent && !profile.ownerPubkey + ); + }); +} diff --git a/src/features/channels/use-opening-blocked.ts b/src/features/channels/use-opening-blocked.ts new file mode 100644 index 0000000000..845236894e --- /dev/null +++ b/src/features/channels/use-opening-blocked.ts @@ -0,0 +1,29 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { openingBlocked } from "./general-access"; + +type Session = Parameters[0] & { + profiles: { subscribe(listener: () => void): () => void }; + agentChoices: { subscribe(listener: () => void): () => void }; +}; + +/** Re-evaluates on every profile or inventory notification. Ownership can be + * verified after a profile arrives without changing the profile snapshot, so + * subscribe to the result itself, not to either snapshot. */ +export function useOpeningBlocked( + session: Session, + members: readonly string[] | undefined, +) { + const subscribe = useCallback( + (listener: () => void) => { + const profiles = session.profiles.subscribe(listener); + const choices = session.agentChoices.subscribe(listener); + return () => { + profiles(); + choices(); + }; + }, + [session], + ); + const blocked = () => openingBlocked(session, members); + return useSyncExternalStore(subscribe, blocked, blocked); +} diff --git a/src/features/solo/ConvertToChannel.test.tsx b/src/features/solo/ConvertToChannel.test.tsx index 5abe4d8dd2..47aafaeab1 100644 --- a/src/features/solo/ConvertToChannel.test.tsx +++ b/src/features/solo/ConvertToChannel.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { AgentChoicesSnapshot } from "../agents/choices"; import "@testing-library/jest-dom/vitest"; import { act, @@ -14,6 +15,7 @@ import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools"; import { createChannelDetails } from "../relay/channel-details"; import { createRelaySession } from "../relay/session"; import { PublishRejected } from "../relay/outbox"; +import type { Profile } from "../relay/contracts"; import { SOLO_LABEL } from "./labels"; import { chatConversion, conversionLabel } from "./conversion"; import { ConvertToChannel, canConvertToChannel } from "./ConvertToChannel"; @@ -32,6 +34,8 @@ function setup( solo = true, initialVisibility: "private" | "public" = "private", initialJoinRole: "member" | "guest" | undefined = undefined, + members?: string[], + profilesLoaded = true, ) { let clock = 100, role = "owner", @@ -101,8 +105,57 @@ function setup( return { sections: [], assignments: {}, starred: [], muted: [] }; }); const projectsRefresh = vi.fn(async () => {}); + const listed = members && { + ...owner.session.channels.list(), + channels: [{ id, name, channelType: "solo" as const, members }], + }; + const channels = listed + ? { ...owner.session.channels, list: () => listed } + : owner.session.channels; + // Every listed member other than the viewer has a person's loaded profile. + const others = (members ?? []).filter((m) => m !== viewer); + const person = (pubkey: string): [string, Profile] => [ + pubkey, + { name: "Morgan" }, + ]; + let people = new Map( + profilesLoaded ? others.map(person) : [], + ); + const owners = new Map(); + const profileListeners = new Set<() => void>(); + let inventory: AgentChoicesSnapshot = Object.freeze({ + ...owner.session.agentChoices.snapshot(), + identities: [], + complete: true, + pending: false, + }); + const inventoryListeners = new Set<() => void>(); const session = { ...owner.session, + ...(members && { + viewer, + profiles: { + ...owner.session.profiles, + snapshot: () => people, + owner: (pubkey: string) => owners.get(pubkey), + subscribe: (listener: () => void) => { + profileListeners.add(listener); + return () => profileListeners.delete(listener); + }, + ensure: async () => {}, + }, + agentChoices: { + ...owner.session.agentChoices, + snapshot: () => inventory, + subscribe: (listener: () => void) => { + inventoryListeners.add(listener); + return () => inventoryListeners.delete(listener); + }, + retain: () => () => {}, + ensure: () => {}, + }, + }), + channels, channelDetails: details.capability, soloPreferences: { ...owner.session.soloPreferences, @@ -125,6 +178,21 @@ function setup( ); return { session, + loadProfiles: (profile?: Profile) => { + people = new Map( + others.map((pubkey) => (profile ? [pubkey, profile] : person(pubkey))), + ); + for (const listener of profileListeners) listener(); + }, + /** Verification lands without a new profile snapshot. */ + verifyOwner: (pubkey: string, by: string) => { + owners.set(pubkey, by); + for (const listener of profileListeners) listener(); + }, + setInventoryComplete: (complete: boolean) => { + inventory = Object.freeze({ ...inventory, complete }); + for (const listener of inventoryListeners) listener(); + }, close, convertedCallback: converted, unfile, @@ -680,3 +748,79 @@ it("serializes conversion completion across two mounted forms", async () => { } await waitFor(() => expect(h.convertedCallback).toHaveBeenCalledOnce()); }); + +it.each([ + ["someone else is a member", true], + ["only the owner is a member", false], +] as const)( + "offers opening a private chat only when nobody else is a member: %s", + async (_, blocked) => { + setup(true, "private", undefined, [ + viewer, + ...(blocked ? ["d".repeat(64)] : []), + ]); + await ready(); + const user = userEvent.setup(); + await user.click(accessControl()); + for (const name of ["Public", "View only"]) { + const option = await screen.findByRole("option", { name }); + if (blocked) { + expect(option).toHaveAttribute("aria-disabled", "true"); + expect(option).toHaveAccessibleDescription( + "Has other members. Create a new public channel instead.", + ); + } else expect(option).not.toHaveAttribute("aria-disabled", "true"); + } + await user.click(screen.getByRole("option", { name: "View only" })); + expect(accessControl()).toHaveTextContent( + blocked ? "Private" : "View only", + ); + }, +); + +it("closes opening once a member's profile shows a person", async () => { + const h = setup(true, "private", undefined, [viewer, "d".repeat(64)], false); + await ready(); + const user = userEvent.setup(); + await user.click(accessControl()); + expect( + await screen.findByRole("option", { name: "View only" }), + ).not.toHaveAttribute("aria-disabled", "true"); + await user.keyboard("{Escape}"); + act(() => h.loadProfiles()); + await user.click(accessControl()); + expect( + await screen.findByRole("option", { name: "View only" }), + ).toHaveAttribute("aria-disabled", "true"); +}); + +async function viewOnlyDisabled(user: ReturnType) { + await user.click(accessControl()); + const option = await screen.findByRole("option", { name: "View only" }); + const disabled = option.getAttribute("aria-disabled") === "true"; + await user.keyboard("{Escape}"); + return disabled; +} + +it("closes opening once another owner is verified for a member agent", async () => { + const agent = "d".repeat(64); + const h = setup(true, "private", undefined, [viewer, agent], false); + act(() => + h.loadProfiles({ name: "Bot", isAgent: true, ownerPubkey: "e".repeat(64) }), + ); + await ready(); + const user = userEvent.setup(); + expect(await viewOnlyDisabled(user)).toBe(false); + act(() => h.verifyOwner(agent, "e".repeat(64))); + expect(await viewOnlyDisabled(user)).toBe(true); +}); + +it("leaves an untagged member to the relay until the agent inventory is complete", async () => { + const h = setup(true, "private", undefined, [viewer, "d".repeat(64)]); + act(() => h.setInventoryComplete(false)); + await ready(); + const user = userEvent.setup(); + expect(await viewOnlyDisabled(user)).toBe(false); + act(() => h.setInventoryComplete(true)); + expect(await viewOnlyDisabled(user)).toBe(true); +}); diff --git a/src/features/solo/ConvertToChannel.tsx b/src/features/solo/ConvertToChannel.tsx index c35489d87f..a75b4ddd45 100644 --- a/src/features/solo/ConvertToChannel.tsx +++ b/src/features/solo/ConvertToChannel.tsx @@ -30,12 +30,15 @@ import { soloRetainedAccessWarning, } from "./labels"; import { applySharedChannelDetails } from "./share"; +import { useAgentChoices } from "../agents/use-choices"; import { generalAccessOptions, generalAccessPolicy, generalAccessValue, + OPENING_BLOCKED_REASON, type GeneralAccess, } from "../channels/general-access"; +import { useOpeningBlocked } from "../channels/use-opening-blocked"; /** Presentation gating only; fresh signed details authorize the actual write. */ export function canConvertToChannel( @@ -123,6 +126,23 @@ export function ConvertToChannel({ const canFinish = converted || confirmedElsewhere; // Uncertain intent belongs to the details owner, including admission. const savedDraft = receipt?.draft ?? conversionAttempt?.draft; + // Re-render as the roster and the ownership evidence for it arrive. + const list = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + ); + useAgentChoices(session); + const source = + list.channels.find((item) => item.id === id) ?? session.channels.get?.(id); + const memberKeys = source?.members?.join(":") ?? ""; + useEffect(() => { + if (memberKeys) + void session.profiles + .ensure(memberKeys.split(":"), "foreground") + .catch(() => {}); + }, [session, memberKeys]); + const blocked = useOpeningBlocked(session, source?.members); + const closed = base?.visibility === "private" && blocked; const selectedAccess = savedDraft ? generalAccessValue(savedDraft) : (access ?? (base ? generalAccessValue(base) : "private")); @@ -349,7 +369,20 @@ export function ConvertToChannel({ label="General access" variant="field" value={selectedAccess} - groups={[{ label: "", options: Object.values(generalAccessOptions) }]} + groups={[ + { + label: "", + options: Object.values(generalAccessOptions).map((option) => + closed && option.value !== "private" + ? { + ...option, + disabled: true, + description: OPENING_BLOCKED_REASON, + } + : option, + ), + }, + ]} disabled={ busy || converted || diff --git a/src/features/solo/share.test.ts b/src/features/solo/share.test.ts index 6e224b3258..21d9fd2b49 100644 --- a/src/features/solo/share.test.ts +++ b/src/features/solo/share.test.ts @@ -243,3 +243,31 @@ it("escapes a chat name while retaining its ordinary link", () => { `[Chat: My \\[chat\\] name](buzz://channel/${id})`, ); }); + +it("refuses to share a private chat that has other members", async () => { + const f = fixture(); + const person = "d".repeat(64); + const ownAgent = "b".repeat(64); + Object.assign(f.session, { + profiles: { + snapshot: () => + new Map([ + [ownAgent, { isAgent: true, ownerPubkey: viewer }], + [person, {}], + ]), + owner: (pubkey: string) => (pubkey === ownAgent ? viewer : undefined), + }, + agentChoices: { snapshot: () => ({ identities: [], complete: true }) }, + }); + const [chat] = f.channels; + if (!chat) throw new Error("missing chat"); + chat.members = [viewer, ownAgent]; + expect(await shareSoloChatAccess(f.session, id, f)).toMatchObject({ + visibility: "private", + }); + chat.members = [viewer, ownAgent, person]; + await expect(shareSoloChatAccess(f.session, id, f)).rejects.toThrow( + "This chat has other members, so it can’t be shared. Create a new channel instead.", + ); + expect(f.save).not.toHaveBeenCalled(); +}); diff --git a/src/features/solo/share.ts b/src/features/solo/share.ts index ae1ef84d5d..68fa34f358 100644 --- a/src/features/solo/share.ts +++ b/src/features/solo/share.ts @@ -7,7 +7,10 @@ import { canAddMembers, canShareSoloChat } from "../channel-members/members"; import type { ChannelSummary } from "../relay/contracts"; import type { RelaySession } from "../relay/session"; import { ownSolo, soloLabels } from "./labels"; -import { generalAccessPolicy } from "../channels/general-access"; +import { + generalAccessPolicy, + openingBlocked, +} from "../channels/general-access"; /** Sharing changes access, never presentation or Solo placement. */ export function canShareConversation( @@ -120,6 +123,10 @@ export async function shareSoloChatAccess( !ownSolo(source, session.viewer) ) throw new Error("You can no longer change this chat’s general access."); + if (openingBlocked(session, source?.members)) + throw new Error( + "This chat has other members, so it can’t be shared. Create a new channel instead.", + ); if (!confirmed) return base; if (confirmed.visibility !== "private") throw new Error( diff --git a/tests/browser/solo-sharing.spec.mjs b/tests/browser/solo-sharing.spec.mjs index 83ebf70993..3c8a4d003b 100644 --- a/tests/browser/solo-sharing.spec.mjs +++ b/tests/browser/solo-sharing.spec.mjs @@ -161,10 +161,10 @@ test.describe("View only channel share", () => { historyCounts: { alpha: 2, [sharedChatId]: 2 }, channelWriteKinds: [9, 9000, 9002, 9007], }); - test("shares View only links with accessible disclosure", async ({ + test("refuses to share a chat that has other people in it", async ({ page, app, - }, info) => { + }) => { // Pre-existing grants have no device-local provenance. Keep the large fallback // collapsed, and prove real scrolling cannot push the fixed footer away. const legacyPeople = Array.from({ length: 14 }, (_, index) => @@ -308,7 +308,8 @@ test.describe("View only channel share", () => { await expect(copy).toBeFocused(); } } - // A channel's search-result action opens the confirmation directly. + // This chat has other people in it, so the relay refuses to open it: a + // channel's search-result action stops before any confirmation. await search.fill("Alpha"); const share = dialog.getByRole("button", { name: "Share #Alpha", @@ -316,19 +317,14 @@ test.describe("View only channel share", () => { }); await reachable(share); await share.press("Enter"); - const confirmation = page.getByRole("alertdialog", { - name: "Share this chat?", - }); - await expect(confirmation).toContainText( - "full history, Canvas, workspace folders, and agent instructions", - ); - await confirmation - .getByRole("button", { name: "Cancel", exact: true }) - .click(); - await expect(share).toBeFocused(); - await expect(search).toHaveValue("Alpha"); await expect( - dialog.getByRole("region", { name: "Selected channel" }), + dialog.getByText( + "This chat has other members, so it can’t be shared. Create a new channel instead.", + { exact: false }, + ), + ).toBeVisible(); + await expect( + page.getByRole("alertdialog", { name: "Share this chat?" }), ).toHaveCount(0); expect(app.report.detailsPublications ?? []).toHaveLength(0); expect( @@ -337,383 +333,418 @@ test.describe("View only channel share", () => { // Close retires this dialog visit before testing persisted appearance reloads. await dialog.getByRole("button", { name: "Close", exact: true }).click(); await expect(dialog).not.toBeVisible(); - // Keep actual appearance settings, nested modal focus, and action reachability - // in this browser journey; service and role matrices live in Vitest. - for (const [mode, width, scale] of [ - ["dark", 1280, 1], - ["light", 1280, 1], - ["dark", 390, 1.5], - ["light", 390, 1.5], - ]) { - await page.setViewportSize({ width, height: width === 390 ? 720 : 850 }); - await page.evaluate( - ({ mode, scale }) => { - localStorage.setItem("buzz-appearance.v1", mode); - localStorage.setItem("buzz-font-scale.v1", String(scale)); - }, - { mode, scale }, + }); + + // Browser-only: clipboard -> Solo card -> real composer consent/focus -> one + // original message through the broker. Policy/failure matrices stay in Vitest. + // Only the viewer and its agents are members, so the chat may be opened. + test.describe("with no other people", () => { + test.use({ agentPeers: false }); + test("shares View only links and confirms each reshare", async ({ + page, + app, + }, info) => { + await page.goto(app.origin); + await openChat(page, app, sharedChatId); + const trigger = page.getByRole("button", { + name: /^Share access to /, + exact: true, + }); + const dialog = page.getByRole("dialog", { name: "Share access" }); + const search = dialog.getByRole("searchbox", { + name: "Search people and channels", + }); + const share = dialog.getByRole("button", { + name: "Share #Alpha", + exact: true, + }); + const confirmation = page.getByRole("alertdialog", { + name: "Share this chat?", + }); + // Keep actual appearance settings, nested modal focus, and action reachability + // in this browser journey; service and role matrices live in Vitest. + for (const [mode, width, scale] of [ + ["dark", 1280, 1], + ["light", 1280, 1], + ["dark", 390, 1.5], + ["light", 390, 1.5], + ]) { + await page.setViewportSize({ + width, + height: width === 390 ? 720 : 850, + }); + await page.evaluate( + ({ mode, scale }) => { + localStorage.setItem("buzz-appearance.v1", mode); + localStorage.setItem("buzz-font-scale.v1", String(scale)); + }, + { mode, scale }, + ); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute( + "data-color-mode", + mode, + ); + await expect(page.locator("html")).toHaveCSS( + "--buzz-text-scale", + String(scale), + ); + // The cached header has only Share. Live Chat actions shifts Share left; + // wait for that header before pointer hit-testing can straddle the change. + await expect( + page.getByRole("button", { name: "Chat actions", exact: true }), + ).toBeVisible(); + await trigger.click(); + await expect(search).toBeFocused(); + await search.fill("Alpha"); + await reachable(share); + await expect( + dialog.getByRole("status").filter({ hasText: "Searching…" }), + ).toHaveCount(0); + if (info.project.name === "chromium") + await dialog.screenshot({ + path: info.outputPath(`SOLO_SHARE_RESULTS_${mode}_${width}.png`), + }); + await share.click(); + const cancel = confirmation.getByRole("button", { + name: "Cancel", + exact: true, + }); + await expect(cancel).toBeFocused(); + const confirmShare = confirmation.getByRole("button", { + name: "Share", + exact: true, + }); + await reachable(confirmShare); + await expect(confirmShare).toBeInViewport({ ratio: 1 }); + if (info.project.name === "chromium") + await confirmation.screenshot({ + path: info.outputPath(`SOLO_SHARE_CONFIRM_${mode}_${width}.png`), + }); + await page.keyboard.press("Escape"); + await expect(confirmation).not.toBeVisible(); + await expect(share).toBeFocused(); + if (mode !== "light" || width !== 390) { + await dialog + .getByRole("button", { name: "Close", exact: true }) + .click(); + await expect(dialog).not.toBeVisible(); + } + } + await share.press("Enter"); + await confirmation + .getByRole("button", { name: "Share", exact: true }) + .click(); + await expect(dialog).not.toBeVisible(); + const changedToast = page.getByRole("dialog", { + name: "Chat changed to View only. Shared in #Alpha", + exact: true, + }); + await expect(changedToast).toBeVisible(); + await expect( + changedToast.getByRole("button", { name: "View", exact: true }), + ).toBeVisible(); + expect( + await page.evaluate( + () => window.fixtureNavigation.snapshot().entry.target.channelId, + ), + ).toBe(sharedChatId); + await changedToast.hover(); + if (info.project.name === "chromium") + await changedToast.screenshot({ + path: info.outputPath("SHARE_ACCESS_CHANGED_TOAST_390.png"), + }); + expect(app.report.detailsPublications).toHaveLength(1); + expect(app.report.detailsPublications[0].tags).toContainEqual([ + "visibility", + "open", + ]); + expect(app.report.detailsPublications[0].tags).toContainEqual([ + "join_role", + "guest", + ]); + expect( + app.report.publications.filter(({ event }) => event.kind === 9000), + ).toHaveLength(0); + await page.setViewportSize({ width: 1280, height: 850 }); + await page.evaluate(() => + document.documentElement.style.setProperty("--buzz-text-scale", "1"), ); - await page.reload(); - await expect(page.locator("html")).toHaveAttribute( - "data-color-mode", - mode, + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(1); + const links = app.report.publications.filter( + ({ event }) => event.kind === 9, ); - await expect(page.locator("html")).toHaveCSS( - "--buzz-text-scale", - String(scale), + expect(links[0].event.content).toBe( + `[Chat: Beta](buzz://channel/${sharedChatId})`, ); - // The cached header has only Share. Live Chat actions shifts Share left; - // wait for that header before pointer hit-testing can straddle the change. - await expect( - page.getByRole("button", { name: "Chat actions", exact: true }), - ).toBeVisible(); + expect(links[0].event.tags).toContainEqual(["h", "alpha"]); + expect(links[0].event.tags.filter(([key]) => key === "p")).toHaveLength( + 0, + ); + expect( + links[0].event.tags.some(([key]) => key === "t" || key === "s"), + ).toBe(false); + await changedToast + .getByRole("button", { name: "View", exact: true }) + .click(); + await expect + .poll(() => + page.evaluate(() => window.fixtureNavigation.snapshot().entry.target), + ) + .toMatchObject({ channelId: "alpha", messageId: links[0].event.id }); + await openChat(page, app, sharedChatId); + // Sharing again confirms delivery while retaining the existing View only policy. await trigger.click(); - await expect(search).toBeFocused(); await search.fill("Alpha"); - await reachable(share); - await expect( - dialog.getByRole("status").filter({ hasText: "Searching…" }), - ).toHaveCount(0); + await share.click(); + await expect(confirmation).toContainText( + "This chat is already View only. Its link will be posted in #Alpha.", + ); + expect(app.report.detailsPublications).toHaveLength(1); + expect( + app.report.publications.filter(({ event }) => event.kind === 9), + ).toHaveLength(1); if (info.project.name === "chromium") - await dialog.screenshot({ - path: info.outputPath(`SOLO_SHARE_RESULTS_${mode}_${width}.png`), + await confirmation.screenshot({ + path: info.outputPath("VIEW_ONLY_SHARE_CONFIRM_1280.png"), }); + await confirmation + .getByRole("button", { name: "Cancel", exact: true }) + .click(); + await expect(share).toBeFocused(); + await expect(search).toHaveValue("Alpha"); + expect( + app.report.publications.filter(({ event }) => event.kind === 9), + ).toHaveLength(1); await share.click(); - const cancel = confirmation.getByRole("button", { - name: "Cancel", + await confirmation + .getByRole("button", { name: "Share", exact: true }) + .click(); + await expect(dialog).not.toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Shared in #Alpha", exact: true }), + ).toBeVisible(); + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(2); + expect(app.report.detailsPublications).toHaveLength(1); + expect( + app.report.publications.filter(({ event }) => event.kind === 9000), + ).toHaveLength(0); + }); + + test("pasted owned chats confirm Private and View only while preserving Public access", async ({ + page, + app, + }, info) => { + await open(page, app); + const input = page.getByRole("textbox", { + name: "Message #Alpha", exact: true, }); - await expect(cancel).toBeFocused(); - const confirmShare = confirmation.getByRole("button", { + const pasteLink = async () => { + // A visible cold composer can still be disabled until signed membership loads. + await expect(input).toBeEditable(); + await expect(input).toBeEnabled(); + await input.focus(); + await input.evaluate((element, link) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", `${link}\n\nPlease look at this`); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, `buzz://channel/${sharedChatId}`); + await expect(input).toContainText("Please look at this"); + }; + await pasteLink(); + const send = page.getByRole("button", { + name: "Send message", + exact: true, + }); + const confirmation = page.getByRole("alertdialog", { + name: "Share this chat?", + }); + const primary = confirmation.getByRole("button", { name: "Share", exact: true, }); - await reachable(confirmShare); - await expect(confirmShare).toBeInViewport({ ratio: 1 }); - if (info.project.name === "chromium") - await confirmation.screenshot({ - path: info.outputPath(`SOLO_SHARE_CONFIRM_${mode}_${width}.png`), + for (const [mode, width, scale] of [ + ["dark", 1280, 1], + ["light", 390, 1.5], + ]) { + await page.setViewportSize({ + width, + height: width === 390 ? 720 : 850, }); - await page.keyboard.press("Escape"); - await expect(confirmation).not.toBeVisible(); - await expect(share).toBeFocused(); - if (mode !== "light" || width !== 390) { - await dialog - .getByRole("button", { name: "Close", exact: true }) - .click(); - await expect(dialog).not.toBeVisible(); + await page.evaluate( + ({ mode, scale }) => { + localStorage.setItem("buzz-appearance.v1", mode); + localStorage.setItem("buzz-font-scale.v1", String(scale)); + }, + { mode, scale }, + ); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute( + "data-color-mode", + mode, + ); + await expect(input).toBeVisible(); + await send.click(); + await expect(confirmation).toContainText( + "full history, Canvas, workspace folders, and agent instructions", + ); + await expect(primary).toBeFocused(); + await reachable(primary); + await expect(primary).toBeInViewport({ ratio: 1 }); + if (info.project.name === "chromium") + await confirmation.screenshot({ + path: info.outputPath(`SOLO_PASTE_CONFIRM_${mode}_${width}.png`), + }); + await page.keyboard.press("Escape"); + await expect(confirmation).not.toBeVisible(); + await expect(input).toBeFocused(); + await expect(input).toContainText("Please look at this"); + expect(app.report.detailsPublications ?? []).toHaveLength(0); } - } - await share.press("Enter"); - await confirmation - .getByRole("button", { name: "Share", exact: true }) - .click(); - await expect(dialog).not.toBeVisible(); - const changedToast = page.getByRole("dialog", { - name: "Chat changed to View only. Shared in #Alpha", - exact: true, - }); - await expect(changedToast).toBeVisible(); - await expect( - changedToast.getByRole("button", { name: "View", exact: true }), - ).toBeVisible(); - expect( - await page.evaluate( - () => window.fixtureNavigation.snapshot().entry.target.channelId, - ), - ).toBe(sharedChatId); - await changedToast.hover(); - if (info.project.name === "chromium") - await changedToast.screenshot({ - path: info.outputPath("SHARE_ACCESS_CHANGED_TOAST_390.png"), - }); - expect(app.report.detailsPublications).toHaveLength(1); - expect(app.report.detailsPublications[0].tags).toContainEqual([ - "visibility", - "open", - ]); - expect(app.report.detailsPublications[0].tags).toContainEqual([ - "join_role", - "guest", - ]); - expect( - app.report.publications.filter(({ event }) => event.kind === 9000), - ).toHaveLength(0); - await page.setViewportSize({ width: 1280, height: 850 }); - await page.evaluate(() => - document.documentElement.style.setProperty("--buzz-text-scale", "1"), - ); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(1); - const links = app.report.publications.filter( - ({ event }) => event.kind === 9, - ); - expect(links[0].event.content).toBe( - `[Chat: Beta](buzz://channel/${sharedChatId})`, - ); - expect(links[0].event.tags).toContainEqual(["h", "alpha"]); - expect(links[0].event.tags.filter(([key]) => key === "p")).toHaveLength(0); - expect( - links[0].event.tags.some(([key]) => key === "t" || key === "s"), - ).toBe(false); - await changedToast - .getByRole("button", { name: "View", exact: true }) - .click(); - await expect - .poll(() => - page.evaluate(() => window.fixtureNavigation.snapshot().entry.target), - ) - .toMatchObject({ channelId: "alpha", messageId: links[0].event.id }); - await openChat(page, app, sharedChatId); - // Sharing again confirms delivery while retaining the existing View only policy. - await trigger.click(); - await search.fill("Alpha"); - await share.click(); - await expect(confirmation).toContainText( - "This chat is already View only. Its link will be posted in #Alpha.", - ); - expect(app.report.detailsPublications).toHaveLength(1); - expect( - app.report.publications.filter(({ event }) => event.kind === 9), - ).toHaveLength(1); - if (info.project.name === "chromium") - await confirmation.screenshot({ - path: info.outputPath("VIEW_ONLY_SHARE_CONFIRM_1280.png"), - }); - await confirmation - .getByRole("button", { name: "Cancel", exact: true }) - .click(); - await expect(share).toBeFocused(); - await expect(search).toHaveValue("Alpha"); - expect( - app.report.publications.filter(({ event }) => event.kind === 9), - ).toHaveLength(1); - await share.click(); - await confirmation - .getByRole("button", { name: "Share", exact: true }) - .click(); - await expect(dialog).not.toBeVisible(); - await expect( - page.getByRole("dialog", { name: "Shared in #Alpha", exact: true }), - ).toBeVisible(); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(2); - expect(app.report.detailsPublications).toHaveLength(1); - expect( - app.report.publications.filter(({ event }) => event.kind === 9000), - ).toHaveLength(0); - }); + await send.click(); + await primary.click(); + await expect(confirmation).not.toBeVisible(); + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(1); + expect(app.report.detailsPublications).toHaveLength(1); + expect(app.report.detailsPublications[0].tags).toContainEqual([ + "visibility", + "open", + ]); + expect(app.report.detailsPublications[0].tags).toContainEqual([ + "join_role", + "guest", + ]); + const message = app.report.publications.find( + ({ event }) => event.kind === 9, + ).event; + expect(message.content).toContain(`buzz://channel/${sharedChatId}`); + expect(message.content).toContain("Please look at this"); + expect( + app.report.publications.filter(({ event }) => event.kind === 9000), + ).toHaveLength(0); - // Browser-only: clipboard -> Solo card -> real composer consent/focus -> one - // original message through the broker. Policy/failure matrices stay in Vitest. - test("pasted owned chats confirm Private and View only while preserving Public access", async ({ - page, - app, - }, info) => { - await open(page, app); - const input = page.getByRole("textbox", { - name: "Message #Alpha", - exact: true, - }); - const pasteLink = async () => { - // A visible cold composer can still be disabled until signed membership loads. - await expect(input).toBeEditable(); - await expect(input).toBeEnabled(); - await input.focus(); - await input.evaluate((element, link) => { - const clipboardData = new DataTransfer(); - clipboardData.setData("text/plain", `${link}\n\nPlease look at this`); - element.dispatchEvent( - new ClipboardEvent("paste", { - bubbles: true, - cancelable: true, - clipboardData, - }), - ); - }, `buzz://channel/${sharedChatId}`); - await expect(input).toContainText("Please look at this"); - }; - await pasteLink(); - const send = page.getByRole("button", { - name: "Send message", - exact: true, - }); - const confirmation = page.getByRole("alertdialog", { - name: "Share this chat?", - }); - const primary = confirmation.getByRole("button", { - name: "Share", - exact: true, - }); - for (const [mode, width, scale] of [ - ["dark", 1280, 1], - ["light", 390, 1.5], - ]) { - await page.setViewportSize({ width, height: width === 390 ? 720 : 850 }); - await page.evaluate( - ({ mode, scale }) => { - localStorage.setItem("buzz-appearance.v1", mode); - localStorage.setItem("buzz-font-scale.v1", String(scale)); - }, - { mode, scale }, - ); - await page.reload(); - await expect(page.locator("html")).toHaveAttribute( - "data-color-mode", - mode, - ); - await expect(input).toBeVisible(); + await pasteLink(); await send.click(); - await expect(confirmation).toContainText( - "full history, Canvas, workspace folders, and agent instructions", + const viewOnlyConfirmation = page.getByRole("alertdialog", { + name: "Share this chat?", + }); + await expect(viewOnlyConfirmation).toContainText( + "This chat is already View only.", ); + expect(app.report.detailsPublications).toHaveLength(1); + expect( + app.report.publications.filter(({ event }) => event.kind === 9), + ).toHaveLength(1); + if (info.project.name === "chromium") + await viewOnlyConfirmation.screenshot({ + path: info.outputPath("VIEW_ONLY_PASTE_CONFIRM_390.png"), + }); + await viewOnlyConfirmation + .getByRole("button", { name: "Cancel", exact: true }) + .click(); + await expect(input).toBeFocused(); + await expect(input).toContainText("Please look at this"); + await send.click(); + await viewOnlyConfirmation + .getByRole("button", { name: "Share", exact: true }) + .click(); + await expect(viewOnlyConfirmation).not.toBeVisible(); + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(2); + expect(app.report.detailsPublications).toHaveLength(1); + + // Renewed consent must remount the real alert, return focus to Cancel and + // keep the original composer message unsent until a second explicit Share. + await pasteLink(); + await send.click(); + await expect(viewOnlyConfirmation).toBeVisible(); + app.setChannelMetadata(sharedChatId, [ + ["t", "stream"], + ["t", "buzz.solo"], + ["P", app.viewer], + ["name", "Beta"], + ["private"], + ["join_role", "guest"], + ]); + await viewOnlyConfirmation + .getByRole("button", { name: "Share", exact: true }) + .click(); + await expect(confirmation).toContainText("from Private to View only"); await expect(primary).toBeFocused(); - await reachable(primary); - await expect(primary).toBeInViewport({ ratio: 1 }); + expect(app.report.detailsPublications).toHaveLength(1); + expect( + app.report.publications.filter(({ event }) => event.kind === 9), + ).toHaveLength(2); if (info.project.name === "chromium") await confirmation.screenshot({ - path: info.outputPath(`SOLO_PASTE_CONFIRM_${mode}_${width}.png`), + path: info.outputPath("RENEWED_PASTE_CONSENT_390.png"), }); - await page.keyboard.press("Escape"); + await primary.click(); await expect(confirmation).not.toBeVisible(); - await expect(input).toBeFocused(); - await expect(input).toContainText("Please look at this"); - expect(app.report.detailsPublications ?? []).toHaveLength(0); - } - await send.click(); - await primary.click(); - await expect(confirmation).not.toBeVisible(); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(1); - expect(app.report.detailsPublications).toHaveLength(1); - expect(app.report.detailsPublications[0].tags).toContainEqual([ - "visibility", - "open", - ]); - expect(app.report.detailsPublications[0].tags).toContainEqual([ - "join_role", - "guest", - ]); - const message = app.report.publications.find( - ({ event }) => event.kind === 9, - ).event; - expect(message.content).toContain(`buzz://channel/${sharedChatId}`); - expect(message.content).toContain("Please look at this"); - expect( - app.report.publications.filter(({ event }) => event.kind === 9000), - ).toHaveLength(0); + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(3); + expect(app.report.detailsPublications).toHaveLength(2); - await pasteLink(); - await send.click(); - const viewOnlyConfirmation = page.getByRole("alertdialog", { - name: "Share this chat?", + app.setChannelMetadata(sharedChatId, [ + ["t", "stream"], + ["t", "buzz.solo"], + ["P", app.viewer], + ["name", "Beta"], + ["public"], + ["join_role", "member"], + ]); + await page.reload(); + await expect(input).toBeVisible(); + await pasteLink(); + await send.click(); + await expect + .poll( + () => + app.report.publications.filter(({ event }) => event.kind === 9) + .length, + ) + .toBe(4); + await expect(confirmation).not.toBeVisible(); + expect(app.report.detailsPublications).toHaveLength(2); + expect( + app.report.publications.filter(({ event }) => event.kind === 9000), + ).toHaveLength(0); }); - await expect(viewOnlyConfirmation).toContainText( - "This chat is already View only.", - ); - expect(app.report.detailsPublications).toHaveLength(1); - expect( - app.report.publications.filter(({ event }) => event.kind === 9), - ).toHaveLength(1); - if (info.project.name === "chromium") - await viewOnlyConfirmation.screenshot({ - path: info.outputPath("VIEW_ONLY_PASTE_CONFIRM_390.png"), - }); - await viewOnlyConfirmation - .getByRole("button", { name: "Cancel", exact: true }) - .click(); - await expect(input).toBeFocused(); - await expect(input).toContainText("Please look at this"); - await send.click(); - await viewOnlyConfirmation - .getByRole("button", { name: "Share", exact: true }) - .click(); - await expect(viewOnlyConfirmation).not.toBeVisible(); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(2); - expect(app.report.detailsPublications).toHaveLength(1); - - // Renewed consent must remount the real alert, return focus to Cancel and - // keep the original composer message unsent until a second explicit Share. - await pasteLink(); - await send.click(); - await expect(viewOnlyConfirmation).toBeVisible(); - app.setChannelMetadata(sharedChatId, [ - ["t", "stream"], - ["t", "buzz.solo"], - ["P", app.viewer], - ["name", "Beta"], - ["private"], - ["join_role", "guest"], - ]); - await viewOnlyConfirmation - .getByRole("button", { name: "Share", exact: true }) - .click(); - await expect(confirmation).toContainText("from Private to View only"); - await expect(primary).toBeFocused(); - expect(app.report.detailsPublications).toHaveLength(1); - expect( - app.report.publications.filter(({ event }) => event.kind === 9), - ).toHaveLength(2); - if (info.project.name === "chromium") - await confirmation.screenshot({ - path: info.outputPath("RENEWED_PASTE_CONSENT_390.png"), - }); - await primary.click(); - await expect(confirmation).not.toBeVisible(); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(3); - expect(app.report.detailsPublications).toHaveLength(2); - - app.setChannelMetadata(sharedChatId, [ - ["t", "stream"], - ["t", "buzz.solo"], - ["P", app.viewer], - ["name", "Beta"], - ["public"], - ["join_role", "member"], - ]); - await page.reload(); - await expect(input).toBeVisible(); - await pasteLink(); - await send.click(); - await expect - .poll( - () => - app.report.publications.filter(({ event }) => event.kind === 9) - .length, - ) - .toBe(4); - await expect(confirmation).not.toBeVisible(); - expect(app.report.detailsPublications).toHaveLength(2); - expect( - app.report.publications.filter(({ event }) => event.kind === 9000), - ).toHaveLength(0); }); }); @@ -934,6 +965,8 @@ test.describe("Messages Share navigation", () => { const firstChat = "55555555-5555-4555-8555-555555555555"; const secondChat = "66666666-6666-4666-8666-666666666666"; test.use({ + // Only the viewer and its agents are members, so the chats may be opened. + agentPeers: false, soloChannels: [firstChat, secondChat], ownSoloChannels: [firstChat, secondChat], channelIds: [firstChat, secondChat, "gamma"],