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