Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/bundled/channels/ChannelGeneralAccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChannelGeneralAccess
capability={h.capability}
channelId="alpha"
openingBlocked={blocked}
/>,
);
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();
},
);
28 changes: 26 additions & 2 deletions src/bundled/channels/ChannelGeneralAccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -44,13 +45,16 @@ export function ChannelGeneralAccess({
channelId,
channel,
mayHaveGuestMembers = true,
openingBlocked = false,
}: {
capability: ChannelDetailsCapability;
recovery?: RelayRecovery | undefined;
channelId: string;
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 =
Expand Down Expand Up @@ -102,6 +106,7 @@ export function ChannelGeneralAccess({
channelId={channelId}
mayHaveGuestMembers={mayHaveGuestMembers}
autoJoin={channel?.autoJoin ?? false}
openingBlocked={openingBlocked}
/>
);
}
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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<HTMLElement>('[role="combobox"]') ?? null;
setError("");
Expand Down Expand Up @@ -318,7 +332,17 @@ function ChannelGeneralAccessEditor({
...generalAccessOptions["view-only"],
leading: <EyeIcon size={20} />,
},
].filter((option) => !solo || option.value !== "public"),
]
.filter((option) => !solo || option.value !== "public")
.map((option) =>
closed
? {
...option,
disabled: true,
description: OPENING_BLOCKED_REASON,
}
: option,
),
},
{
label: "",
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/ChannelMembersDialog.solo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
3 changes: 3 additions & 0 deletions src/bundled/channels/ChannelMembersDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -1933,6 +1935,7 @@ export function ChannelMembersDialog({
administration.status !== "ready" ||
Object.values(administration.authority.roles).includes("guest")
}
openingBlocked={openingBlocked}
/>
) : null;
const dialog = (
Expand Down
74 changes: 74 additions & 0 deletions src/features/channels/general-access.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { isAgent?: true; ownerPubkey?: string }>([
[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,
);
});
47 changes: 47 additions & 0 deletions src/features/channels/general-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
}
29 changes: 29 additions & 0 deletions src/features/channels/use-opening-blocked.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useSyncExternalStore } from "react";
import { openingBlocked } from "./general-access";

type Session = Parameters<typeof openingBlocked>[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);
}
Loading
Loading