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"],