---
docs/channels.md | 3 ++-
src/features/messages/ChannelJoinNotice.test.tsx | 4 +++-
src/features/messages/ChannelJoinNotice.tsx | 4 ++--
src/features/messages/ThreadPanel.test.tsx | 4 +++-
4 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index bbd645bc0d..18326835e4 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -748,7 +748,8 @@ channels with a Guest default remain intact when changing unrelated details.
Readers see General access as plain text from verified channel discovery metadata,
without calling the editing capability. Its audience description is followed by
“Read-only for you. Ask an owner or admin to contribute.” The compact timeline
-label says “Contributions by invitation” for Guest-default channels; a Guest in a
+label says “This channel is read-only for new people. Existing contributors can
+still post.” for Guest-default channels; a Guest in a
Member-default channel sees “Read-only access.” Guest-default previews never suggest
joining to add people or agents.
diff --git a/src/features/messages/ChannelJoinNotice.test.tsx b/src/features/messages/ChannelJoinNotice.test.tsx
index 4071ed47f5..dce635a88c 100644
--- a/src/features/messages/ChannelJoinNotice.test.tsx
+++ b/src/features/messages/ChannelJoinNotice.test.tsx
@@ -33,7 +33,9 @@ it.each([false, true])(
);
expect(
screen.getByRole("region", { name: "Read-only channel" }),
- ).toHaveTextContent("Contributions by invitation");
+ ).toHaveTextContent(
+ "This channel is read-only for new people. Existing contributors can still post.",
+ );
expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
expect(run).not.toHaveBeenCalled();
expect(started).not.toHaveBeenCalled();
diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx
index 618307b153..c9c9f513e0 100644
--- a/src/features/messages/ChannelJoinNotice.tsx
+++ b/src/features/messages/ChannelJoinNotice.tsx
@@ -87,10 +87,10 @@ export function ChannelJoinNotice({
data-ready={visible}
className={`${styles.channelJoinSurface} pointer-events-auto flex w-fit max-w-full flex-wrap items-center justify-center gap-2 p-1.5`}
>
-
+
{readOnly
? joinRole === "guest"
- ? "Contributions by invitation"
+ ? "This channel is read-only for new people. Existing contributors can still post."
: "Read-only access"
: canJoin
? "Join channel to send messages"
diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx
index 9a2ce761bf..5710ddf94b 100644
--- a/src/features/messages/ThreadPanel.test.tsx
+++ b/src/features/messages/ThreadPanel.test.tsx
@@ -1515,7 +1515,9 @@ it("keeps guest threads readable without a reply composer or Join action across
h.render();
expect(
screen.getByRole("region", { name: "Read-only channel" }),
- ).toHaveTextContent("Contributions by invitation");
+ ).toHaveTextContent(
+ "This channel is read-only for new people. Existing contributors can still post.",
+ );
expect(
screen.getByRole("region", { name: "Thread messages" }),
).toBeInTheDocument();
From 0add05915ffbdf2129f1a74ef9fd9022ab479d7b Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 11:41:28 -0700
Subject: [PATCH 14/63] fix(channels): shorten the read-only footer label
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 3 +--
src/features/messages/ChannelJoinNotice.test.tsx | 4 +---
src/features/messages/ChannelJoinNotice.tsx | 2 +-
src/features/messages/ThreadPanel.test.tsx | 4 +---
4 files changed, 4 insertions(+), 9 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 18326835e4..c235ca7c8f 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -748,8 +748,7 @@ channels with a Guest default remain intact when changing unrelated details.
Readers see General access as plain text from verified channel discovery metadata,
without calling the editing capability. Its audience description is followed by
“Read-only for you. Ask an owner or admin to contribute.” The compact timeline
-label says “This channel is read-only for new people. Existing contributors can
-still post.” for Guest-default channels; a Guest in a
+label says “This channel is read-only.” for Guest-default channels; a Guest in a
Member-default channel sees “Read-only access.” Guest-default previews never suggest
joining to add people or agents.
diff --git a/src/features/messages/ChannelJoinNotice.test.tsx b/src/features/messages/ChannelJoinNotice.test.tsx
index dce635a88c..1b5b80a34a 100644
--- a/src/features/messages/ChannelJoinNotice.test.tsx
+++ b/src/features/messages/ChannelJoinNotice.test.tsx
@@ -33,9 +33,7 @@ it.each([false, true])(
);
expect(
screen.getByRole("region", { name: "Read-only channel" }),
- ).toHaveTextContent(
- "This channel is read-only for new people. Existing contributors can still post.",
- );
+ ).toHaveTextContent("This channel is read-only.");
expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
expect(run).not.toHaveBeenCalled();
expect(started).not.toHaveBeenCalled();
diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx
index c9c9f513e0..b41b35d287 100644
--- a/src/features/messages/ChannelJoinNotice.tsx
+++ b/src/features/messages/ChannelJoinNotice.tsx
@@ -90,7 +90,7 @@ export function ChannelJoinNotice({
{readOnly
? joinRole === "guest"
- ? "This channel is read-only for new people. Existing contributors can still post."
+ ? "This channel is read-only."
: "Read-only access"
: canJoin
? "Join channel to send messages"
diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx
index 5710ddf94b..12c9e6f472 100644
--- a/src/features/messages/ThreadPanel.test.tsx
+++ b/src/features/messages/ThreadPanel.test.tsx
@@ -1515,9 +1515,7 @@ it("keeps guest threads readable without a reply composer or Join action across
h.render();
expect(
screen.getByRole("region", { name: "Read-only channel" }),
- ).toHaveTextContent(
- "This channel is read-only for new people. Existing contributors can still post.",
- );
+ ).toHaveTextContent("This channel is read-only.");
expect(
screen.getByRole("region", { name: "Thread messages" }),
).toBeInTheDocument();
From 0305f7a9ede4611c0143c2ba854bbf74b1541d4b Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 13:47:03 -0700
Subject: [PATCH 15/63] fix(channels): clarify participation and viewing access
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 15 ++++----
.../channels/ChannelGeneralAccess.test.tsx | 16 ++++-----
src/bundled/channels/ChannelGeneralAccess.tsx | 34 ++++++++++++++-----
.../channels/ChannelMembersDialog.test.tsx | 12 +++----
.../channels/MemberAdministration.test.tsx | 16 +++++----
src/bundled/channels/MemberAdministration.tsx | 8 ++---
6 files changed, 59 insertions(+), 42 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index c235ca7c8f..920ec8612a 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -725,14 +725,15 @@ only stages the choice and Create channel performs the write.
Access management lives in the members dialog. It retains the existing member
list, row menus and confirmed role changes. Roles appear as muted
-text before each row’s ellipsis button; Member and Guest are shown as **Contribute**
-and **Read**. The ellipsis menu contains permitted role choices with the verified
-selection checked, alongside profile/removal actions and the existing role
+text before each row’s ellipsis button; Member and Guest are shown as **Participating**
+and **Viewing only**. The ellipsis menu contains permitted role choices with
+the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
**General access** sits below the list as one sentence-like row: a community/lock
icon, a compact audience Select (**Who can read**), a short result description,
-and the trailing **New people can** Select (Contribute / Only read). Both Selects
-use the shared ghost Button treatment. On narrow screens the role control wraps
+and the trailing **New people can** Select (Participate / View only), with short
+descriptions of the permitted actions. Audience options show the community and
+lock icons inside the dropdown. Both Selects use the shared ghost Button treatment. On narrow screens the role control wraps
under the audience. The confirmation explains “People already in the channel keep
their access.”; the note is not a permanent paragraph below the controls.
Each selection opens a consequence confirmation. Cancel/Escape returns focus to
@@ -1022,8 +1023,8 @@ another non-owner member:
Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority.
The menu includes **Make guest** through the existing deliberate confirmation
-flow. Contribute and Read captions represent each participant's verified Member
-and Guest role; changing the admission default never changes those roles.
+flow. Participating and Viewing only captions represent each participant's
+verified Member and Guest role; changing the admission default never changes those roles.
Agent Bot roles retain the contributing presentation.
The members dialog's General access controls keep reading access and new people's
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 1e1a775ff3..59c92f67d1 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -41,9 +41,7 @@ it.each(["public", "private"] as const)(
);
expect(
screen.getByRole("region", { name: "General access" }),
- ).toHaveTextContent(
- "Read-only for you. Ask an owner or admin to contribute.",
- );
+ ).toHaveTextContent("Viewing only. Ask an owner or admin to participate.");
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(h.load).not.toHaveBeenCalled();
@@ -151,11 +149,11 @@ it.each(["public", "private"] as const)(
);
},
);
-it("confirms new people's Read/Contribute access independently of private reading access", async () => {
+it("confirms new people's View only/Participate access independently of private reading access", async () => {
const h = harness();
const user = userEvent.setup();
render( );
- await select(user, "New people can", "Contribute");
+ await select(user, "New people can", "Participate");
expect(screen.getByRole("alertdialog")).toHaveTextContent(
"People already in the channel keep their access.",
);
@@ -185,9 +183,9 @@ it("shows the absent Member default without saving, and a declined save reloads
);
expect(
screen.getByRole("combobox", { name: "New people can" }),
- ).toHaveTextContent("Contribute");
+ ).toHaveTextContent("Participate");
expect(h.save).not.toHaveBeenCalled();
- await select(user, "New people can", "Only read");
+ await select(user, "New people can", "View only");
await user.click(screen.getByRole("button", { name: "Change access" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Channel details changed",
@@ -197,7 +195,7 @@ it("shows the absent Member default without saving, and a declined save reloads
await waitFor(() =>
expect(
screen.getByRole("combobox", { name: "New people can" }),
- ).toHaveTextContent("Only read"),
+ ).toHaveTextContent("View only"),
);
expect(
screen.getByRole("combobox", { name: "New people can" }),
@@ -222,7 +220,7 @@ it("locks both choices while saving and recovers an uncertain save after remount
const view = render(
,
);
- await select(user, "New people can", "Contribute");
+ await select(user, "New people can", "Participate");
try {
await user.click(screen.getByRole("button", { name: "Change access" }));
await waitFor(() => expect(h.save).toHaveBeenCalledOnce());
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index eb58718b6b..6a69846708 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -53,9 +53,9 @@ export function ChannelGeneralAccess({
{channel.viewerRole === "guest" || channel.joinRole === "guest"
- ? "Read-only for you. Ask an owner or admin to contribute."
+ ? "Viewing only. Ask an owner or admin to participate."
: channel.joinRole === "member"
- ? "New people can contribute. People already in the channel keep their access."
+ ? "New people can participate. People already in the channel keep their access."
: "New people’s access could not be confirmed."}
@@ -183,8 +183,16 @@ function ChannelGeneralAccessEditor({
{
label: "",
options: [
- { value: "public", label: "Anyone in the community" },
- { value: "private", label: "Invited people only" },
+ {
+ value: "public",
+ label: "Anyone in the community",
+ leading: ,
+ },
+ {
+ value: "private",
+ label: "Invited people only",
+ leading: ,
+ },
],
},
]}
@@ -202,8 +210,8 @@ function ChannelGeneralAccessEditor({
? "Checking channel access…"
: "Channel access could not be confirmed."
: base.visibility === "public"
- ? `Anyone in the community can find and ${base.joinRole === "guest" ? "read" : "contribute"}.`
- : `Invited people can ${base.joinRole === "guest" ? "read" : "contribute"}.`}
+ ? `Anyone in the community can find and ${base.joinRole === "guest" ? "view" : "participate"}.`
+ : `Invited people can ${base.joinRole === "guest" ? "view" : "participate"}.`}
@@ -218,8 +226,16 @@ function ChannelGeneralAccessEditor({
{
label: "",
options: [
- { value: "member", label: "Contribute" },
- { value: "guest", label: "Only read" },
+ {
+ value: "member",
+ label: "Participate",
+ description: "Send messages, reply, and react.",
+ },
+ {
+ value: "guest",
+ label: "View only",
+ description: "Read messages and threads.",
+ },
],
},
]}
@@ -274,7 +290,7 @@ function ChannelGeneralAccessEditor({
description={
choice.visibility !== base.visibility
? privacyConsequence(choice.visibility)
- : `New people will ${choice.joinRole === "guest" ? "only be able to read" : "be able to contribute"}. People already in the channel keep their access.`
+ : `New people will ${choice.joinRole === "guest" ? "only be able to view" : "be able to participate"}. People already in the channel keep their access.`
}
onClose={() => setChoice(undefined)}
initialFocus={cancel}
diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx
index f1f7bcf833..5b7731c33a 100644
--- a/src/bundled/channels/ChannelMembersDialog.test.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.test.tsx
@@ -2452,11 +2452,11 @@ it("shows personal contribution roles and General access in the members dialog",
);
expect(
screen.getByRole("combobox", { name: "New people can" }),
- ).toHaveTextContent("Only read");
+ ).toHaveTextContent("View only");
expect(
await within(
screen.getByRole("region", { name: "Member list" }),
- ).findAllByText("Contribute"),
+ ).findAllByText("Participating"),
).not.toHaveLength(0);
role = "guest";
await h.user.click(
@@ -2465,7 +2465,7 @@ it("shows personal contribution roles and General access in the members dialog",
expect(
await within(
screen.getByRole("region", { name: "Member list" }),
- ).findByText("Read"),
+ ).findByText("Viewing only"),
).toBeVisible();
expect(save).not.toHaveBeenCalled();
});
@@ -2553,7 +2553,7 @@ it.each([false, true])(
await user.click(screen.getByRole("button", { name: "Access" }));
expect(
await screen.findByText(
- "Read-only for you. Ask an owner or admin to contribute.",
+ "Viewing only. Ask an owner or admin to participate.",
),
).toBeVisible();
expect(
@@ -2568,9 +2568,7 @@ it.each([false, true])(
).toHaveTextContent("Anyone in the community can read.");
expect(
screen.getByRole("region", { name: "General access" }),
- ).toHaveTextContent(
- "Read-only for you. Ask an owner or admin to contribute.",
- );
+ ).toHaveTextContent("Viewing only. Ask an owner or admin to participate.");
// Finish the roster read before checking that the dialog cannot invite or mutate.
await vi.waitFor(() =>
expect(
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 9581966092..3054f366b6 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -244,9 +244,9 @@ async function setup(
name === "Make admin"
? "Admin"
: name === "Make member"
- ? "Contribute"
+ ? "Participating"
: name === "Make guest"
- ? "Read"
+ ? "Viewing only"
: undefined;
await user.click(
roleChoice
@@ -692,7 +692,11 @@ it.each(["member", "admin", "guest"] as const)(
const t = await setup("owner", role);
const row = required(screen.getByText("Morgan").closest("li"));
const caption =
- role === "member" ? "Contribute" : role === "guest" ? "Read" : "Admin";
+ role === "member"
+ ? "Participating"
+ : role === "guest"
+ ? "Viewing only"
+ : "Admin";
const actions = within(row).getByRole("button", {
name: "Actions for Morgan",
});
@@ -708,7 +712,7 @@ it.each(["member", "admin", "guest"] as const)(
await screen.findByRole("menuitem", { name: "Remove from channel" });
expect(
screen.getAllByRole("menuitemradio").map((item) => item.textContent),
- ).toEqual(["Admin", "Contribute", "Read"]);
+ ).toEqual(["Admin", "Participating", "Viewing only"]);
expect(
screen.getByRole("menuitemradio", { name: caption }),
).toHaveAttribute("aria-checked", "true");
@@ -791,7 +795,7 @@ it.each([false, true])(
).toBeVisible();
expect(screen.getByRole("menuitemradio", { name: "Admin" })).toBeVisible();
expect(
- screen.getByRole("menuitemradio", { name: "Contribute" }),
+ screen.getByRole("menuitemradio", { name: "Participating" }),
).toBeVisible();
expect(t.publish).not.toHaveBeenCalled();
await t.user.click(
@@ -1801,7 +1805,7 @@ it.each(["owner", "admin"])(
screen.getByRole("button", { name: "Actions for Morgan" }),
);
expect(
- await screen.findByRole("menuitemradio", { name: "Contribute" }),
+ await screen.findByRole("menuitemradio", { name: "Participating" }),
).toHaveAttribute("aria-checked", "true");
await t.user.keyboard("{Escape}");
const dialog = await t.choose("Make admin");
diff --git a/src/bundled/channels/MemberAdministration.tsx b/src/bundled/channels/MemberAdministration.tsx
index 465cabcc30..2275256818 100644
--- a/src/bundled/channels/MemberAdministration.tsx
+++ b/src/bundled/channels/MemberAdministration.tsx
@@ -130,9 +130,9 @@ export function MemberRow({
);
const roleCaption =
role === "member" || role === "bot"
- ? "Contribute"
+ ? "Participating"
: role === "guest"
- ? "Read"
+ ? "Viewing only"
: role === "owner"
? "Owner"
: role === "admin"
@@ -191,9 +191,9 @@ export function MemberRow({
{editableMemberRoles.map((next) => (
{next === "member"
- ? "Contribute"
+ ? "Participating"
: next === "guest"
- ? "Read"
+ ? "Viewing only"
: "Admin"}
))}
From 61c2570f508e2d26968d55e62d1d82430e5d90a4 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:18:45 -0700
Subject: [PATCH 16/63] fix(channels): show inline access progress and align
role choices
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/ChannelGeneralAccess.test.tsx | 5 ++++
src/bundled/channels/ChannelGeneralAccess.tsx | 27 +++++++++++++------
2 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 59c92f67d1..c960ed50a3 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -224,6 +224,8 @@ it("locks both choices while saving and recovers an uncertain save after remount
try {
await user.click(screen.getByRole("button", { name: "Change access" }));
await waitFor(() => expect(h.save).toHaveBeenCalledOnce());
+ expect(screen.getByRole("status", { name: "Saving access" })).toBeVisible();
+ expect(screen.queryByText("Saving access…")).not.toBeInTheDocument();
for (const control of screen.getAllByRole("combobox"))
expect(control).toBeDisabled();
await waitFor(() =>
@@ -238,6 +240,9 @@ it("locks both choices while saving and recovers an uncertain save after remount
});
}
await screen.findByRole("button", { name: "Check save status" });
+ expect(
+ screen.queryByRole("status", { name: "Saving access" }),
+ ).not.toBeInTheDocument();
view.unmount();
render( );
const check = await screen.findByRole("button", {
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 6a69846708..9ae4b46057 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -14,7 +14,11 @@ import type {
import { Button } from "../../shared/design-system/ui/Button";
import { AlertDialog } from "../../shared/design-system/ui/AlertDialog";
import { Select } from "../../shared/design-system/ui/Select";
-import { LockIcon, UsersIcon } from "../../shared/design-system/icons";
+import {
+ CircleNotchIcon,
+ LockIcon,
+ UsersIcon,
+} from "../../shared/design-system/icons";
import styles from "./ChannelMembersDialog.module.css";
import { privacyConsequence } from "./ChannelPrivacyConfirmation";
@@ -164,8 +168,19 @@ function ChannelGeneralAccessEditor({
>
General access
-
- {base?.visibility === "public" ? (
+
+ {pending ? (
+
+ ) : base?.visibility === "public" ? (
) : (
@@ -219,6 +234,7 @@ function ChannelGeneralAccessEditor({
variant="compact"
appearance="ghost"
label="New people can"
+ align="end"
value={base ? (base.joinRole ?? "member") : ""}
placeholder={loading ? "Loading access…" : "Access unavailable"}
disabled={locked}
@@ -249,11 +265,6 @@ function ChannelGeneralAccessEditor({
/>
- {pending && (
-
- Saving access…
-
- )}
{error && (
{error}
From ca90044609c150047fd406108b3b6bfef0db118d Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:22:50 -0700
Subject: [PATCH 17/63] fix(channels): show community identity in access
controls
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 12 ++--
.../channels/ChannelGeneralAccess.test.tsx | 58 +++++++++++++++++++
src/bundled/channels/ChannelGeneralAccess.tsx | 51 ++++++++++++++--
.../channels/ChannelMembersDialog.module.css | 1 +
src/bundled/channels/ChannelMembersDialog.tsx | 4 ++
src/bundled/channels/ChannelsPage.tsx | 1 +
src/bundled/channels/index.tsx | 2 +-
7 files changed, 118 insertions(+), 11 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 920ec8612a..f4e96f6a6b 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -729,17 +729,21 @@ text before each row’s ellipsis button; Member and Guest are shown as **Partic
and **Viewing only**. The ellipsis menu contains permitted role choices with
the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
-**General access** sits below the list as one sentence-like row: a community/lock
+**General access** sits below the list as one sentence-like row: a community avatar/lock
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
-descriptions of the permitted actions. Audience options show the community and
-lock icons inside the dropdown. Both Selects use the shared ghost Button treatment. On narrow screens the role control wraps
+descriptions of the permitted actions. Audience options show the community avatar and
+lock icon inside the dropdown. The audience Select uses the shared ghost Button
+treatment; the participation Select is outlined and vertically centered against
+the audience row, with its popup aligned to the trigger’s right edge. On narrow screens the role control wraps
under the audience. The confirmation explains “People already in the channel keep
their access.”; the note is not a permanent paragraph below the controls.
Each selection opens a consequence confirmation. Cancel/Escape returns focus to
the unchanged Select. **Change access** saves immediately through the existing
channel-details capability, then reloads signed metadata. Loading, pending and
-unconfirmed saves disable both controls. Rejections offer **Reload access**.
+unconfirmed saves disable both controls. A pending save shows an inline spinner
+in the leading icon slot instead of a separate saving message. Rejections offer
+**Reload access**.
failures before publication identify the initial access check, signing, or final
access check that failed, retaining the underlying error. No request is retried.
Uncertain results survive closing the dialog and offer **Check save status**,
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index c960ed50a3..6801d4588d 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -9,9 +9,67 @@ import type {
} from "../../features/relay/channel-details";
import type { ChannelDetails } from "../../features/relay/channel-details-protocol";
import { ChannelGeneralAccess } from "./ChannelGeneralAccess";
+import type { CommunityReader } from "../../features/communities/service";
afterEach(cleanup);
+it.each([false, true])(
+ "uses this channel's community image when readOnly is %s",
+ async (readOnly) => {
+ const h = harness();
+ h.load.mockResolvedValue({ ...base, visibility: "public" });
+ const state: ReturnType = {
+ status: "ready",
+ relayAvailable: true,
+ profile: { name: "Viewer", picture: "" },
+ selected: "https://other.example",
+ sync: { known: [], outbox: [] },
+ memberships: [
+ {
+ id: "https://other.example",
+ name: "Other",
+ icon: "https://other.example/icon.png",
+ },
+ {
+ id: "https://community.example",
+ name: "Community",
+ icon: "https://community.example/old.png",
+ publishedIcon: "https://community.example/current.png",
+ },
+ ],
+ };
+ const reader: CommunityReader = {
+ subscribe: () => () => {},
+ snapshot: () => state,
+ };
+ const view = render(
+ ,
+ );
+ if (!readOnly)
+ await waitFor(() =>
+ expect(
+ screen.getByRole("combobox", { name: "Who can read" }),
+ ).toBeEnabled(),
+ );
+ expect(view.container.querySelector("img")).toHaveAttribute(
+ "src",
+ "https://community.example/current.png",
+ );
+ },
+);
+
it.each(["public", "private"] as const)(
"shows verified %s access to readers without invoking the editing capability",
(visibility) => {
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 9ae4b46057..b987b88447 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -4,6 +4,7 @@ import {
useRef,
useState,
useSyncExternalStore,
+ type ReactNode,
} from "react";
import type { ChannelDetailsCapability } from "../../features/relay/channel-details";
import type { ChannelSummary } from "../../features/relay/contracts";
@@ -14,25 +15,57 @@ import type {
import { Button } from "../../shared/design-system/ui/Button";
import { AlertDialog } from "../../shared/design-system/ui/AlertDialog";
import { Select } from "../../shared/design-system/ui/Select";
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import type { CommunityReader } from "../../features/communities/service";
+import { communityName } from "../../features/communities/names";
+import { communityFromScope } from "../../features/relay/gifs";
+import { communityDestination } from "../../features/communities/destination";
import {
CircleNotchIcon,
LockIcon,
- UsersIcon,
+ GlobeIcon,
} from "../../shared/design-system/icons";
import styles from "./ChannelMembersDialog.module.css";
import { privacyConsequence } from "./ChannelPrivacyConfirmation";
+const noCommunitySubscription = () => () => {};
+
/** General access uses the same signed save/readback owner as channel details.
* Choices are confirmed individually, like the member rows above this section. */
export function ChannelGeneralAccess({
+ communityReader,
+ communityScope,
capability,
channelId,
channel,
}: {
+ communityReader?: CommunityReader | undefined;
+ communityScope?: string | undefined;
capability: ChannelDetailsCapability;
channelId: string;
channel?: ChannelSummary;
}) {
+ const origin = communityScope ? communityFromScope(communityScope) : null;
+ const community = useSyncExternalStore(
+ communityReader?.subscribe ?? noCommunitySubscription,
+ () =>
+ communityReader
+ ?.snapshot()
+ .memberships.find(
+ (entry) => communityDestination(entry.id).url === origin,
+ ),
+ );
+ const communityAvatar = community ? (
+
+ ) : (
+
+ );
// Public previews and Guests can inspect signed discovery metadata without
// calling the membership/write-gated channel-details capability.
if (channel?.readOnly)
@@ -42,7 +75,7 @@ export function ChannelGeneralAccess({
{channel.visibility === "public" ? (
-
+ communityAvatar
) : (
)}
@@ -67,16 +100,22 @@ export function ChannelGeneralAccess({
);
return (
-
+
);
}
function ChannelGeneralAccessEditor({
capability,
channelId,
+ communityAvatar,
}: {
capability: ChannelDetailsCapability;
channelId: string;
+ communityAvatar: ReactNode;
}) {
const attempt = useSyncExternalStore(capability.subscribe, () =>
capability.snapshot(channelId),
@@ -181,7 +220,7 @@ function ChannelGeneralAccessEditor({
className="motion-safe:animate-spin"
/>
) : base?.visibility === "public" ? (
-
+ communityAvatar
) : (
)}
@@ -201,7 +240,7 @@ function ChannelGeneralAccessEditor({
{
value: "public",
label: "Anyone in the community",
- leading: ,
+ leading: communityAvatar,
},
{
value: "private",
@@ -232,7 +271,7 @@ function ChannelGeneralAccessEditor({
{generalAccess && (
{current && membersOpen && (
{
registerCanvas(ctx);
From 4f973599a0bfbc562d5462d464f384e3f83491bd Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:24:06 -0700
Subject: [PATCH 18/63] fix(channels): center access icons and enlarge
community artwork
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 35 ++++++++++---------
.../channels/ChannelMembersDialog.module.css | 3 ++
2 files changed, 21 insertions(+), 17 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index b987b88447..bf4f498cd2 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -55,27 +55,28 @@ export function ChannelGeneralAccess({
(entry) => communityDestination(entry.id).url === origin,
),
);
- const communityAvatar = community ? (
-
- ) : (
-
- );
+ const communityAvatar = (size: "compact" | "default") =>
+ community ? (
+
+ ) : (
+
+ );
// Public previews and Guests can inspect signed discovery metadata without
// calling the membership/write-gated channel-details capability.
if (channel?.readOnly)
return (
General access
-
-
+
+
{channel.visibility === "public" ? (
- communityAvatar
+ communityAvatar("default")
) : (
)}
@@ -115,7 +116,7 @@ function ChannelGeneralAccessEditor({
}: {
capability: ChannelDetailsCapability;
channelId: string;
- communityAvatar: ReactNode;
+ communityAvatar: (size: "compact" | "default") => ReactNode;
}) {
const attempt = useSyncExternalStore(capability.subscribe, () =>
capability.snapshot(channelId),
@@ -220,7 +221,7 @@ function ChannelGeneralAccessEditor({
className="motion-safe:animate-spin"
/>
) : base?.visibility === "public" ? (
- communityAvatar
+ communityAvatar("default")
) : (
)}
@@ -240,7 +241,7 @@ function ChannelGeneralAccessEditor({
{
value: "public",
label: "Anyone in the community",
- leading: communityAvatar,
+ leading: communityAvatar("compact"),
},
{
value: "private",
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index d5aad53921..f265fb513d 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -174,7 +174,10 @@
}
.accessIcon {
display: flex;
+ align-self: center;
align-items: center;
+ justify-content: center;
+ width: var(--size-control-sm);
min-height: var(--size-control-sm);
color: var(--text-subtle);
}
From 8504f46cecd2fa7de309cf6e47d05df1b5c9c198 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:25:36 -0700
Subject: [PATCH 19/63] fix(channels): name private access by channel
membership
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.test.tsx | 8 ++++----
src/bundled/channels/ChannelGeneralAccess.tsx | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 6801d4588d..5421ccb613 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -95,7 +95,7 @@ it.each(["public", "private"] as const)(
).toHaveTextContent(
visibility === "public"
? "Anyone in the community can read."
- : "Only invited people can read.",
+ : "Only channel members can read.",
);
expect(
screen.getByRole("region", { name: "General access" }),
@@ -169,7 +169,7 @@ it.each(["public", "private"] as const)(
);
const option =
visibility === "public"
- ? "Invited people only"
+ ? "Channel members only"
: "Anyone in the community";
const control = await select(user, "Who can read", option);
const confirm = screen.getByRole("alertdialog", {
@@ -189,7 +189,7 @@ it.each(["public", "private"] as const)(
expect(control).toHaveTextContent(
visibility === "public"
? "Anyone in the community"
- : "Invited people only",
+ : "Channel members only",
);
await select(user, "Who can read", option);
const next = {
@@ -225,7 +225,7 @@ it("confirms new people's View only/Participate access independently of private
);
expect(
screen.getByRole("combobox", { name: "Who can read" }),
- ).toHaveTextContent("Invited people only");
+ ).toHaveTextContent("Channel members only");
});
it("shows the absent Member default without saving, and a declined save reloads current access", async () => {
const h = harness();
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index bf4f498cd2..2aa4c2d5bc 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -86,7 +86,7 @@ export function ChannelGeneralAccess({
{channel.visibility === "public"
? "Anyone in the community can read."
: channel.visibility === "private"
- ? "Only invited people can read."
+ ? "Only channel members can read."
: "Reading access could not be confirmed."}
@@ -245,7 +245,7 @@ function ChannelGeneralAccessEditor({
},
{
value: "private",
- label: "Invited people only",
+ label: "Channel members only",
leading: ,
},
],
@@ -266,7 +266,7 @@ function ChannelGeneralAccessEditor({
: "Channel access could not be confirmed."
: base.visibility === "public"
? `Anyone in the community can find and ${base.joinRole === "guest" ? "view" : "participate"}.`
- : `Invited people can ${base.joinRole === "guest" ? "view" : "participate"}.`}
+ : `Channel members can ${base.joinRole === "guest" ? "view" : "participate"}.`}
From 99e88159892a2b33c0d9d4f5161e0eafa3e20041 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:38:57 -0700
Subject: [PATCH 20/63] fix(channels): render audience choice as a larger text
link
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 3 ++-
src/bundled/channels/ChannelMembersDialog.module.css | 3 ---
src/shared/design-system/DESIGN.md | 2 ++
src/shared/design-system/styles/forms.css | 3 +++
src/shared/design-system/ui/Select.tsx | 3 ++-
5 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 2aa4c2d5bc..2201869cca 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -229,7 +229,8 @@ function ChannelGeneralAccessEditor({
* {
max-width: 100%;
}
-.accessAudience > p {
- padding-inline: var(--space-4);
-}
.accessRole {
min-width: 0;
align-self: center;
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index f293015db3..55af7fc150 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -888,6 +888,8 @@ horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"`
for an unframed label; shared hover, disabled, and keyboard focus still apply. Compact Selects also expose
`appearance="ghost"` for an unframed, labelled row choice, such as channel access;
the existing Button owns its hover, disabled, and focus states.
+Use `appearance="link"` for a text-and-caret choice without a capsule or horizontal
+button inset; it retains the Select's keyboard behavior and focus treatment.
Button defaults to `md` (40px minimum height); IconButton defaults to `sm`
(32px square with 16px artwork) and a 10px rounded rectangle
diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css
index f87fc7bd7d..172ad1d52c 100644
--- a/src/shared/design-system/styles/forms.css
+++ b/src/shared/design-system/styles/forms.css
@@ -301,6 +301,9 @@
inline-size: var(--space-5);
block-size: var(--space-5);
}
+ .buzz-select[data-appearance="link"] .buzz-button {
+ padding-inline: 0;
+ }
/* Artwork provides its own visual inset; keep its edge closer to the frame. */
.buzz-select .buzz-button:has(.buzz-select-leading),
.buzz-select .buzz-select-trigger:has(.buzz-select-leading) {
diff --git a/src/shared/design-system/ui/Select.tsx b/src/shared/design-system/ui/Select.tsx
index 15102c8eaf..f212d3124f 100644
--- a/src/shared/design-system/ui/Select.tsx
+++ b/src/shared/design-system/ui/Select.tsx
@@ -58,7 +58,7 @@ export function Select({
variant?: "inline" | "field" | "compact" | "preference";
/** Match adjacent controls without changing the selection treatment. */
size?: "xs" | "sm" | "md";
- appearance?: "outlined" | "ghost" | "glass-ghost";
+ appearance?: "outlined" | "ghost" | "glass-ghost" | "link";
disabled?: boolean;
value: string;
groups: readonly SelectGroup[];
@@ -75,6 +75,7 @@ export function Select({
data-buzz-ui=""
className="buzz-select text-body"
data-variant={variant}
+ data-appearance={appearance}
>
Date: Wed, 28 Oct 2026 19:39:20 -0700
Subject: [PATCH 21/63] fix(channels): tighten audience caption spacing
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.module.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 658becf196..9d02dffc3d 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -185,7 +185,7 @@
display: grid;
min-width: 0;
justify-items: start;
- gap: var(--space-1);
+ gap: 0;
}
.accessAudience > * {
max-width: 100%;
From de8b1f7b770c2edcfe8536c5726cdd2325c4b183 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:41:47 -0700
Subject: [PATCH 22/63] feat(channels): add role choices beside member
invitations
Offer View only and Admin through a split Add menu with explicit confirmation and fresh signed role checks. Preserve ordinary additions and keep identity labels readable at narrow widths. Center access artwork in its full gutter beside the text-link audience control.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/ChannelMembersDialog.module.css | 23 ++-
src/bundled/channels/ChannelMembersDialog.tsx | 170 ++++++++++++------
.../channels/MemberAdministration.test.tsx | 115 +++++++++++-
.../administration-protocol.ts | 22 ++-
.../channel-members/administration.test.ts | 68 +++++++
.../design-system/styles/components.css | 13 ++
src/shared/design-system/ui/SplitButton.tsx | 14 +-
src/shared/design-system/ui/controls.test.tsx | 32 ++++
src/shared/design-system/ui/registry.ts | 1 +
.../design-system/ui/ButtonSpecimens.tsx | 14 ++
10 files changed, 403 insertions(+), 69 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 9d02dffc3d..fc562a7772 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -168,9 +168,12 @@
}
.accessRow {
display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-columns:
+ calc(var(--size-control-sm) + var(--space-2)) minmax(0, 1fr)
+ auto;
align-items: start;
gap: var(--space-2);
+ column-gap: 0;
}
.accessIcon {
display: flex;
@@ -181,6 +184,9 @@
min-height: var(--size-control-sm);
color: var(--text-subtle);
}
+.accessRow > .accessIcon {
+ width: 100%;
+}
.accessAudience {
display: grid;
min-width: 0;
@@ -193,13 +199,26 @@
.accessRole {
min-width: 0;
align-self: center;
+ margin-inline-start: var(--space-2);
}
@media (max-width: 480px) {
+ .member:has(> .addAction[data-split]) .staticProfile > span > span {
+ flex-direction: column;
+ align-items: start;
+ gap: 0;
+ }
+ .member:has(> .addAction[data-split]) .name {
+ max-width: 100%;
+ }
.accessRow {
- grid-template-columns: auto minmax(0, 1fr);
+ grid-template-columns: calc(var(--size-control-sm) + var(--space-2)) minmax(
+ 0,
+ 1fr
+ );
}
.accessRole {
grid-column: 2;
justify-self: start;
+ margin-inline-start: 0;
}
}
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index 988260d854..a85cafb74c 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -41,8 +41,7 @@ import styles from "./ChannelMembersDialog.module.css";
import { SoloOwnerAvatar, soloOwnerLabel } from "./ChatSharedMark";
import { MEMBER_SEARCH_PAGE_SIZE } from "../../features/channel-members/search";
import {
- canManageMember,
- canRemoveMember,
+ authorizeMemberChange,
type MemberChange,
} from "../../features/channel-members/administration-protocol";
import {
@@ -58,7 +57,9 @@ import {
import { Dialog } from "../../shared/design-system/ui/Dialog";
import { InlineHeader } from "../../shared/design-system/ui/Header";
import { IconButton } from "../../shared/design-system/ui/IconButton";
-import { Button } from "../../shared/design-system/ui/Button";
+import { Button, type ButtonProps } from "../../shared/design-system/ui/Button";
+import { SplitButton } from "../../shared/design-system/ui/SplitButton";
+import { MenuItem } from "../../shared/design-system/ui/Menu";
import { Select } from "../../shared/design-system/ui/Select";
import { SearchField } from "../../shared/design-system/ui/SearchField";
import { Avatar } from "../../shared/design-system/ui/Avatar";
@@ -157,6 +158,7 @@ const MemberIdentityRow = memo(function MemberIdentityRow({
add,
busy,
addDisabled,
+ canAddWithRole,
highlightId,
highlighted,
onHighlightMove,
@@ -194,11 +196,30 @@ const MemberIdentityRow = memo(function MemberIdentityRow({
add(key: string): Promise;
busy: boolean;
addDisabled: boolean;
+ canAddWithRole: boolean;
/** Set on rows the search highlight can choose. */
highlightId?: string | undefined;
highlighted?: boolean | undefined;
onHighlightMove?: ((key: string, event: PointerEvent) => void) | undefined;
}) {
+ const addButtonProps = {
+ variant: "prominent",
+ size: "xs",
+ "aria-label": `Add ${name} (${keyLabel})`,
+ "aria-disabled": busy || undefined,
+ disabled: addDisabled,
+ onBlur: (event) => {
+ if (focusedAdd.current?.button === event.currentTarget)
+ focusedAdd.current = null;
+ },
+ onClick: (event) => {
+ if (busy) return;
+ if (document.activeElement === event.currentTarget)
+ focusedAdd.current = { key: pubkey, button: event.currentTarget };
+ void add(pubkey);
+ },
+ children: busy ? "Adding…" : "Add",
+ } satisfies ButtonProps;
const avatar = (
- {
- if (focusedAdd.current?.button === event.currentTarget)
- focusedAdd.current = null;
- }}
- onClick={(event) => {
- if (busy) return;
- if (document.activeElement === event.currentTarget)
- focusedAdd.current = {
- key: pubkey,
- button: event.currentTarget,
- };
- void add(pubkey);
- }}
- >
- {busy ? "Adding…" : "Add"}
-
-
+
+ {canAddWithRole ? (
+
+
+ onChange({
+ pubkey,
+ expectedRole: "absent",
+ role: "guest",
+ })
+ }
+ >
+ View only
+
+
+ onChange({
+ pubkey,
+ expectedRole: "absent",
+ role: "admin",
+ })
+ }
+ >
+ Admin
+
+ >
+ }
+ />
+ ) : (
+
+ )}
+
) : undefined
}
>
@@ -729,6 +766,11 @@ export function ChannelMembersDialog({
memberKey,
!!channel && !channel.cached,
);
+ if (
+ administration.operation?.change.expectedRole === "absent" &&
+ administration.operation.status === "pending"
+ )
+ busy.add(administration.operation.change.pubkey);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh retries missing names with the other dialog reads.
useEffect(() => {
setNameError("");
@@ -1019,25 +1061,27 @@ export function ChannelMembersDialog({
.sort()
.join(":");
const ownership = useMemberOwners(session, agentKeys, refresh);
- const changePermitted =
+ let changePermitted = false;
+ if (
selection &&
+ (selection.expectedRole !== "absent" ||
+ !archiveHides(session.archives, selection.pubkey, session.viewer)) &&
administration.status === "ready" &&
- administration.authority.roles[selection.pubkey] ===
- selection.expectedRole &&
administration.operation?.status !== "pending" &&
- administration.operation?.status !== "uncertain" &&
- (selection.role === "remove"
- ? canRemoveMember(
- administration.authority,
- session.viewer ?? "",
- selection.pubkey,
- ownership.owners.get(selection.pubkey),
- )
- : canManageMember(
- administration.authority,
- session.viewer ?? "",
- selection.pubkey,
- ));
+ administration.operation?.status !== "uncertain"
+ ) {
+ try {
+ authorizeMemberChange(
+ administration.authority,
+ session.viewer ?? "",
+ selection,
+ ownership.owners.get(selection.pubkey),
+ );
+ changePermitted = true;
+ } catch {
+ // A changed roster or authority leaves the confirmation read-only.
+ }
+ }
const refreshing =
ownership.busy ||
rosterBusy ||
@@ -1361,7 +1405,20 @@ export function ChannelMembersDialog({
messagePending={openingMessage}
add={add}
busy={busy.has(key)}
- addDisabled={rosterBusy || !!rosterError}
+ addDisabled={
+ rosterBusy ||
+ !!rosterError ||
+ administration.operation?.status === "pending" ||
+ administration.operation?.status === "uncertain"
+ }
+ canAddWithRole={
+ !solo &&
+ session.memberAdministration.available &&
+ administration.status === "ready" &&
+ administration.authority.canManage &&
+ administration.operation?.status !== "pending" &&
+ administration.operation?.status !== "uncertain"
+ }
highlightId={adding ? highlight.rowId(key) : undefined}
highlighted={adding && highlight.active === key}
onHighlightMove={adding ? moveHighlight : undefined}
@@ -1373,6 +1430,9 @@ export function ChannelMembersDialog({
(known.has(selection.pubkey) || profiles.get(selection.pubkey)?.isAgent);
const removalTarget = selectedAgent ? "agent" : "member";
const selectedPicture = selection && profiles.get(selection.pubkey)?.picture;
+ const selectedName =
+ selection &&
+ label(selection.pubkey, candidates.get(selection.pubkey)?.name);
/** A name in a channel share's audience; access actions stay on member rows. */
const audienceRow = (key: string) => (
@@ -1420,7 +1480,9 @@ export function ChannelMembersDialog({
selection
? selection.role === "remove"
? `Remove ${removalTarget} from ${place}`
- : "Change member role?"
+ : selection.expectedRole === "absent"
+ ? "Add member?"
+ : "Change member role?"
: (title ??
(solo
? "Share access"
@@ -1441,14 +1503,12 @@ export function ChannelMembersDialog({
-
- {label(selection.pubkey)}
-
+ {selectedName}
) : (
description
@@ -1498,7 +1558,11 @@ export function ChannelMembersDialog({
>
{selection.role === "remove"
? `Remove ${removalTarget}`
- : `Make ${selection.role}`}
+ : selection.expectedRole === "absent"
+ ? selection.role === "admin"
+ ? "Add as admin"
+ : "Add with view-only access"
+ : `Make ${selection.role}`}
>
) : solo ? (
@@ -1559,7 +1623,11 @@ export function ChannelMembersDialog({
{selection ? (
selection.role !== "remove" && (
- {`Change this member’s role from ${selection.expectedRole} to ${selection.role}. ${selection.role === "admin" ? `Admins can manage this ${place} and its members.` : `This changes their authority in this ${place}.`}`}
+ {selection.expectedRole === "absent"
+ ? selection.role === "admin"
+ ? "Admins can manage this channel and its members."
+ : "Viewing only allows reading messages and threads."
+ : `Change this member’s role from ${selection.expectedRole} to ${selection.role}. ${selection.role === "admin" ? `Admins can manage this ${place} and its members.` : `This changes their authority in this ${place}.`}`}
)
) : (
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 3054f366b6..4122c91014 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -94,7 +94,8 @@ async function setup(
: profile(target, profileBody);
const viewerProfile = profile(viewer, { name: "Carl" });
const ownerProfile = profile(owner, { name: "Owner" });
- let role: string | undefined = targetRole;
+ let role: string | undefined =
+ targetRole === "absent" ? undefined : targetRole;
let tick = 1700000000;
let lag = false;
const members = () => [
@@ -143,6 +144,8 @@ async function setup(
});
let dialogReads = false;
const query = vi.fn(async (filters: Parameters[1][]) => {
+ if (targetRole === "absent" && filters.some((filter) => filter.search))
+ return [targetProfile];
if (filters.some((filter) => filter.kinds?.includes(39001))) await roleRead;
if (filters.some((filter) => filter.kinds?.includes(0)))
await initialReads?.names;
@@ -211,7 +214,8 @@ async function setup(
await user.click(screen.getByRole("button", { name: "Access" }));
if (roleRead || initialReads)
await screen.findByRole("status", { name: "Loading members" });
- else await screen.findByText("Morgan");
+ else
+ await screen.findByText(targetRole === "absent" ? "Carl (you)" : "Morgan");
if (!initialReads)
await vi.waitFor(() =>
expect(session.memberAdministration.snapshot(id).status).toBe(
@@ -263,6 +267,113 @@ async function setup(
},
};
}
+it.each([
+ ["owner", "guest", true],
+ ["owner", "admin", true],
+ ["admin", "guest", false],
+ ["admin", "admin", false],
+] as const)(
+ "lets an %s add someone directly as %s from the split menu (Agent: %s)",
+ async (actor, role, agent) => {
+ const t = await setup(actor, "absent", true, undefined, agent);
+ await t.user.type(screen.getByRole("searchbox"), "Morgan");
+ await t.user.click(
+ await screen.findByRole("button", { name: /^Add Morgan .* as$/ }),
+ );
+ await t.user.click(
+ await screen.findByRole("menuitem", {
+ name: role === "guest" ? "View only" : "Admin",
+ }),
+ );
+ const dialog = await screen.findByRole("dialog", { name: "Add member?" });
+ expect(dialog).toHaveAccessibleDescription("Morgan");
+ expect(t.publish).not.toHaveBeenCalled();
+ await vi.waitFor(() =>
+ expect(
+ within(dialog).getByRole("button", { name: "Cancel" }),
+ ).toHaveFocus(),
+ );
+ await t.user.click(
+ within(dialog).getByRole("button", {
+ name: role === "guest" ? "Add with view-only access" : "Add as admin",
+ }),
+ );
+ await vi.waitFor(() =>
+ expect(
+ t.session.memberAdministration.snapshot(id).operation?.status,
+ ).toBe("confirmed"),
+ );
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.publish.mock.calls[0]?.[0].tags).toEqual([
+ ["h", id],
+ ["p", t.target.pubkey],
+ ["role", role],
+ ]);
+ await vi.waitFor(() =>
+ expect(screen.getByText("Morgan").closest("li")).toHaveTextContent(
+ role === "guest" ? "Viewing only" : "Admin",
+ ),
+ );
+ },
+);
+
+it("cancels an addition from the split menu and retains the search and keyboard focus", async () => {
+ const t = await setup("owner", "absent");
+ await t.user.type(screen.getByRole("searchbox"), "Morgan");
+ const trigger = await screen.findByRole("button", {
+ name: /^Add Morgan .* as$/,
+ });
+ trigger.focus();
+ await t.user.keyboard("{Enter}");
+ await t.user.click(await screen.findByRole("menuitem", { name: "Admin" }));
+ const dialog = await screen.findByRole("dialog", { name: "Add member?" });
+ await t.user.click(within(dialog).getByRole("button", { name: "Cancel" }));
+ await vi.waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
+ expect(screen.getByRole("searchbox")).toHaveValue("Morgan");
+ expect(t.publish).not.toHaveBeenCalled();
+ await t.user.click(
+ screen.getByRole("button", { name: /^Add Morgan .* as$/ }),
+ );
+ await t.user.keyboard("{Escape}");
+ await vi.waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: /^Add Morgan .* as$/ }),
+ ).toHaveFocus(),
+ );
+});
+
+it.each(["member", "guest"])(
+ "hides role additions from a %s",
+ async (actor) => {
+ const t = await setup(actor, "absent");
+ await t.user.type(screen.getByRole("searchbox"), "Morgan");
+ if (actor === "member")
+ await screen.findByRole("button", { name: /^Add Morgan \(/ });
+ expect(
+ screen.queryByRole("button", { name: /^Add Morgan .* as$/ }),
+ ).not.toBeInTheDocument();
+ expect(t.publish).not.toHaveBeenCalled();
+ },
+);
+
+it("disables an addition confirmation if the person joins with a different role", async () => {
+ const t = await setup("owner", "absent");
+ await t.user.type(screen.getByRole("searchbox"), "Morgan");
+ await t.user.click(
+ await screen.findByRole("button", { name: /^Add Morgan .* as$/ }),
+ );
+ await t.user.click(await screen.findByRole("menuitem", { name: "Admin" }));
+ const dialog = await screen.findByRole("dialog", { name: "Add member?" });
+ await act(async () => {
+ t.confirm("member");
+ await t.session.memberAdministration.refresh(id);
+ });
+ expect(
+ within(dialog).getByRole("button", { name: "Add as admin" }),
+ ).toBeDisabled();
+ expect(t.publish).not.toHaveBeenCalled();
+});
+
it.each([
["member", "admin"],
["admin", "member"],
diff --git a/src/features/channel-members/administration-protocol.ts b/src/features/channel-members/administration-protocol.ts
index 37e265a96d..5fe8bf6a68 100644
--- a/src/features/channel-members/administration-protocol.ts
+++ b/src/features/channel-members/administration-protocol.ts
@@ -14,9 +14,8 @@ export type EditableMemberRole = (typeof editableMemberRoles)[number];
export type MemberRole = EditableMemberRole | "owner" | "bot" | "unknown";
export type MemberChange = Readonly<{
pubkey: string;
- /** `none`: the key holds no role, so the change adds it (system channels
- * only). A missing role never stands in for this. */
- expectedRole: MemberRole | "none";
+ /** `none` is reserved for system setup; `absent` is an explicit role addition. */
+ expectedRole: MemberRole | "none" | "absent";
role: EditableMemberRole | "remove";
}>;
export type MemberAuthority = Readonly<{
@@ -146,7 +145,7 @@ export function authorizeMemberChange(
owner?: string,
) {
if (change.expectedRole === "none") {
- // Ordinary channels add members only through `addChannelMember`.
+ // This sentinel is reserved for the system-channel setup path.
if (
!state.system ||
!state.canManage ||
@@ -161,10 +160,17 @@ export function authorizeMemberChange(
return;
}
if (
- !(change.role === "remove"
- ? canRemoveMember(state, viewer, change.pubkey, owner)
- : canManageMember(state, viewer, change.pubkey)) ||
- state.roles[change.pubkey] !== change.expectedRole ||
+ !(change.expectedRole === "absent"
+ ? change.role !== "remove" &&
+ state.canManage &&
+ PUBKEY.test(change.pubkey) &&
+ change.pubkey !== viewer &&
+ !(change.pubkey in state.roles)
+ : change.role === "remove"
+ ? canRemoveMember(state, viewer, change.pubkey, owner)
+ : canManageMember(state, viewer, change.pubkey)) ||
+ (change.expectedRole !== "absent" &&
+ state.roles[change.pubkey] !== change.expectedRole) ||
(change.role !== "remove" &&
(change.expectedRole === change.role ||
!editableMemberRoles.includes(change.role)))
diff --git a/src/features/channel-members/administration.test.ts b/src/features/channel-members/administration.test.ts
index 893bdb582a..da5730a611 100644
--- a/src/features/channel-members/administration.test.ts
+++ b/src/features/channel-members/administration.test.ts
@@ -133,6 +133,74 @@ it.each(["admin", "remove"] as const)(
},
);
+it.each(["guest", "admin"] as const)(
+ "adds an absent person directly as %s with exact signed readback",
+ async (role) => {
+ const h = harness();
+ h.setRoles("owner");
+ await h.owner.capability.run(id, {
+ ...change,
+ expectedRole: "absent",
+ role,
+ });
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.publish.mock.calls[0]?.[0].tags).toEqual([
+ ["h", id],
+ ["p", target],
+ ["role", role],
+ ]);
+ expect(h.owner.capability.snapshot(id).operation?.status).toBe("confirmed");
+ expect(h.owner.capability.snapshot(id).authority.roles[target]).toBe(role);
+ },
+);
+
+it.each(["member", "guest", "bot", "unknown"])(
+ "refuses explicit-role additions by a %s",
+ async (actor) => {
+ const h = harness(actor);
+ h.setRoles(actor);
+ await h.owner.capability.run(id, { ...change, expectedRole: "absent" });
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ },
+);
+
+it.each(["guest", "member", "admin", "owner", "bot", "unknown"])(
+ "never treats an existing %s as an absent addition",
+ async (role) => {
+ const h = harness("owner", role);
+ await h.owner.capability.run(id, { ...change, expectedRole: "absent" });
+ expect(h.sign).not.toHaveBeenCalled();
+ },
+);
+
+it.each(["membership", "authority"])(
+ "rechecks %s after signing an explicit-role addition",
+ async (changed) => {
+ const h = harness();
+ h.setRoles("owner");
+ const sign = required(h.sign.getMockImplementation());
+ h.sign.mockImplementationOnce(async (template) => {
+ if (changed === "membership") h.setRoles("owner", "member");
+ else h.setRoles("member");
+ return sign(template);
+ });
+ await h.owner.capability.run(id, { ...change, expectedRole: "absent" });
+ expect(h.sign).toHaveBeenCalledOnce();
+ expect(h.publish).not.toHaveBeenCalled();
+ },
+);
+
+it("rejects removal with an absent expectation", async () => {
+ const h = harness();
+ await h.owner.capability.run(id, {
+ ...change,
+ expectedRole: "absent",
+ role: "remove",
+ });
+ expect(h.sign).not.toHaveBeenCalled();
+});
+
it.each(["admin", "member", "guest", "remove"] as const)(
"confirms %s from exact fresh signed state, never acknowledgement alone",
async (role) => {
diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css
index b5d35b2c5a..8618a43b97 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -2,6 +2,19 @@
@import "./tab-drag.css";
@layer components {
+ .buzz-split-button {
+ display: inline-flex;
+ gap: 1px;
+ }
+ .buzz-split-button > .buzz-button:first-child {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ }
+ .buzz-split-button > .buzz-button:last-child {
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+ }
+
.buzz-loading-region {
position: relative;
min-width: 0;
diff --git a/src/shared/design-system/ui/SplitButton.tsx b/src/shared/design-system/ui/SplitButton.tsx
index 4860477aba..cb3d97050e 100644
--- a/src/shared/design-system/ui/SplitButton.tsx
+++ b/src/shared/design-system/ui/SplitButton.tsx
@@ -3,25 +3,26 @@ import type { ReactNode } from "react";
import { Button, type ButtonProps } from "./Button";
import { MenuPopup, MenuRoot, MenuTrigger } from "./Menu";
-export type SplitButtonProps = Pick & {
+export type SplitButtonProps = Pick & {
/** The main action's label, which names the menu's current choice. */
children: ReactNode;
/** Names the menu button, such as "Choose agent type". */
menuLabel: string;
- /** The menu's items: choices that change what the main action does. */
+ /** The menu's items: option choices or related actions. */
menu: ReactNode;
variant?: "subtle" | "outline" | "prominent";
- size?: "sm" | "md";
+ size?: "xs" | "sm" | "md";
+ menuDisabled?: boolean | undefined;
loading?: boolean | undefined;
disabled?: boolean | undefined;
};
-/** An action joined to a menu of choices for it. The action runs with the
- * chosen option; choosing in the menu changes the option, not runs it. */
+/** An action joined to a separately named menu of options or related actions. */
export function SplitButton({
children,
menuLabel,
menu,
+ menuDisabled,
variant = "subtle",
size = "md",
loading = false,
@@ -41,6 +42,7 @@ export function SplitButton({
diff --git a/src/shared/design-system/ui/controls.test.tsx b/src/shared/design-system/ui/controls.test.tsx
index 102b96a062..691f606e98 100644
--- a/src/shared/design-system/ui/controls.test.tsx
+++ b/src/shared/design-system/ui/controls.test.tsx
@@ -5,6 +5,8 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { Button } from "./Button";
+import { SplitButton } from "./SplitButton";
+import { MenuItem } from "./Menu";
import { IconButton } from "./IconButton";
import { PlusIcon } from "../icons";
import { Field } from "./Field";
@@ -17,6 +19,36 @@ import { Composer } from "./Composer";
afterEach(cleanup);
+test("split actions keep separate activation, menu focus and pending gates", async () => {
+ const user = userEvent.setup();
+ const primary = vi.fn(),
+ alternative = vi.fn();
+ const props = {
+ menuLabel: "Add with other access",
+ onClick: primary,
+ menu: View only ,
+ children: "Add",
+ };
+ const view = render( );
+ const menu = screen.getByRole("button", { name: props.menuLabel });
+ await user.click(menu);
+ await user.click(await screen.findByRole("menuitem", { name: "View only" }));
+ expect(alternative).toHaveBeenCalledOnce();
+ expect(primary).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Add" }));
+ expect(primary).toHaveBeenCalledOnce();
+ menu.focus();
+ await user.keyboard("{Enter}{Escape}");
+ await waitFor(() => expect(menu).toHaveFocus());
+ view.rerender( );
+ expect(menu).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
+ view.rerender( );
+ expect(menu).toBeDisabled();
+ await user.click(screen.getByRole("button", { name: "Add" }));
+ expect(primary).toHaveBeenCalledOnce();
+});
+
test.each([
["sm", "sm"],
["md", "md"],
diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts
index 63e6fbb23f..e2829e51dd 100644
--- a/src/shared/design-system/ui/registry.ts
+++ b/src/shared/design-system/ui/registry.ts
@@ -471,6 +471,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"medium leading icon + label: 12px leading / 16px trailing inset (proposed)",
"shape: capsule (default) | control (12px) | icon (square, 10px)",
"SurfaceButton: artwork layout with shared activation and focus (proposed)",
+ "SplitButton: primary action with a separately named related-action menu",
"loading",
],
status: "proposed",
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 115d3df55b..3e41133cd9 100644
--- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
@@ -154,6 +154,20 @@ export function ButtonSpecimen() {
Continue
+
+
+ View only
+ Admin
+ >
+ }
+ >
+ Add
+
+
Date: Wed, 28 Oct 2026 19:42:41 -0700
Subject: [PATCH 23/63] fix(channels): balance community image spacing across
access views
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
src/bundled/channels/ChannelMembersDialog.module.css | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 2201869cca..085ea4850f 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -73,7 +73,7 @@ export function ChannelGeneralAccess({
return (
General access
-
+
{channel.visibility === "public" ? (
communityAvatar("default")
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index fc562a7772..e06a1c3594 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -169,7 +169,7 @@
.accessRow {
display: grid;
grid-template-columns:
- calc(var(--size-control-sm) + var(--space-2)) minmax(0, 1fr)
+ calc(var(--size-control-sm) + var(--space-4)) minmax(0, 1fr)
auto;
align-items: start;
gap: var(--space-2);
@@ -211,7 +211,7 @@
max-width: 100%;
}
.accessRow {
- grid-template-columns: calc(var(--size-control-sm) + var(--space-2)) minmax(
+ grid-template-columns: calc(var(--size-control-sm) + var(--space-4)) minmax(
0,
1fr
);
From 55d260bfa57cb03cbf1432046b6788df790c4239 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:43:10 -0700
Subject: [PATCH 24/63] fix(channels): add room around the community image
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.module.css | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index e06a1c3594..9f5c8b258c 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -169,7 +169,7 @@
.accessRow {
display: grid;
grid-template-columns:
- calc(var(--size-control-sm) + var(--space-4)) minmax(0, 1fr)
+ calc(var(--size-control-sm) + var(--space-6)) minmax(0, 1fr)
auto;
align-items: start;
gap: var(--space-2);
@@ -211,7 +211,7 @@
max-width: 100%;
}
.accessRow {
- grid-template-columns: calc(var(--size-control-sm) + var(--space-4)) minmax(
+ grid-template-columns: calc(var(--size-control-sm) + var(--space-6)) minmax(
0,
1fr
);
From c428c2553cc088047d8bbc1d4564eea84b1aa928 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 19:50:41 -0700
Subject: [PATCH 25/63] fix(channels): finish split Add menu states and role
choices
Match caret height and outer padding to Add, preserve joined corners while the menu inserts focus guards, and include Participate alongside View only and Admin.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.tsx | 78 ++++++++++---------
.../channels/MemberAdministration.test.tsx | 22 +++++-
.../channel-members/administration.test.ts | 2 +-
.../design-system/styles/components.css | 14 +++-
.../design-system/ui/ButtonSpecimens.tsx | 1 +
5 files changed, 77 insertions(+), 40 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index a85cafb74c..4fa61c424b 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -93,6 +93,27 @@ const typedMatch = (name: string, needle: string) =>
const INVITATION_GROUP_SIZE = 4;
+const additionRoles = [
+ {
+ role: "member",
+ label: "Participate",
+ action: "Add to channel",
+ description: "Participants can send messages, reply, and react.",
+ },
+ {
+ role: "guest",
+ label: "View only",
+ action: "Add with view-only access",
+ description: "Viewing only allows reading messages and threads.",
+ },
+ {
+ role: "admin",
+ label: "Admin",
+ action: "Add as admin",
+ description: "Admins can manage this channel and its members.",
+ },
+] as const;
+
/** Mounted rows share the same bounded presence owner as message bylines. */
function MemberAvatar({
session,
@@ -320,32 +341,20 @@ const MemberIdentityRow = memo(function MemberIdentityRow({
{...addButtonProps}
menuLabel={`Add ${name} (${keyLabel}) as`}
menuDisabled={busy}
- menu={
- <>
-
- onChange({
- pubkey,
- expectedRole: "absent",
- role: "guest",
- })
- }
- >
- View only
-
-
- onChange({
- pubkey,
- expectedRole: "absent",
- role: "admin",
- })
- }
- >
- Admin
-
- >
- }
+ menu={additionRoles.map((option) => (
+
+ onChange({
+ pubkey,
+ expectedRole: "absent",
+ role: option.role,
+ })
+ }
+ >
+ {option.label}
+
+ ))}
/>
) : (
@@ -1433,6 +1442,10 @@ export function ChannelMembersDialog({
const selectedName =
selection &&
label(selection.pubkey, candidates.get(selection.pubkey)?.name);
+ const selectedAddition =
+ selection?.expectedRole === "absent"
+ ? additionRoles.find((option) => option.role === selection.role)
+ : undefined;
/** A name in a channel share's audience; access actions stay on member rows. */
const audienceRow = (key: string) => (
@@ -1558,10 +1571,8 @@ export function ChannelMembersDialog({
>
{selection.role === "remove"
? `Remove ${removalTarget}`
- : selection.expectedRole === "absent"
- ? selection.role === "admin"
- ? "Add as admin"
- : "Add with view-only access"
+ : selectedAddition
+ ? selectedAddition.action
: `Make ${selection.role}`}
>
@@ -1623,11 +1634,8 @@ export function ChannelMembersDialog({
{selection ? (
selection.role !== "remove" && (
- {selection.expectedRole === "absent"
- ? selection.role === "admin"
- ? "Admins can manage this channel and its members."
- : "Viewing only allows reading messages and threads."
- : `Change this member’s role from ${selection.expectedRole} to ${selection.role}. ${selection.role === "admin" ? `Admins can manage this ${place} and its members.` : `This changes their authority in this ${place}.`}`}
+ {selectedAddition?.description ??
+ `Change this member’s role from ${selection.expectedRole} to ${selection.role}. ${selection.role === "admin" ? `Admins can manage this ${place} and its members.` : `This changes their authority in this ${place}.`}`}
)
) : (
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 4122c91014..7732cf5d83 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -268,9 +268,11 @@ async function setup(
};
}
it.each([
+ ["owner", "member", true],
["owner", "guest", true],
["owner", "admin", true],
["admin", "guest", false],
+ ["admin", "member", false],
["admin", "admin", false],
] as const)(
"lets an %s add someone directly as %s from the split menu (Agent: %s)",
@@ -282,7 +284,12 @@ it.each([
);
await t.user.click(
await screen.findByRole("menuitem", {
- name: role === "guest" ? "View only" : "Admin",
+ name:
+ role === "guest"
+ ? "View only"
+ : role === "member"
+ ? "Participate"
+ : "Admin",
}),
);
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
@@ -295,7 +302,12 @@ it.each([
);
await t.user.click(
within(dialog).getByRole("button", {
- name: role === "guest" ? "Add with view-only access" : "Add as admin",
+ name:
+ role === "guest"
+ ? "Add with view-only access"
+ : role === "member"
+ ? "Add to channel"
+ : "Add as admin",
}),
);
await vi.waitFor(() =>
@@ -311,7 +323,11 @@ it.each([
]);
await vi.waitFor(() =>
expect(screen.getByText("Morgan").closest("li")).toHaveTextContent(
- role === "guest" ? "Viewing only" : "Admin",
+ role === "guest"
+ ? "Viewing only"
+ : role === "member"
+ ? "Participating"
+ : "Admin",
),
);
},
diff --git a/src/features/channel-members/administration.test.ts b/src/features/channel-members/administration.test.ts
index da5730a611..53c585f24b 100644
--- a/src/features/channel-members/administration.test.ts
+++ b/src/features/channel-members/administration.test.ts
@@ -133,7 +133,7 @@ it.each(["admin", "remove"] as const)(
},
);
-it.each(["guest", "admin"] as const)(
+it.each(["guest", "admin", "member"] as const)(
"adds an absent person directly as %s with exact signed readback",
async (role) => {
const h = harness();
diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css
index 8618a43b97..292cf27a4f 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -10,9 +10,21 @@
border-start-end-radius: 0;
border-end-end-radius: 0;
}
- .buzz-split-button > .buzz-button:last-child {
+ .buzz-split-button > .buzz-button[aria-haspopup="menu"] {
border-start-start-radius: 0;
border-end-start-radius: 0;
+ border-start-end-radius: var(--radius-capsule);
+ border-end-end-radius: var(--radius-capsule);
+ }
+ /* The primary label owns height; icon-button dimensions must not shorten its segment. */
+ .buzz-split-button > .buzz-button[data-icon-variant][aria-haspopup="menu"] {
+ align-self: stretch;
+ height: auto;
+ min-height: 0;
+ }
+ .buzz-split-button > .buzz-button[data-icon-size="xs"][aria-haspopup="menu"] {
+ width: auto;
+ padding-inline: var(--space-2) var(--space-3);
}
.buzz-loading-region {
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 3e41133cd9..992e392873 100644
--- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
@@ -160,6 +160,7 @@ export function ButtonSpecimen() {
menuLabel="Other add options"
menu={
<>
+ Participate
View only
Admin
>
From 6f204e8f45933fb07482ad21d56a1eee8c559ab7 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 20:04:21 -0700
Subject: [PATCH 26/63] fix(channels): center split caret and clarify add
actions
Give the compact caret equal side insets that override leading-icon text padding. Use descriptive add actions consistently in the menu and confirmation.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.tsx | 7 ++-----
.../channels/MemberAdministration.test.tsx | 16 ++++++++++------
src/shared/design-system/styles/components.css | 5 +++--
.../design-system/ui/ButtonSpecimens.tsx | 6 +++---
4 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index 4fa61c424b..bacf698478 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -96,19 +96,16 @@ const INVITATION_GROUP_SIZE = 4;
const additionRoles = [
{
role: "member",
- label: "Participate",
- action: "Add to channel",
+ action: "Add as participant",
description: "Participants can send messages, reply, and react.",
},
{
role: "guest",
- label: "View only",
action: "Add with view-only access",
description: "Viewing only allows reading messages and threads.",
},
{
role: "admin",
- label: "Admin",
action: "Add as admin",
description: "Admins can manage this channel and its members.",
},
@@ -352,7 +349,7 @@ const MemberIdentityRow = memo(function MemberIdentityRow({
})
}
>
- {option.label}
+ {option.action}
))}
/>
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 7732cf5d83..26e849cc90 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -286,10 +286,10 @@ it.each([
await screen.findByRole("menuitem", {
name:
role === "guest"
- ? "View only"
+ ? "Add with view-only access"
: role === "member"
- ? "Participate"
- : "Admin",
+ ? "Add as participant"
+ : "Add as admin",
}),
);
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
@@ -306,7 +306,7 @@ it.each([
role === "guest"
? "Add with view-only access"
: role === "member"
- ? "Add to channel"
+ ? "Add as participant"
: "Add as admin",
}),
);
@@ -341,7 +341,9 @@ it("cancels an addition from the split menu and retains the search and keyboard
});
trigger.focus();
await t.user.keyboard("{Enter}");
- await t.user.click(await screen.findByRole("menuitem", { name: "Admin" }));
+ await t.user.click(
+ await screen.findByRole("menuitem", { name: "Add as admin" }),
+ );
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
await t.user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await vi.waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
@@ -378,7 +380,9 @@ it("disables an addition confirmation if the person joins with a different role"
await t.user.click(
await screen.findByRole("button", { name: /^Add Morgan .* as$/ }),
);
- await t.user.click(await screen.findByRole("menuitem", { name: "Admin" }));
+ await t.user.click(
+ await screen.findByRole("menuitem", { name: "Add as admin" }),
+ );
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
await act(async () => {
t.confirm("member");
diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css
index 292cf27a4f..79257dcd4a 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -22,9 +22,10 @@
height: auto;
min-height: 0;
}
- .buzz-split-button > .buzz-button[data-icon-size="xs"][aria-haspopup="menu"] {
+ .buzz-split-button
+ > .buzz-button[data-icon-variant][data-icon-size="xs"][aria-haspopup="menu"] {
width: auto;
- padding-inline: var(--space-2) var(--space-3);
+ padding-inline: var(--space-2);
}
.buzz-loading-region {
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 992e392873..251ed3702c 100644
--- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
@@ -160,9 +160,9 @@ export function ButtonSpecimen() {
menuLabel="Other add options"
menu={
<>
- Participate
- View only
- Admin
+ Add as participant
+ Add with view-only access
+ Add as admin
>
}
>
From 5023788075fef8c7ad50d3a67742f85811eeca53 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 20:07:23 -0700
Subject: [PATCH 27/63] fix(channels): describe roles beneath add menu heading
Group role additions under Add as and reuse ChoiceRow for the same Participate and View only language as general access, plus Admin guidance.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.tsx | 52 ++++++++++++-------
.../channels/MemberAdministration.test.tsx | 14 +++--
.../design-system/ui/ButtonSpecimens.tsx | 29 +++++++++--
3 files changed, 67 insertions(+), 28 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index bacf698478..50f7538ac1 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -59,7 +59,12 @@ import { InlineHeader } from "../../shared/design-system/ui/Header";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { Button, type ButtonProps } from "../../shared/design-system/ui/Button";
import { SplitButton } from "../../shared/design-system/ui/SplitButton";
-import { MenuItem } from "../../shared/design-system/ui/Menu";
+import {
+ MenuGroup,
+ MenuGroupLabel,
+ MenuItem,
+} from "../../shared/design-system/ui/Menu";
+import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
import { Select } from "../../shared/design-system/ui/Select";
import { SearchField } from "../../shared/design-system/ui/SearchField";
import { Avatar } from "../../shared/design-system/ui/Avatar";
@@ -96,18 +101,21 @@ const INVITATION_GROUP_SIZE = 4;
const additionRoles = [
{
role: "member",
+ label: "Participate",
action: "Add as participant",
- description: "Participants can send messages, reply, and react.",
+ description: "Send messages, reply, and react.",
},
{
role: "guest",
+ label: "View only",
action: "Add with view-only access",
- description: "Viewing only allows reading messages and threads.",
+ description: "Read messages and threads.",
},
{
role: "admin",
+ label: "Admin",
action: "Add as admin",
- description: "Admins can manage this channel and its members.",
+ description: "Manage this channel and its members.",
},
] as const;
@@ -338,20 +346,28 @@ const MemberIdentityRow = memo(function MemberIdentityRow({
{...addButtonProps}
menuLabel={`Add ${name} (${keyLabel}) as`}
menuDisabled={busy}
- menu={additionRoles.map((option) => (
-
- onChange({
- pubkey,
- expectedRole: "absent",
- role: option.role,
- })
- }
- >
- {option.action}
-
- ))}
+ menu={
+
+ Add as
+ {additionRoles.map((option) => (
+
+ onChange({
+ pubkey,
+ expectedRole: "absent",
+ role: option.role,
+ })
+ }
+ >
+
+
+ ))}
+
+ }
/>
) : (
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 26e849cc90..aeb80ee194 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -286,10 +286,10 @@ it.each([
await screen.findByRole("menuitem", {
name:
role === "guest"
- ? "Add with view-only access"
+ ? "View only Read messages and threads."
: role === "member"
- ? "Add as participant"
- : "Add as admin",
+ ? "Participate Send messages, reply, and react."
+ : "Admin Manage this channel and its members.",
}),
);
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
@@ -342,7 +342,9 @@ it("cancels an addition from the split menu and retains the search and keyboard
trigger.focus();
await t.user.keyboard("{Enter}");
await t.user.click(
- await screen.findByRole("menuitem", { name: "Add as admin" }),
+ await screen.findByRole("menuitem", {
+ name: "Admin Manage this channel and its members.",
+ }),
);
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
await t.user.click(within(dialog).getByRole("button", { name: "Cancel" }));
@@ -381,7 +383,9 @@ it("disables an addition confirmation if the person joins with a different role"
await screen.findByRole("button", { name: /^Add Morgan .* as$/ }),
);
await t.user.click(
- await screen.findByRole("menuitem", { name: "Add as admin" }),
+ await screen.findByRole("menuitem", {
+ name: "Admin Manage this channel and its members.",
+ }),
);
const dialog = await screen.findByRole("dialog", { name: "Add member?" });
await act(async () => {
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 251ed3702c..39e3a810dc 100644
--- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
@@ -19,9 +19,12 @@ import {
MenuItem,
MenuRadioGroup,
MenuRadioItem,
+ MenuGroup,
+ MenuGroupLabel,
} from "../../../../src/shared/design-system/ui/Menu";
import { SurfaceButton } from "../../../../src/shared/design-system/ui/SurfaceButton";
import { Button } from "../../../../src/shared/design-system/ui/Button";
+import { ChoiceRow } from "../../../../src/shared/design-system/ui/ChoiceRow";
import { IconButton } from "../../../../src/shared/design-system/ui/IconButton";
import { SplitButton } from "../../../../src/shared/design-system/ui/SplitButton";
@@ -159,11 +162,27 @@ export function ButtonSpecimen() {
variant="prominent"
menuLabel="Other add options"
menu={
- <>
- Add as participant
- Add with view-only access
- Add as admin
- >
+
+ Add as
+
+
+
+
+
+
+
+
+
+
}
>
Add
From 4017236069a91a359b3c663b3bcdd3bff2819bfe Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 20:09:43 -0700
Subject: [PATCH 28/63] fix(channels): name participant role in add menu
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.tsx | 2 +-
src/bundled/channels/MemberAdministration.test.tsx | 2 +-
tests/fixtures/design-system/ui/ButtonSpecimens.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index 50f7538ac1..ff13906a98 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -101,7 +101,7 @@ const INVITATION_GROUP_SIZE = 4;
const additionRoles = [
{
role: "member",
- label: "Participate",
+ label: "Participant",
action: "Add as participant",
description: "Send messages, reply, and react.",
},
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index aeb80ee194..18a17a5f3f 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -288,7 +288,7 @@ it.each([
role === "guest"
? "View only Read messages and threads."
: role === "member"
- ? "Participate Send messages, reply, and react."
+ ? "Participant Send messages, reply, and react."
: "Admin Manage this channel and its members.",
}),
);
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 39e3a810dc..6ae9919653 100644
--- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
@@ -166,7 +166,7 @@ export function ButtonSpecimen() {
Add as
From 95140ecb3a051299aa2d75835bb21977c9cb68d0 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 20:56:08 -0700
Subject: [PATCH 29/63] test(channels): locate guest access dialog by its
current title
Keep the guest browser journey aligned with the read-only access dialog. Reproduced the old title assertion failure in both Chromium and WebKit; all six permissions journeys pass with the current label.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
tests/browser/channel-permissions.spec.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/browser/channel-permissions.spec.mjs b/tests/browser/channel-permissions.spec.mjs
index 21675534f6..796425d321 100644
--- a/tests/browser/channel-permissions.spec.mjs
+++ b/tests/browser/channel-permissions.spec.mjs
@@ -175,7 +175,7 @@ test("a joined guest can read and leave without receiving a composer or Join pro
.getByRole("button", { name: "Manage members", exact: true })
.click();
const members = page.getByRole("dialog", {
- name: "Manage members",
+ name: "Channel access",
exact: true,
});
await expect(members.getByRole("searchbox")).toHaveAttribute(
From 7505ecd497f4ebe9f6283eb78f2d5628c003a2fd Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:07:09 -0700
Subject: [PATCH 30/63] fix(channels): reconcile permissions with current
shared contracts
Reuse the upstream split-button owner, preserve compact caret insets, and align strict discovery and read-only tests with the access contract. Remove the duplicated fixture property introduced by integration.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/ChannelMembersDialog.test.tsx | 1 -
.../ChannelPrivacyConfirmation.test.tsx | 2 +-
src/bundled/channels/ChannelsPage.tsx | 66 ++++++++++---------
src/features/relay/fold.test.ts | 1 +
src/features/relay/global-search.test.ts | 8 +--
src/features/relay/prepared.test.ts | 1 +
.../design-system/styles/components.css | 32 ++-------
src/shared/design-system/ui/SplitButton.tsx | 5 +-
src/shared/design-system/ui/registry.ts | 7 +-
9 files changed, 54 insertions(+), 69 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx
index 5b7731c33a..d58c6c4008 100644
--- a/src/bundled/channels/ChannelMembersDialog.test.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.test.tsx
@@ -238,7 +238,6 @@ async function setup(
relay,
person,
viewer,
- relay,
delayRoster: () => {
applyAddition = false;
},
diff --git a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
index 0be6d4bd18..43a17f4f16 100644
--- a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
+++ b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
@@ -119,7 +119,7 @@ it.each(["private", "public"] as const)(
await screen.findByRole("option", {
name:
choice === "private"
- ? "Invited people only"
+ ? "Channel members only"
: "Anyone in the community",
}),
);
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 116733004d..8340e0592f 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -1739,38 +1739,40 @@ function ChannelWorkspace({
}
/>
)}
- {current && current.viewerRole !== "guest" && (!current.readOnly || current.cached) && (
-
- ) : undefined
- }
- onSend={onComposerSend}
- focusRequest={composerFocus}
- />
- )}
+ {current &&
+ current.viewerRole !== "guest" &&
+ (!current.readOnly || current.cached) && (
+
+ ) : undefined
+ }
+ onSend={onComposerSend}
+ focusRequest={composerFocus}
+ />
+ )}
>
);
const workspace = (
diff --git a/src/features/relay/fold.test.ts b/src/features/relay/fold.test.ts
index c81b85eb0c..7e0ad270a0 100644
--- a/src/features/relay/fold.test.ts
+++ b/src/features/relay/fold.test.ts
@@ -828,6 +828,7 @@ describe("discovery", () => {
name: "Alpha",
description: "",
visibility: undefined,
+ joinRole: "member",
members: [alice.pubkey, bob.pubkey].sort(),
},
{ id: "zeta", name: "zeta".slice(0, 8), members: [alice.pubkey] },
diff --git a/src/features/relay/global-search.test.ts b/src/features/relay/global-search.test.ts
index d373f48da3..6ac5b41000 100644
--- a/src/features/relay/global-search.test.ts
+++ b/src/features/relay/global-search.test.ts
@@ -141,17 +141,17 @@ it("preserves one server ranking above 128 joined channels and opens a public ex
name: "Open open",
});
expect(() => owner.session.messages.send("open", "not joined")).toThrow(
- "Join the conversation",
+ "You don’t have write access to this conversation.",
);
expect(() =>
owner.session.messages.reply("open", root.id, "not joined"),
- ).toThrow("Join the conversation");
+ ).toThrow("You don’t have write access to this conversation.");
expect(() => owner.session.messages.react(reply.id, "👍")).toThrow(
- "Join the conversation",
+ "You don’t have write access to this conversation.",
);
expect(() =>
owner.session.messages.edit(reply.id, "not joined", reply.id),
- ).toThrow("Join the conversation");
+ ).toThrow("You don’t have write access to this conversation.");
const thread = owner.session.thread("open", reply.id, { exact: true });
const opening = thread.refresh();
// Exact target, overlays, ancestry, traversal: all use the existing owner.
diff --git a/src/features/relay/prepared.test.ts b/src/features/relay/prepared.test.ts
index 4eadd15c7a..9eba8e86d7 100644
--- a/src/features/relay/prepared.test.ts
+++ b/src/features/relay/prepared.test.ts
@@ -463,6 +463,7 @@ it("hides DM channels behind the NIP-29 hidden tag", async () => {
name: "DM",
description: "",
visibility: undefined,
+ joinRole: "member",
hidden: true,
members: [viewer.pubkey, alice.pubkey].sort(),
preview: undefined,
diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css
index 79257dcd4a..48c86844e8 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -2,32 +2,6 @@
@import "./tab-drag.css";
@layer components {
- .buzz-split-button {
- display: inline-flex;
- gap: 1px;
- }
- .buzz-split-button > .buzz-button:first-child {
- border-start-end-radius: 0;
- border-end-end-radius: 0;
- }
- .buzz-split-button > .buzz-button[aria-haspopup="menu"] {
- border-start-start-radius: 0;
- border-end-start-radius: 0;
- border-start-end-radius: var(--radius-capsule);
- border-end-end-radius: var(--radius-capsule);
- }
- /* The primary label owns height; icon-button dimensions must not shorten its segment. */
- .buzz-split-button > .buzz-button[data-icon-variant][aria-haspopup="menu"] {
- align-self: stretch;
- height: auto;
- min-height: 0;
- }
- .buzz-split-button
- > .buzz-button[data-icon-variant][data-icon-size="xs"][aria-haspopup="menu"] {
- width: auto;
- padding-inline: var(--space-2);
- }
-
.buzz-loading-region {
position: relative;
min-width: 0;
@@ -460,6 +434,12 @@
border-start-start-radius: 0;
border-end-start-radius: 0;
}
+ /* Compact icon-only menus keep equal insets rather than the leading-icon text inset. */
+ .buzz-split-button
+ > .buzz-button[data-icon-variant][data-icon-size="xs"][aria-haspopup="menu"] {
+ width: auto;
+ padding-inline: var(--space-2);
+ }
.buzz-split-button[data-variant="outline"] > .buzz-button:last-of-type {
margin-inline-start: -1px;
}
diff --git a/src/shared/design-system/ui/SplitButton.tsx b/src/shared/design-system/ui/SplitButton.tsx
index cb3d97050e..0ccfee8862 100644
--- a/src/shared/design-system/ui/SplitButton.tsx
+++ b/src/shared/design-system/ui/SplitButton.tsx
@@ -3,7 +3,10 @@ import type { ReactNode } from "react";
import { Button, type ButtonProps } from "./Button";
import { MenuPopup, MenuRoot, MenuTrigger } from "./Menu";
-export type SplitButtonProps = Pick & {
+export type SplitButtonProps = Pick<
+ ButtonProps,
+ "onClick" | "onBlur" | "aria-label" | "aria-disabled"
+> & {
/** The main action's label, which names the menu's current choice. */
children: ReactNode;
/** Names the menu button, such as "Choose agent type". */
diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts
index e2829e51dd..bdc81f4bbb 100644
--- a/src/shared/design-system/ui/registry.ts
+++ b/src/shared/design-system/ui/registry.ts
@@ -471,7 +471,6 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"medium leading icon + label: 12px leading / 16px trailing inset (proposed)",
"shape: capsule (default) | control (12px) | icon (square, 10px)",
"SurfaceButton: artwork layout with shared activation and focus (proposed)",
- "SplitButton: primary action with a separately named related-action menu",
"loading",
],
status: "proposed",
@@ -485,14 +484,14 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
slug: "split-button",
name: "SplitButton",
purpose:
- "Run an action with a chosen option, and change the option from the attached menu. The label names the current choice, so what the action does is always visible.",
+ "Run a primary action and open its options or related actions from an attached menu.",
behavior:
- "Composes Buzz Button and Menu as one joined capsule, including while the menu is open. Choosing in the menu changes the option without running the action; the menu is unavailable while the action loads.",
+ "Composes Buzz Button and Menu as one joined capsule, including while the menu is open. The caller owns option changes and related actions; the menu is unavailable while the action loads.",
variants: [
"subtle (default)",
"outline: halves share one stroke",
"prominent",
- "size: sm | md (default)",
+ "size: xs | sm | md (default)",
"loading",
"disabled",
],
From 27c5744ab9c8c7956910739b98f2729923ff6524 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:10:44 -0700
Subject: [PATCH 31/63] fix(channels): preserve cancellation errors before
publication
Keep AbortError identity when an edit is cancelled after signing or final authorization. Genuine pre-publication failures retain request-phase diagnostics, and uncertain publication handling stays intact.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/features/relay/channel-details.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/features/relay/channel-details.ts b/src/features/relay/channel-details.ts
index 6eda9a085f..21c0a56735 100644
--- a/src/features/relay/channel-details.ts
+++ b/src/features/relay/channel-details.ts
@@ -266,6 +266,7 @@ export function createChannelDetails({
throw new Error(uncertain);
}
attempts.delete(id);
+ if (error instanceof Error && error.name === "AbortError") throw error;
if (!publicationStarted)
throw new Error(
`${phase}: ${error instanceof Error ? error.message : String(error)}`,
From d833df2b464a49a81ba04a94b3f70a6a9ea358af Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:26:15 -0700
Subject: [PATCH 32/63] fix(channels): preserve guest membership commands
Keep membership commands on their relay-authorized path so Guest owners can remove agents without gaining posting rights. Cover membership requests and fresh roster confirmation through the existing agent-removal workflow.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../relay/session-permissions.test.ts | 58 ++++++++++++++++++-
src/features/relay/session.ts | 3 +
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/src/features/relay/session-permissions.test.ts b/src/features/relay/session-permissions.test.ts
index 838afebae4..8ae061fbb9 100644
--- a/src/features/relay/session-permissions.test.ts
+++ b/src/features/relay/session-permissions.test.ts
@@ -6,6 +6,7 @@ import { matchesEvent } from "./projection";
import { keypair, message, signed } from "./testing";
import type { RelayEvent } from "./events";
import type { AttachmentUpload } from "./attachments";
+import { removeAgentFromChannels } from "../agents/relay-removal";
const id = "11111111-1111-4111-8111-111111111111";
const owners: ReturnType[] = [];
@@ -25,11 +26,13 @@ async function setup(
) {
const viewer = keypair(),
relay = keypair(),
- peer = keypair();
+ peer = keypair(),
+ agent = keypair();
let role = initialRole,
member = true,
joinRole = "guest",
- time = 1700000000;
+ time = 1700000000,
+ agentJoined = true;
const own = message(viewer, id, "Earlier message", time);
const root = message(peer, id, "Announcement", time);
const events = () => [
@@ -62,6 +65,7 @@ async function setup(
["d", id],
["p", peer.pubkey, "", "owner"],
...(member ? [["p", viewer.pubkey, "", role]] : []),
+ ...(agentJoined ? [["p", agent.pubkey, "", "bot"]] : []),
],
}),
own,
@@ -73,6 +77,13 @@ async function setup(
member = false;
time++;
}
+ if (
+ event.kind === 9001 &&
+ event.tags.some(([key, value]) => key === "p" && value === agent.pubkey)
+ ) {
+ agentJoined = false;
+ time++;
+ }
});
const owner = createRelaySession(
{
@@ -106,6 +117,7 @@ async function setup(
root,
viewer,
peer,
+ agent,
async role(next: string) {
role = next;
time++;
@@ -138,7 +150,7 @@ it("rejects a guest's direct message operations before signing and restores them
await vi.waitFor(() => expect(h.publish).toHaveBeenCalledOnce());
});
-it.each([9, 7, 40003, 5, 40100, 9000, 30620, 46020])(
+it.each([9, 7, 40003, 5, 40100, 30620, 46020])(
"blocks kind %s sent directly through a guest's outbox",
async (kind) => {
const h = await setup("guest");
@@ -170,6 +182,46 @@ it.each([9, 7, 40003, 5, 40100, 9000, 30620, 46020])(
},
);
+it.each([9000, 9022])(
+ "leaves Guest membership command %s authorization to the relay",
+ async (kind) => {
+ const h = await setup("guest");
+ const outbox = h.session.outbox;
+ assert.exists(outbox);
+ const eventId = outbox.send({
+ kind,
+ content: "",
+ tags: [["h", id], ...(kind === 9022 ? [] : [["p", h.peer.pubkey]])],
+ });
+ await vi.waitFor(() =>
+ expect(
+ outbox.snapshot().find((entry) => entry.event.id === eventId),
+ ).toMatchObject({ delivery: "accepted" }),
+ );
+ expect(h.sign).toHaveBeenCalledOnce();
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.publish.mock.calls[0]?.[0]).toMatchObject({ kind });
+ },
+);
+
+it("lets a Guest remove an agent through the existing removal workflow and confirm its fresh roster", async () => {
+ const h = await setup("guest");
+ await removeAgentFromChannels(
+ h.session,
+ h.agent.pubkey,
+ new AbortController().signal,
+ );
+ expect(h.sign).toHaveBeenCalledOnce();
+ expect(h.publish.mock.calls[0]?.[0]).toMatchObject({
+ kind: 9001,
+ tags: expect.arrayContaining([
+ ["h", id],
+ ["p", h.agent.pubkey],
+ ]),
+ });
+ expect(h.session.outbox?.snapshot()).toHaveLength(0);
+});
+
it("blocks a message demoted while signing without publishing it", async () => {
const h = await setup();
const started = deferred(),
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 0d85a235c0..0f1f2c80fa 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1167,6 +1167,9 @@ export function createRelaySession(
}
/** Guest admission is rechecked for signed retries and after async preparation. */
function validateChannelWrite(event: Pick) {
+ // Membership commands have their own relay authorization, including a
+ // Guest removing an owned agent. They are not channel contributions.
+ if ([9000, 9001, 9022].includes(event.kind)) return;
const id = event.tags.find(([key]) => key === "h")?.[1];
if (!id || channels.queries.get?.(id)?.viewerRole !== "guest") return;
// The relay permits a verified owner to edit/delete their agent's messages.
From 30c0fd1875930555f52f90561d1096a1be076c9c Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:27:15 -0700
Subject: [PATCH 33/63] fix(channels): retain guest search and canvas reading
Distinguish joined Guests from public previews in search labels and recent destinations. Keep their canvas available with live read-only editor state across role changes, and retain archived canvas restrictions.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/app/shell/SearchResults.test.tsx | 55 ++++++++++
src/app/shell/SearchResults.tsx | 6 +-
src/bundled/channels/CanvasEditor.test.tsx | 4 +-
src/bundled/channels/CanvasEditor.tsx | 4 +-
src/bundled/channels/canvas-panel.test.tsx | 120 +++++++++++++++++++++
src/bundled/channels/canvas-panel.tsx | 12 ++-
6 files changed, 189 insertions(+), 12 deletions(-)
create mode 100644 src/bundled/channels/canvas-panel.test.tsx
diff --git a/src/app/shell/SearchResults.test.tsx b/src/app/shell/SearchResults.test.tsx
index f2e7689694..90b8966880 100644
--- a/src/app/shell/SearchResults.test.tsx
+++ b/src/app/shell/SearchResults.test.tsx
@@ -62,6 +62,61 @@ afterEach(() => {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
});
+it("keeps a joined private Guest channel in recents without calling it public or unjoined", async () => {
+ const relay = keypair(),
+ viewer = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ const discovery = [
+ metadata(relay, "guest", "Private updates", 1700000000, [["private"]]),
+ signed(relay, {
+ kind: 39002,
+ content: "",
+ tags: [
+ ["d", "guest"],
+ ["p", viewer.pubkey, "", "guest"],
+ ],
+ }),
+ ];
+ const owner = createRelaySession({
+ ...wire.transport,
+ query: async (filters) =>
+ discovery.filter((event) =>
+ filters.some((filter) => matchFilter(filter as Filter, event)),
+ ),
+ });
+ const props = {
+ session: owner.session,
+ onQueryChange() {},
+ input: createRef(),
+ pages: [],
+ openConversation: vi.fn(),
+ };
+ try {
+ const mounted = render( );
+ const recent = await screen.findByRole("group", {
+ name: "Recent activity",
+ });
+ expect(
+ within(recent).getByRole("option", { name: /Private updates/ }),
+ ).toBeVisible();
+ mounted.rerender( );
+ expect(
+ await screen.findByRole("option", { name: /Private updates/ }),
+ ).toHaveTextContent("Conversation");
+ expect(
+ screen.queryByText("Public channel · not joined"),
+ ).not.toBeInTheDocument();
+ expect(owner.session.channels.get?.("guest")).toMatchObject({
+ readOnly: true,
+ viewerRole: "guest",
+ visibility: "private",
+ });
+ } finally {
+ cleanup();
+ owner.dispose();
+ }
+});
+
it("opens with a conversation action and recent channels in activity order", async () => {
const relay = keypair();
const viewer = keypair();
diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx
index c598214b2a..7676621c97 100644
--- a/src/app/shell/SearchResults.tsx
+++ b/src/app/shell/SearchResults.tsx
@@ -778,7 +778,7 @@ export function SearchResults({
? channel.channelType === "solo"
? "Archived chat"
: "Archived channel"
- : channel.readOnly && !channel.cached
+ : channel.readOnly && !channel.cached && channel.viewerRole !== "guest"
? "Public channel · not joined"
: channel.channelType === "dm"
? "Direct message"
@@ -907,7 +907,9 @@ export function SearchResults({
const recent: SearchDestination[] = channels
.filter(
(channel) =>
- !channel.readOnly && !channel.archived && !sidebarHidden(channel),
+ (!channel.readOnly || channel.viewerRole === "guest") &&
+ !channel.archived &&
+ !sidebarHidden(channel),
)
.sort(
(a, b) =>
diff --git a/src/bundled/channels/CanvasEditor.test.tsx b/src/bundled/channels/CanvasEditor.test.tsx
index fdf94d0d42..66e90bebcc 100644
--- a/src/bundled/channels/CanvasEditor.test.tsx
+++ b/src/bundled/channels/CanvasEditor.test.tsx
@@ -544,9 +544,7 @@ it.each([false, true])(
expect(
screen.getByRole("button", { name: "Restore this version" }),
).toBeDisabled();
- expect(
- screen.getByText("Archived channel. Canvas is read-only."),
- ).toBeVisible();
+ expect(screen.getByText("Canvas is read-only.")).toBeVisible();
expect(canvas.save).not.toHaveBeenCalled();
await user.click(screen.getByRole("tab", { name: "Edit" }));
expect(text).toHaveValue(content);
diff --git a/src/bundled/channels/CanvasEditor.tsx b/src/bundled/channels/CanvasEditor.tsx
index f4fefe98d2..78b3c1c643 100644
--- a/src/bundled/channels/CanvasEditor.tsx
+++ b/src/bundled/channels/CanvasEditor.tsx
@@ -247,9 +247,7 @@ export function CanvasEditor({
const body = (
<>
- {readOnly && (
-
Archived channel. Canvas is read-only.
- )}
+ {readOnly &&
Canvas is read-only.
}
{
+ cleanup();
+ localStorage.clear();
+});
+
+it("lets a joined Guest read the canvas and reacts to promotion and demotion", async () => {
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const owner = createRelaySession(null);
+ const panels = new PanelsService(root);
+ const viewer = "a".repeat(64);
+ const scope = "guest-canvas-panel";
+ const id = "11111111-1111-4111-8111-111111111111";
+ let channel: ChannelSummary = {
+ id,
+ name: "Updates",
+ members: [viewer],
+ readOnly: true,
+ viewerRole: "guest",
+ };
+ let list = { status: "ready" as const, channels: [channel] };
+ const listeners = new Set<() => void>();
+ const head = signed(keypair(), {
+ kind: 40100,
+ content: "Saved canvas",
+ tags: [["h", id]],
+ });
+ const canvas = {
+ available: true,
+ read: vi.fn(async () => head),
+ save: vi.fn(async () => head),
+ history: vi.fn(async () => ({ revisions: [head], next: undefined })),
+ };
+ const session = {
+ ...owner.session,
+ viewer,
+ canvas,
+ channels: {
+ ...owner.session.channels,
+ list: () => list,
+ subscribeList: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ },
+ };
+ const snapshot = { status: "ready" as const, scope, generation: 1, session };
+ root.provide("relay", {
+ snapshot: () => snapshot,
+ subscribe: () => () => {},
+ });
+ const fiber = root
+ .extend({ pluginOwner: { id: "channels", revision: "test" } })
+ .plugin(registerCanvas);
+ try {
+ await fiber.await();
+ const panel = panels.snapshot()[0];
+ assert.exists(panel);
+ expect(panel?.channelMenu?.eligible(channel, session)).toBe(true);
+ expect(
+ panel?.channelMenu?.eligible({ ...channel, cached: true }, session),
+ ).toBe(false);
+ expect(
+ panel?.channelMenu?.eligible(
+ { id, name: "Updates", readOnly: true, members: [] },
+ session,
+ ),
+ ).toBe(false);
+ const Panel = panel.component;
+ render(
+ {}}
+ channelContext={{
+ scope,
+ channelId: id,
+ channelName: "Updates",
+ viewer,
+ relayUrl: "https://relay.test",
+ }}
+ />,
+ );
+ const editor = await screen.findByRole("textbox", {
+ name: "Canvas Markdown",
+ });
+ await waitFor(() => expect(editor).toHaveValue("Saved canvas"));
+ expect(editor).toHaveAttribute("readonly");
+ expect(screen.getByRole("button", { name: "Save Canvas" })).toBeDisabled();
+ expect(screen.queryByText(/Archived channel/)).not.toBeInTheDocument();
+ const update = (next: ChannelSummary) =>
+ act(() => {
+ channel = next;
+ list = { ...list, channels: [channel] };
+ for (const listener of listeners) listener();
+ });
+ update({ id, name: "Updates", members: [viewer], viewerRole: "member" });
+ expect(editor).not.toHaveAttribute("readonly");
+ update({ ...channel, readOnly: true, viewerRole: "guest" });
+ expect(editor).toHaveAttribute("readonly");
+ expect(canvas.save).not.toHaveBeenCalled();
+ } finally {
+ cleanup();
+ owner.dispose();
+ await root.fiber.dispose();
+ }
+});
diff --git a/src/bundled/channels/canvas-panel.tsx b/src/bundled/channels/canvas-panel.tsx
index 8d6a490ae1..5fd1108cb7 100644
--- a/src/bundled/channels/canvas-panel.tsx
+++ b/src/bundled/channels/canvas-panel.tsx
@@ -11,7 +11,7 @@ import { CanvasEditor } from "./CanvasEditor";
function eligible(channel: ChannelSummary, session: RelaySession) {
return (
!channel.cached &&
- !channel.readOnly &&
+ (!channel.readOnly || channel.viewerRole === "guest") &&
!!channel.members?.includes(session.viewer ?? "")
);
}
@@ -23,10 +23,14 @@ export function registerCanvas(ctx: Context) {
function Panel({ channelContext }: PanelProps) {
usePanelTabTitle("Canvas", tabIcon);
const state = useSyncExternalStore(ctx.relay.subscribe, ctx.relay.snapshot);
- const archived = useListedChannel(
+ const readOnly = useListedChannel(
state.status === "ready" ? state.session.channels : undefined,
channelContext?.channelId,
- (channel) => !!channel?.archived,
+ (channel) =>
+ !channel ||
+ !!channel.archived ||
+ !!channel.readOnly ||
+ !!channel.cached,
);
if (
!channelContext ||
@@ -41,7 +45,7 @@ export function registerCanvas(ctx: Context) {
return (
Date: Wed, 28 Oct 2026 21:27:47 -0700
Subject: [PATCH 34/63] fix(channels): describe private access as a new-member
default
Keep default participation separate from existing roles in the private-channel summary and align documented reader copy with the current UI. Preserve the explicitly selected compact channel-policy footer.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 5 +++--
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index f4e96f6a6b..75559f370d 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -733,7 +733,7 @@ confirmation. Immutable roles such as Owner and self remain protected.
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
-lock icon inside the dropdown. The audience Select uses the shared ghost Button
+lock icon inside the dropdown. The audience Select uses the shared text-link
treatment; the participation Select is outlined and vertically centered against
the audience row, with its popup aligned to the trigger’s right edge. On narrow screens the role control wraps
under the audience. The confirmation explains “People already in the channel keep
@@ -752,7 +752,8 @@ channels with a Guest default remain intact when changing unrelated details.
Readers see General access as plain text from verified channel discovery metadata,
without calling the editing capability. Its audience description is followed by
-“Read-only for you. Ask an owner or admin to contribute.” The compact timeline
+“Viewing only. Ask an owner or admin to participate.” The private-channel summary
+describes what new members can do; existing participants keep their roles. The compact timeline
label says “This channel is read-only.” for Guest-default channels; a Guest in a
Member-default channel sees “Read-only access.” Guest-default previews never suggest
joining to add people or agents.
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 085ea4850f..5d68761618 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -267,7 +267,7 @@ function ChannelGeneralAccessEditor({
: "Channel access could not be confirmed."
: base.visibility === "public"
? `Anyone in the community can find and ${base.joinRole === "guest" ? "view" : "participate"}.`
- : `Channel members can ${base.joinRole === "guest" ? "view" : "participate"}.`}
+ : `New channel members can ${base.joinRole === "guest" ? "view" : "participate"}.`}
From eb88e8dcbdedc31f55d2792e1e6388ebf7b0ce93 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:27:54 -0700
Subject: [PATCH 35/63] refactor(design-system): remove unused ghost select
appearance
Keep the link appearance used by channel access and show it in the shared specimen and registry. Remove the unused ghost option instead of maintaining an unadopted variant.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/shared/design-system/DESIGN.md | 6 ++----
src/shared/design-system/ui/Select.tsx | 2 +-
src/shared/design-system/ui/registry.ts | 2 +-
tests/fixtures/design-system/ui/FormSpecimens.tsx | 4 ++--
4 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index 55af7fc150..adfc196815 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -885,10 +885,8 @@ Standard Button labels use the complete `text-label-sm` role with an 8px icon ga
extra-small capsule uses `text-caption`. For a caption-sized inline choice such as
the composer's model name, `size="inline"` keeps a 24px minimum target with 4px
horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"`
-for an unframed label; shared hover, disabled, and keyboard focus still apply. Compact Selects also expose
-`appearance="ghost"` for an unframed, labelled row choice, such as channel access;
-the existing Button owns its hover, disabled, and focus states.
-Use `appearance="link"` for a text-and-caret choice without a capsule or horizontal
+for an unframed label; shared hover, disabled, and keyboard focus still apply.
+Compact Selects use `appearance="link"` for a text-and-caret choice, such as channel access, without a capsule or horizontal
button inset; it retains the Select's keyboard behavior and focus treatment.
Button defaults to `md` (40px minimum height); IconButton defaults to `sm`
diff --git a/src/shared/design-system/ui/Select.tsx b/src/shared/design-system/ui/Select.tsx
index f212d3124f..000314606e 100644
--- a/src/shared/design-system/ui/Select.tsx
+++ b/src/shared/design-system/ui/Select.tsx
@@ -58,7 +58,7 @@ export function Select({
variant?: "inline" | "field" | "compact" | "preference";
/** Match adjacent controls without changing the selection treatment. */
size?: "xs" | "sm" | "md";
- appearance?: "outlined" | "ghost" | "glass-ghost" | "link";
+ appearance?: "outlined" | "glass-ghost" | "link";
disabled?: boolean;
value: string;
groups: readonly SelectGroup[];
diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts
index bdc81f4bbb..dec5b52432 100644
--- a/src/shared/design-system/ui/registry.ts
+++ b/src/shared/design-system/ui/registry.ts
@@ -923,7 +923,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"button size: xs | sm | md",
"disabled",
"align: start | center | end",
- "compact ghost",
+ "compact link",
"compact glass-ghost (proposed)",
"compact value suffix",
"20px leading accessory: icon, avatar, or custom UI",
diff --git a/tests/fixtures/design-system/ui/FormSpecimens.tsx b/tests/fixtures/design-system/ui/FormSpecimens.tsx
index c471be87f7..2122059e40 100644
--- a/tests/fixtures/design-system/ui/FormSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/FormSpecimens.tsx
@@ -441,9 +441,9 @@ export function SelectExamples() {
groups={workspaceGroups}
/>
Date: Wed, 28 Oct 2026 21:28:32 -0700
Subject: [PATCH 36/63] test(channels): exercise guest canvas panel access
Extend the existing joined-Guest browser journey through the registered channel menu and actual canvas panel, verifying native read-only editing and disabled saving without adding another browser case.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
tests/browser/channel-permissions.spec.mjs | 25 ++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/tests/browser/channel-permissions.spec.mjs b/tests/browser/channel-permissions.spec.mjs
index 796425d321..38feedbd4a 100644
--- a/tests/browser/channel-permissions.spec.mjs
+++ b/tests/browser/channel-permissions.spec.mjs
@@ -6,6 +6,7 @@ test.use({
productionBroker: true,
channelLifecycle: true,
lifecycleRole: "guest",
+ channelWriteKinds: [9, 9007, 40100],
historyCounts: { alpha: 1, beta: 1 },
});
@@ -185,6 +186,30 @@ test("a joined guest can read and leave without receiving a composer or Join pro
await expect(
members.getByRole("region", { name: "Not in this channel" }),
).toHaveCount(0);
+ await members.getByRole("button", { name: "Done", exact: true }).click();
+ // The registered channel menu and panel must retain Guest reading access.
+ await page
+ .getByRole("button", { name: "Channel actions", exact: true })
+ .click();
+ await page
+ .getByRole("menuitem", { name: "View canvas", exact: true })
+ .click();
+ const canvas = page.getByRole("region", {
+ name: "Channel Canvas",
+ exact: true,
+ });
+ const editor = canvas.getByRole("textbox", {
+ name: "Canvas Markdown",
+ exact: true,
+ });
+ await expect(editor).toBeEnabled();
+ await expect(editor).toHaveJSProperty("readOnly", true);
+ await expect(
+ canvas.getByRole("button", { name: "Save Canvas", exact: true }),
+ ).toBeDisabled();
+ await expect(
+ canvas.getByText("Canvas is read-only.", { exact: true }),
+ ).toBeVisible();
expect(app.report.lifecyclePublications ?? []).toEqual([]);
expect(app.report.unexpected).toEqual([]);
});
From b35d34603ace600bbca0bbfd088d9d1b58bc6b3c Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:30:52 -0700
Subject: [PATCH 37/63] fix(channels): allow joined guests to open readable
panels
Keep the channel-panel host consistent with menu eligibility: a verified Guest remains a member who can open readable panels. Retain the existing live-session, cached-channel, membership and contribution checks. The Guest canvas browser journey reproduced the blocked opening before this correction.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelsPage.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 8340e0592f..b9df84ce3e 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -2028,7 +2028,8 @@ function ChannelWorkspace({
drawerContext.channelId !== channel?.id ||
!channel ||
channel.cached ||
- channel.readOnly ||
+ (channel.readOnly &&
+ channel.viewerRole !== "guest") ||
!channel.members?.includes(
queries.viewer ?? "",
) ||
From d97e38efc74d5e01b9cc82781df5b984f846334f Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:30:59 -0700
Subject: [PATCH 38/63] docs(channels): match bot role actions to the
participant menu
Describe the existing contributing Bot presentation accurately instead of promising a separate equivalent Member action in the role menu.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/channels.md b/docs/channels.md
index 75559f370d..19e416e3ca 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1022,7 +1022,7 @@ another non-owner member:
| Target role | Change role | Remove from this channel |
| --- | --- | --- |
| Admin / Member / Guest | Admin / Member / Guest, excluding the current role | Yes |
-| Bot | Admin / Member / Guest, after explicit confirmation | Yes |
+| Bot | Admin / Guest, after explicit confirmation; Participating is already selected | Yes |
| Owner, self, unknown or inconsistent | No | No |
Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority.
From 28ce9fbde5f258cd813270e904dd58e6db92d6a7 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:33:13 -0700
Subject: [PATCH 39/63] fix(channels): retain guest panel context and tabs
Preserve joined Guest context and visible readable panel tabs through the existing conversation-tab owner. Keep cached channels excluded. Cover both Member and Guest launcher lifecycles with the same mounted test.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/useConversationTabs.test.tsx | 186 +++++++++---------
src/bundled/channels/useConversationTabs.tsx | 8 +-
2 files changed, 104 insertions(+), 90 deletions(-)
diff --git a/src/bundled/channels/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx
index 79eebbf053..b94f8e82fd 100644
--- a/src/bundled/channels/useConversationTabs.test.tsx
+++ b/src/bundled/channels/useConversationTabs.test.tsx
@@ -651,94 +651,104 @@ it("the current verified read-only preview owns its thread host, but unrelated p
expect(h.result.current.workspace.hosts).toHaveLength(0);
});
-it("a tab-placed channel launcher opens one tab beside the threads and toggles it", async () => {
- const user = userEvent.setup();
- const channels = [{ id: "alpha", name: "Alpha" }];
- const session = {
- viewer: "viewer",
- channels: {
- list: () => ({ channels }),
- window: () => ({ rows: [{ id: "root", content: "A thread" }] }),
- },
- } as unknown as RelaySession;
- const panel: RegisteredPanel = {
- id: "work",
- key: "fixture:work",
- pluginId: "fixture",
- revision: "1",
- title: "Work",
- channelPlacement: "tab",
- matches: () => false,
- channelLauncher: ({ toggle, pressed, context }) => (
- toggle(context.channelId)}
- >
- Work
-
- ),
- component: () => null,
- };
- const available = [panel];
- const panels = {
- snapshot: () => available,
- subscribe: () => () => {},
- resolve: () => undefined,
- } as unknown as Panels;
- const snapshot = { status: "ready", session };
- const relay = { snapshot: () => snapshot } as unknown as RelayData;
- let workspace!: ReturnType;
- let state!: ReturnType;
- function Harness() {
- state = useChannelTabState(session, "alpha");
- workspace = useConversationTabs({
- profiles: new Map(),
- state,
- relay,
- session,
- panels,
- current: channels[0],
- channels,
- scope: "wss://relay.example:viewer",
- conversationIcon: () => null,
- leadingIds: [],
- continuingVisit: true,
- isLive: () => true,
- openLink: () => false,
- });
- return <>{workspace.drawer.launchers}>;
- }
- render( );
- act(() => state.openThreadTab("alpha", "root", "root", undefined, "side"));
- const launcher = screen.getByRole("button", { name: "Work" });
- expect(launcher).toHaveAttribute("aria-pressed", "false");
- await user.click(launcher);
- expect(workspace.items.map((item) => item.label)).toEqual([
- "A thread",
- "Work",
- ]);
- expect(workspace.selectedTab).toBe(workspace.items[1]?.id);
- expect(state.paneOpen).toBe(true);
- expect(launcher).toHaveAttribute("aria-pressed", "true");
- // The tab gets the full tab context, so the panel can push its own tabs.
- const work = workspace.items[1];
- if (!work) throw new Error("no Work tab");
- const { props } = work.content as {
- props: { context: PanelContext; channelContext: unknown };
- };
- expect(props.channelContext).toMatchObject({ channelId: "alpha" });
- expect(typeof props.context.push).toBe("function");
- // Selecting another tab leaves Work open; the launcher reselects it.
- act(() => workspace.selectPanelTab(workspace.items[0]?.id ?? ""));
- expect(launcher).toHaveAttribute("aria-pressed", "false");
- await user.click(launcher);
- expect(workspace.items).toHaveLength(2);
- expect(launcher).toHaveAttribute("aria-pressed", "true");
- await user.click(launcher);
- expect(workspace.items.map((item) => item.label)).toEqual(["A thread"]);
- expect(launcher).toHaveAttribute("aria-pressed", "false");
-});
+it.each(["member", "guest"] as const)(
+ "a %s's tab-placed channel launcher opens one tab beside the threads and toggles it",
+ async (role) => {
+ const user = userEvent.setup();
+ const channels = [
+ {
+ id: "alpha",
+ name: "Alpha",
+ viewerRole: role,
+ ...(role === "guest" ? { readOnly: true as const } : {}),
+ },
+ ];
+ const session = {
+ viewer: "viewer",
+ channels: {
+ list: () => ({ channels }),
+ window: () => ({ rows: [{ id: "root", content: "A thread" }] }),
+ },
+ } as unknown as RelaySession;
+ const panel: RegisteredPanel = {
+ id: "work",
+ key: "fixture:work",
+ pluginId: "fixture",
+ revision: "1",
+ title: "Work",
+ channelPlacement: "tab",
+ matches: () => false,
+ channelLauncher: ({ toggle, pressed, context }) => (
+ toggle(context.channelId)}
+ >
+ Work
+
+ ),
+ component: () => null,
+ };
+ const available = [panel];
+ const panels = {
+ snapshot: () => available,
+ subscribe: () => () => {},
+ resolve: () => undefined,
+ } as unknown as Panels;
+ const snapshot = { status: "ready", session };
+ const relay = { snapshot: () => snapshot } as unknown as RelayData;
+ let workspace!: ReturnType;
+ let state!: ReturnType;
+ function Harness() {
+ state = useChannelTabState(session, "alpha");
+ workspace = useConversationTabs({
+ profiles: new Map(),
+ state,
+ relay,
+ session,
+ panels,
+ current: channels[0],
+ channels,
+ scope: "wss://relay.example:viewer",
+ conversationIcon: () => null,
+ leadingIds: [],
+ continuingVisit: true,
+ isLive: () => true,
+ openLink: () => false,
+ });
+ return <>{workspace.drawer.launchers}>;
+ }
+ render( );
+ act(() => state.openThreadTab("alpha", "root", "root", undefined, "side"));
+ const launcher = screen.getByRole("button", { name: "Work" });
+ expect(launcher).toHaveAttribute("aria-pressed", "false");
+ await user.click(launcher);
+ expect(workspace.items.map((item) => item.label)).toEqual([
+ "A thread",
+ "Work",
+ ]);
+ expect(workspace.selectedTab).toBe(workspace.items[1]?.id);
+ expect(state.paneOpen).toBe(true);
+ expect(launcher).toHaveAttribute("aria-pressed", "true");
+ // The tab gets the full tab context, so the panel can push its own tabs.
+ const work = workspace.items[1];
+ if (!work) throw new Error("no Work tab");
+ const { props } = work.content as {
+ props: { context: PanelContext; channelContext: unknown };
+ };
+ expect(props.channelContext).toMatchObject({ channelId: "alpha" });
+ expect(typeof props.context.push).toBe("function");
+ // Selecting another tab leaves Work open; the launcher reselects it.
+ act(() => workspace.selectPanelTab(workspace.items[0]?.id ?? ""));
+ expect(launcher).toHaveAttribute("aria-pressed", "false");
+ await user.click(launcher);
+ expect(workspace.items).toHaveLength(2);
+ expect(launcher).toHaveAttribute("aria-pressed", "true");
+ await user.click(launcher);
+ expect(workspace.items.map((item) => item.label)).toEqual(["A thread"]);
+ expect(launcher).toHaveAttribute("aria-pressed", "false");
+ },
+);
function launcherHarness(mainPane = false) {
const channels = [{ id: "alpha", name: "Alpha" }];
diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx
index a3afd430f6..59ee69d1cc 100644
--- a/src/bundled/channels/useConversationTabs.tsx
+++ b/src/bundled/channels/useConversationTabs.tsx
@@ -285,7 +285,8 @@ export function useConversationTabs({
(!eligible || eligible(entry)) &&
(!entry.channelContext ||
(current &&
- !current.readOnly &&
+ !current.cached &&
+ (!current.readOnly || current.viewerRole === "guest") &&
(!current.archived ||
entry.panel.channelMenu?.eligible(current, queries)) &&
current.id === entry.channelId)),
@@ -369,7 +370,10 @@ export function useConversationTabs({
};
const drawerContext = useMemo(
() =>
- current && !current.readOnly && viewer
+ current &&
+ !current.cached &&
+ (!current.readOnly || current.viewerRole === "guest") &&
+ viewer
? {
scope,
viewer,
From 711582fc53a6a1f24613614ed95f540c8f3d3ff6 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 21:34:49 -0700
Subject: [PATCH 40/63] fix(channels): separate canvas reading from
contribution access
Authorize canvas reads and history through verified channel membership while retaining the stricter writing predicate for saves. Keep existing capability callers compatible and verify Guest reading and denied saving through the real session. The app journey reproduced the read denial at the capability boundary.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/features/channel-templates/capability.ts | 8 +++++---
.../relay/session-permissions.test.ts | 20 +++++++++++++++++++
src/features/relay/session.ts | 1 +
3 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/src/features/channel-templates/capability.ts b/src/features/channel-templates/capability.ts
index bb209049e9..84ee2b92d9 100644
--- a/src/features/channel-templates/capability.ts
+++ b/src/features/channel-templates/capability.ts
@@ -48,6 +48,7 @@ export function createChannelKit({
community,
signal,
canWrite,
+ canRead = canWrite,
delivered,
}: {
host: ChannelKitHost | undefined;
@@ -59,6 +60,7 @@ export function createChannelKit({
community: string;
signal: AbortSignal;
canWrite(channel: string): boolean;
+ canRead?(channel: string): boolean;
delivered(id: string): Promise;
}) {
let state: Snapshot = { status: host ? "idle" : "unavailable", entries: [] };
@@ -332,7 +334,7 @@ export function createChannelKit({
available: !!outbox?.supports(40100),
async read(channel: string, { strong = true }: { strong?: boolean } = {}) {
signal.throwIfAborted();
- if (!canWrite(channel))
+ if (!canRead(channel))
throw new Error("Canvas is unavailable after channel access changed");
return selectedHead(
await fresh([
@@ -350,7 +352,7 @@ export function createChannelKit({
cursor?: CanvasHistoryCursor,
): Promise {
signal.throwIfAborted();
- if (!canWrite(channel))
+ if (!canRead(channel))
throw new Error("Canvas is unavailable after channel access changed");
const rows = await fresh([
{
@@ -361,7 +363,7 @@ export function createChannelKit({
},
]);
signal.throwIfAborted();
- if (!canWrite(channel))
+ if (!canRead(channel))
throw new Error("Canvas is unavailable after channel access changed");
if (
rows.some(
diff --git a/src/features/relay/session-permissions.test.ts b/src/features/relay/session-permissions.test.ts
index 8ae061fbb9..782224c0f2 100644
--- a/src/features/relay/session-permissions.test.ts
+++ b/src/features/relay/session-permissions.test.ts
@@ -35,6 +35,12 @@ async function setup(
agentJoined = true;
const own = message(viewer, id, "Earlier message", time);
const root = message(peer, id, "Announcement", time);
+ const canvas = signed(peer, {
+ kind: 40100,
+ content: "Saved announcement notes",
+ tags: [["h", id]],
+ created_at: time,
+ });
const events = () => [
signed(relay, {
kind: 39000,
@@ -70,6 +76,7 @@ async function setup(
}),
own,
root,
+ canvas,
];
const sign = vi.fn(async (event: EventTemplate) => signed(viewer, event));
const publish = vi.fn(async (event: RelayEvent) => {
@@ -285,6 +292,19 @@ it("retains guest history, details and Leave", async () => {
expect(h.session.channels.list().channels).toHaveLength(0);
});
+it("lets a Guest read canvas content and history without granting save access", async () => {
+ const h = await setup("guest");
+ expect(await h.session.canvas.read(id)).toMatchObject({
+ content: "Saved announcement notes",
+ });
+ expect((await h.session.canvas.history(id)).revisions).toHaveLength(1);
+ await expect(
+ h.session.canvas.save(id, "Forbidden edit", undefined),
+ ).rejects.toThrow("Canvas writing is unavailable in this channel");
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+});
+
it("rejects guest uploads and fences an upload completed after demotion", async () => {
const started = deferred(),
gate = deferred();
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 0f1f2c80fa..5a6d946610 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1392,6 +1392,7 @@ export function createRelaySession(
community: transport?.scope ?? "",
signal: lifetime.signal,
canWrite: (id) => !closed && channels.canWrite(id),
+ canRead: (id) => !closed && channels.canParticipate(id),
delivered: channelWrites.delivered,
});
const soloPreferences = createSoloPreferences(
From c5a33b2d9e8cdcdf1d47d83357885e6c886c1337 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 22:18:18 -0700
Subject: [PATCH 41/63] fix(channels): lead the member list with general access
Move the existing access controls into the first scrollable member-list section, match General to the member group captions, and align avatar/text geometry. Preserve access choices and confirmation behavior.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 3 ++-
src/bundled/channels/ChannelGeneralAccess.tsx | 4 ++--
.../channels/ChannelMembersDialog.module.css | 18 +++++++----------
src/bundled/channels/ChannelMembersDialog.tsx | 20 +++++++++----------
4 files changed, 21 insertions(+), 24 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 19e416e3ca..c3d4683702 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -729,7 +729,8 @@ text before each row’s ellipsis button; Member and Guest are shown as **Partic
and **Viewing only**. The ellipsis menu contains permitted role choices with
the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
-**General access** sits below the list as one sentence-like row: a community avatar/lock
+**General** is the first section of the scrolling member list. Its row aligns its
+avatar slot and text with the member rows: a community avatar/lock
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 5d68761618..ecd5db903c 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -72,7 +72,7 @@ export function ChannelGeneralAccess({
if (channel?.readOnly)
return (
- General access
+ General
{channel.visibility === "public" ? (
@@ -206,7 +206,7 @@ function ChannelGeneralAccessEditor({
aria-busy={loading || pending}
className={styles.generalAccess}
>
- General access
+ General
:where(.accessRow) ~ * {
+ margin-block-start: var(--space-3);
}
.accessRow {
display: grid;
grid-template-columns:
- calc(var(--size-control-sm) + var(--space-6)) minmax(0, 1fr)
+ var(--size-control-sm) minmax(0, 1fr)
auto;
align-items: start;
- gap: var(--space-2);
- column-gap: 0;
+ gap: var(--space-row-gap);
}
.accessIcon {
display: flex;
@@ -211,10 +210,7 @@
max-width: 100%;
}
.accessRow {
- grid-template-columns: calc(var(--size-control-sm) + var(--space-6)) minmax(
- 0,
- 1fr
- );
+ grid-template-columns: var(--size-control-sm) minmax(0, 1fr);
}
.accessRole {
grid-column: 2;
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index ff13906a98..135f3fa92d 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -1761,6 +1761,16 @@ export function ChannelMembersDialog({
className={`${styles.memberList} buzz-thin-scrollbar`}
aria-label="Member list"
>
+ {generalAccess && (
+
+ )}
{!canAdd && !readOnlyAccess && !(solo && rosterBusy) && (
{channel?.channelType === "dm"
@@ -2167,16 +2177,6 @@ export function ChannelMembersDialog({
) : null)}
- {generalAccess && (
-
- )}
)}
From e9faa88ff2f9b95df8e3b18f120248a20d372a0b Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 22:22:33 -0700
Subject: [PATCH 42/63] fix(channels): align access type with member identities
Use regular body-small type and a text-aligned 24px link Select target, then match the access row vertical inset to member identities. Preserve shared selection, focus and confirmation behavior.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.module.css | 1 +
src/shared/design-system/DESIGN.md | 3 ++-
src/shared/design-system/styles/forms.css | 5 ++++-
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 8d82f64d39..2f7204a51b 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -173,6 +173,7 @@
auto;
align-items: start;
gap: var(--space-row-gap);
+ padding-block: var(--space-half);
}
.accessIcon {
display: flex;
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index adfc196815..32d5f51fe6 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -887,7 +887,8 @@ the composer's model name, `size="inline"` keeps a 24px minimum target with 4px
horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"`
for an unframed label; shared hover, disabled, and keyboard focus still apply.
Compact Selects use `appearance="link"` for a text-and-caret choice, such as channel access, without a capsule or horizontal
-button inset; it retains the Select's keyboard behavior and focus treatment.
+button inset. It uses regular body-small type and a 24px minimum target aligned
+to the text's top edge; it retains the Select's keyboard behavior and focus treatment.
Button defaults to `md` (40px minimum height); IconButton defaults to `sm`
(32px square with 16px artwork) and a 10px rounded rectangle
diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css
index 172ad1d52c..e67e50947a 100644
--- a/src/shared/design-system/styles/forms.css
+++ b/src/shared/design-system/styles/forms.css
@@ -302,7 +302,10 @@
block-size: var(--space-5);
}
.buzz-select[data-appearance="link"] .buzz-button {
- padding-inline: 0;
+ @apply text-body-sm;
+ align-items: flex-start;
+ min-height: var(--space-6);
+ padding: 0;
}
/* Artwork provides its own visual inset; keep its edge closer to the frame. */
.buzz-select .buzz-button:has(.buzz-select-leading),
From 9e390e9cca4dbd8e0bdbdf6881d734328e92e900 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 22:29:40 -0700
Subject: [PATCH 43/63] fix(channels): lead access rows without a heading
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 2 +-
src/bundled/channels/ChannelGeneralAccess.tsx | 4 +---
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index c3d4683702..d377091bae 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -729,7 +729,7 @@ text before each row’s ellipsis button; Member and Guest are shown as **Partic
and **Viewing only**. The ellipsis menu contains permitted role choices with
the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
-**General** is the first section of the scrolling member list. Its row aligns its
+The access row leads the scrolling member list without a visible section heading. It aligns its
avatar slot and text with the member rows: a community avatar/lock
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index ecd5db903c..b30794b753 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -31,7 +31,7 @@ import { privacyConsequence } from "./ChannelPrivacyConfirmation";
const noCommunitySubscription = () => () => {};
/** General access uses the same signed save/readback owner as channel details.
- * Choices are confirmed individually, like the member rows above this section. */
+ * Choices are confirmed individually, like the member rows that follow. */
export function ChannelGeneralAccess({
communityReader,
communityScope,
@@ -72,7 +72,6 @@ export function ChannelGeneralAccess({
if (channel?.readOnly)
return (
- General
{channel.visibility === "public" ? (
@@ -206,7 +205,6 @@ function ChannelGeneralAccessEditor({
aria-busy={loading || pending}
className={styles.generalAccess}
>
- General
Date: Wed, 28 Oct 2026 22:31:48 -0700
Subject: [PATCH 44/63] fix(channels): tighten access lines and trailing
alignment
Align the audience text to the bottom of its existing target, remove the reader caption gap, and align the participation control with member action edges.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
src/bundled/channels/ChannelMembersDialog.module.css | 2 +-
src/shared/design-system/DESIGN.md | 5 +++--
src/shared/design-system/styles/forms.css | 2 +-
4 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index b30794b753..2a403f9c94 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -80,7 +80,7 @@ export function ChannelGeneralAccess({
)}
-
+
{channel.visibility === "public"
? "Anyone in the community can read."
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 2f7204a51b..7ce2dc80b2 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -161,7 +161,7 @@
}
.generalAccess {
- padding: var(--space-2) var(--space-control-inset) 0;
+ padding: var(--space-2) 0 0 var(--space-control-inset);
}
.generalAccess > :where(.accessRow) ~ * {
margin-block-start: var(--space-3);
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index 32d5f51fe6..f644df26ce 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -887,8 +887,9 @@ the composer's model name, `size="inline"` keeps a 24px minimum target with 4px
horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"`
for an unframed label; shared hover, disabled, and keyboard focus still apply.
Compact Selects use `appearance="link"` for a text-and-caret choice, such as channel access, without a capsule or horizontal
-button inset. It uses regular body-small type and a 24px minimum target aligned
-to the text's top edge; it retains the Select's keyboard behavior and focus treatment.
+button inset. It uses regular body-small type and a 24px minimum target with
+the text at its bottom edge, so supporting copy follows without an extra gap;
+it retains the Select's keyboard behavior and focus treatment.
Button defaults to `md` (40px minimum height); IconButton defaults to `sm`
(32px square with 16px artwork) and a 10px rounded rectangle
diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css
index e67e50947a..f5858d9933 100644
--- a/src/shared/design-system/styles/forms.css
+++ b/src/shared/design-system/styles/forms.css
@@ -303,7 +303,7 @@
}
.buzz-select[data-appearance="link"] .buzz-button {
@apply text-body-sm;
- align-items: flex-start;
+ align-items: flex-end;
min-height: var(--space-6);
padding: 0;
}
From a7d38bc1c4f60bddb7b908f6dadf025f9a9ea063 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 22:44:41 -0700
Subject: [PATCH 45/63] fix(channels): give private access artwork a subtle
backdrop
Use the existing squircle Avatar fallback for the lock in editable and read-only access rows.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 2a403f9c94..c3837cbcf5 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -77,7 +77,12 @@ export function ChannelGeneralAccess({
{channel.visibility === "public" ? (
communityAvatar("default")
) : (
-
+ }
+ shape="squircle"
+ />
)}
@@ -221,7 +226,12 @@ function ChannelGeneralAccessEditor({
) : base?.visibility === "public" ? (
communityAvatar("default")
) : (
-
+
}
+ shape="squircle"
+ />
)}
From 7f1dd94179f4c0affbd5592f2c27e02f7fa68e69 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:05:33 -0700
Subject: [PATCH 46/63] fix(channels): restore fixed access controls below
members
Move the access section outside the member scrollport and restore its divider while retaining the refined row styling.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 4 ++--
.../channels/ChannelMembersDialog.module.css | 3 ++-
src/bundled/channels/ChannelMembersDialog.tsx | 20 +++++++++----------
3 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index d377091bae..6c10493636 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -729,8 +729,8 @@ text before each row’s ellipsis button; Member and Guest are shown as **Partic
and **Viewing only**. The ellipsis menu contains permitted role choices with
the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
-The access row leads the scrolling member list without a visible section heading. It aligns its
-avatar slot and text with the member rows: a community avatar/lock
+The access row stays below and outside the scrolling member list, without a visible
+section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 7ce2dc80b2..06b2f315b5 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -161,7 +161,8 @@
}
.generalAccess {
- padding: var(--space-2) 0 0 var(--space-control-inset);
+ border-top: 1px solid var(--border-standard);
+ padding: var(--space-4) 0 0 var(--space-control-inset);
}
.generalAccess > :where(.accessRow) ~ * {
margin-block-start: var(--space-3);
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index 135f3fa92d..ff13906a98 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -1761,16 +1761,6 @@ export function ChannelMembersDialog({
className={`${styles.memberList} buzz-thin-scrollbar`}
aria-label="Member list"
>
- {generalAccess && (
-
- )}
{!canAdd && !readOnlyAccess && !(solo && rosterBusy) && (
{channel?.channelType === "dm"
@@ -2177,6 +2167,16 @@ export function ChannelMembersDialog({
) : null)}
+ {generalAccess && (
+
+ )}
)}
From 67bcc0434a2f19302d2184b6b0fa38f2f2cf8453 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:13:46 -0700
Subject: [PATCH 47/63] fix(channels): match access inset to the artwork gap
Use the same row-gap token for the leading inset and the space between artwork and text.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.module.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index 06b2f315b5..c5e69d2b5b 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -162,7 +162,7 @@
.generalAccess {
border-top: 1px solid var(--border-standard);
- padding: var(--space-4) 0 0 var(--space-control-inset);
+ padding: var(--space-4) 0 0 var(--space-row-gap);
}
.generalAccess > :where(.accessRow) ~ * {
margin-block-start: var(--space-3);
From 74bafd3a40bbe8536c35f5c07619dc22b29d6b00 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:15:03 -0700
Subject: [PATCH 48/63] fix(channels): tighten access separator spacing
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelMembersDialog.module.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index c5e69d2b5b..ea720064c3 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -162,7 +162,7 @@
.generalAccess {
border-top: 1px solid var(--border-standard);
- padding: var(--space-4) 0 0 var(--space-row-gap);
+ padding: var(--space-2) 0 0 var(--space-row-gap);
}
.generalAccess > :where(.accessRow) ~ * {
margin-block-start: var(--space-3);
From 5376f68519cf87a2243700b4cb0d8e732f955e3b Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:16:11 -0700
Subject: [PATCH 49/63] fix(channels): balance fixed access footer spacing
Let the member scrollport meet the separator and add a small gap between the audience label and its description.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
src/bundled/channels/ChannelMembersDialog.module.css | 7 ++++++-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index c3837cbcf5..5f8876799d 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -85,7 +85,7 @@ export function ChannelGeneralAccess({
/>
)}
-
+
{channel.visibility === "public"
? "Anyone in the community can read."
diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css
index ea720064c3..9ff714cab2 100644
--- a/src/bundled/channels/ChannelMembersDialog.module.css
+++ b/src/bundled/channels/ChannelMembersDialog.module.css
@@ -34,6 +34,11 @@
.memberList > * {
flex-shrink: 0;
}
+/* Let the scrollport meet the fixed access divider without a blank gutter. */
+.memberList:has(+ .generalAccess) {
+ padding-block-end: 0;
+ margin-block-end: calc(-1 * var(--space-4));
+}
/* Headings already provide their top inset; tighten only adjacent member groups. */
.memberList > section + section:not(.memberGroup) {
margin-block-start: calc(-1 * var(--space-2));
@@ -192,7 +197,7 @@
display: grid;
min-width: 0;
justify-items: start;
- gap: 0;
+ gap: var(--space-1);
}
.accessAudience > * {
max-width: 100%;
From 13b2bbcdbc613a78d50f22a78c9297cc5b88d795 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:17:45 -0700
Subject: [PATCH 50/63] fix(channels): center access artwork on its text lines
Account for the link target extending above the visible audience line when centering the artwork.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
src/bundled/channels/ChannelMembersDialog.module.css | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 5f8876799d..5a8853e8b5 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -212,7 +212,7 @@ function ChannelGeneralAccessEditor({
>
.accessIcon {
width: 100%;
}
+/* The 24px link target extends 4px above its 20px text line. */
+.accessEditableIcon {
+ translate: 0 var(--space-half);
+}
.accessAudience {
display: grid;
min-width: 0;
From 0f9fd0c39f9d551591d547e7dead5b39f638795b Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:34:59 -0700
Subject: [PATCH 51/63] docs(channels): align access comment with fixed
placement
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 5a8853e8b5..5146966bf9 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -31,7 +31,7 @@ import { privacyConsequence } from "./ChannelPrivacyConfirmation";
const noCommunitySubscription = () => () => {};
/** General access uses the same signed save/readback owner as channel details.
- * Choices are confirmed individually, like the member rows that follow. */
+ * Choices are confirmed individually, like the member rows above this section. */
export function ChannelGeneralAccess({
communityReader,
communityScope,
From b4fcc19f2b0e70a8b854afc4561fa9a32b9b970f Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 28 Oct 2026 23:36:15 -0700
Subject: [PATCH 52/63] test(channels): follow details duration with the cancel
action
Keep creation focus checks on Private while checking the details form next tab stop after visibility moved to member management.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
tests/browser/channel-header-menu.spec.mjs | 7 ++++++-
tests/browser/duration-layout.mjs | 17 ++++++++++++-----
2 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs
index dbfbf23b98..75139ce7ab 100644
--- a/tests/browser/channel-header-menu.spec.mjs
+++ b/tests/browser/channel-header-menu.spec.mjs
@@ -288,7 +288,12 @@ test("header actions use the full-size menu and retain pane/dialog focus owners"
await nameEditor
.getByRole("radio", { name: "Temporary", exact: true })
.click();
- await checkDurationConsumer(page, nameEditor, "edit-duration", testInfo);
+ await checkDurationConsumer(page, nameEditor, "edit-duration", testInfo, {
+ nextControl: nameEditor.getByRole("button", {
+ name: "Cancel",
+ exact: true,
+ }),
+ });
await nameEditor.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(title).toBeFocused();
if (browserName === "chromium") {
diff --git a/tests/browser/duration-layout.mjs b/tests/browser/duration-layout.mjs
index efa8173fc3..aa7e8ea589 100644
--- a/tests/browser/duration-layout.mjs
+++ b/tests/browser/duration-layout.mjs
@@ -173,11 +173,18 @@ export async function checkDurationLayout(
}
// Other consumers need representative integration, not another app-startup matrix.
-export async function checkDurationConsumer(page, dialog, prefix, testInfo) {
- const nextControl = dialog.getByRole("switch", {
- name: "Private",
- exact: true,
- });
+export async function checkDurationConsumer(
+ page,
+ dialog,
+ prefix,
+ testInfo,
+ {
+ nextControl = dialog.getByRole("switch", {
+ name: "Private",
+ exact: true,
+ }),
+ } = {},
+) {
await checkDurationLayout(dialog, { selected: false, nextControl });
await page.screenshot({
path: testInfo.outputPath(`${prefix}-ongoing-wide.png`),
From 975a1a329cf7bffa3712709180e1cd7bdb26def6 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 09:14:35 -0700
Subject: [PATCH 53/63] fix(channels): clarify public and private access labels
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 3 ++-
.../channels/ChannelGeneralAccess.test.tsx | 23 +++++++++++--------
src/bundled/channels/ChannelGeneralAccess.tsx | 4 ++--
.../ChannelPrivacyConfirmation.test.tsx | 5 +---
4 files changed, 19 insertions(+), 16 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 6c10493636..2bc52b6ccb 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -731,7 +731,8 @@ the verified selection checked, alongside profile/removal actions and the existi
confirmation. Immutable roles such as Owner and self remain protected.
The access row stays below and outside the scrolling member list, without a visible
section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock
-icon, a compact audience Select (**Who can read**), a short result description,
+icon, a compact audience Select (**Who can read**) with **Public** / **Private**
+labels, a hint explaining the audience and selected participation default,
and the trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
lock icon inside the dropdown. The audience Select uses the shared text-link
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 5421ccb613..9a3a9c2e5a 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -167,10 +167,14 @@ it.each(["public", "private"] as const)(
render(
,
);
- const option =
- visibility === "public"
- ? "Channel members only"
- : "Anyone in the community";
+ expect(
+ await screen.findByText(
+ visibility === "public"
+ ? "Anyone in the community can find and view."
+ : "New channel members can view.",
+ ),
+ ).toBeVisible();
+ const option = visibility === "public" ? "Private" : "Public";
const control = await select(user, "Who can read", option);
const confirm = screen.getByRole("alertdialog", {
name: "Change general access?",
@@ -187,9 +191,7 @@ it.each(["public", "private"] as const)(
await user.keyboard("{Escape}");
await waitFor(() => expect(control).toHaveFocus());
expect(control).toHaveTextContent(
- visibility === "public"
- ? "Anyone in the community"
- : "Channel members only",
+ visibility === "public" ? "Public" : "Private",
);
await select(user, "Who can read", option);
const next = {
@@ -225,7 +227,10 @@ it("confirms new people's View only/Participate access independently of private
);
expect(
screen.getByRole("combobox", { name: "Who can read" }),
- ).toHaveTextContent("Channel members only");
+ ).toHaveTextContent("Private");
+ expect(
+ screen.getByText("New channel members can participate."),
+ ).toBeVisible();
});
it("shows the absent Member default without saving, and a declined save reloads current access", async () => {
const h = harness();
@@ -325,7 +330,7 @@ it("retires an open confirmation and late reads when the destination changes", a
const view = render(
,
);
- await select(user, "Who can read", "Anyone in the community");
+ await select(user, "Who can read", "Public");
const pending = new Promise(() => {});
h.load.mockReturnValueOnce(pending);
view.rerender(
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 5146966bf9..943dc77cc6 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -249,12 +249,12 @@ function ChannelGeneralAccessEditor({
options: [
{
value: "public",
- label: "Anyone in the community",
+ label: "Public",
leading: communityAvatar("compact"),
},
{
value: "private",
- label: "Channel members only",
+ label: "Private",
leading: ,
},
],
diff --git a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
index 43a17f4f16..903cdb7989 100644
--- a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
+++ b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
@@ -117,10 +117,7 @@ it.each(["private", "public"] as const)(
await user.click(control);
await user.click(
await screen.findByRole("option", {
- name:
- choice === "private"
- ? "Channel members only"
- : "Anyone in the community",
+ name: choice === "private" ? "Private" : "Public",
}),
);
expect(
From 4ffda7e9983c5d58c412403c848aac2cd8b2810c Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 09:56:21 -0700
Subject: [PATCH 54/63] feat(channels): default private additions to
participation
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
browser-host/channel-commands.mjs | 6 +-
browser-host/channel-commands.test.mjs | 21 +++++-
browser-host/relay-broker-api.test.mjs | 14 +++-
docs/channels.md | 13 +++-
.../channels/ChannelGeneralAccess.test.tsx | 36 +++++++--
src/bundled/channels/ChannelGeneralAccess.tsx | 74 ++++++++++---------
src/features/channel-members/members.test.ts | 44 +++++++++--
src/features/channel-members/members.ts | 15 +++-
8 files changed, 169 insertions(+), 54 deletions(-)
diff --git a/browser-host/channel-commands.mjs b/browser-host/channel-commands.mjs
index afecbac243..45c9e0e833 100644
--- a/browser-host/channel-commands.mjs
+++ b/browser-host/channel-commands.mjs
@@ -88,7 +88,11 @@ export function validChannelCommand(event) {
if (event.kind === 9000)
return (
event.content === "" &&
- tags.length === 2 &&
+ (tags.length === 2 ||
+ (tags.length === 3 &&
+ tags[2].length === 2 &&
+ tags[2][0] === "role" &&
+ tags[2][1] === "member")) &&
p?.length === 2 &&
p[0] === "p" &&
/^[0-9a-f]{64}$/.test(p[1])
diff --git a/browser-host/channel-commands.test.mjs b/browser-host/channel-commands.test.mjs
index 221bc04de7..4e32979f46 100644
--- a/browser-host/channel-commands.test.mjs
+++ b/browser-host/channel-commands.test.mjs
@@ -1,7 +1,7 @@
import { expect, it } from "vitest";
import { validChannelCommand } from "./channel-commands.mjs";
const id = "11111111-1111-4111-8111-111111111111";
-it("allows only ordinary invitations, without extra roles", () => {
+it("allows ordinary invitations with an optional explicit participant role", () => {
const invite = {
kind: 9000,
created_at: 1,
@@ -12,6 +12,20 @@ it("allows only ordinary invitations, without extra roles", () => {
],
};
expect(validChannelCommand(invite)).toBe(true);
+ expect(
+ validChannelCommand({
+ ...invite,
+ tags: [...invite.tags, ["role", "member"]],
+ }),
+ ).toBe(true);
+ for (const role of ["owner", "admin", "guest", "bot", ""]) {
+ expect(
+ validChannelCommand({
+ ...invite,
+ tags: [...invite.tags, ["role", role]],
+ }),
+ ).toBe(false);
+ }
expect(
validChannelCommand({
...invite,
@@ -94,11 +108,16 @@ it("accepts one trailing outbox identifier for invitations and rejects ambiguous
p = ["p", "a".repeat(64)],
client = ["client-id", id];
expect(validChannelCommand(invite([h, p, client]))).toBe(true);
+ expect(validChannelCommand(invite([h, p, ["role", "member"], client]))).toBe(
+ true,
+ );
for (const tags of [
[h, p, client, client],
[client, h, p],
[h, p, ["client-id", "invalid"]],
[h, p, [...client, "extra"]],
+ [h, p, ["role", "member", "extra"]],
+ [h, p, ["role", "member"], ["role", "member"]],
])
expect(validChannelCommand(invite(tags))).toBe(false);
});
diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index f05f1f7008..7d1bc06aac 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -1769,7 +1769,19 @@ test("member addition works without channel creation, while role elevation remai
).toBe("accepted"),
);
expect(h.publications).toHaveLength(1);
- for (const role of ["owner", "admin", "member"]) {
+ const participantId = owner.outbox.send({
+ kind: 9000,
+ content: "",
+ tags: [...tags, ["role", "member"]],
+ });
+ await vi.waitFor(() =>
+ expect(
+ owner.local.snapshot().find((row) => row.event.id === participantId)
+ ?.delivery,
+ ).toBe("accepted"),
+ );
+ expect(h.publications).toHaveLength(2);
+ for (const role of ["owner", "admin", "guest"]) {
const denied = await h.post("sign", {
kind: 9000,
content: "",
diff --git a/docs/channels.md b/docs/channels.md
index 2bc52b6ccb..8c4cbed64e 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -732,8 +732,8 @@ confirmation. Immutable roles such as Owner and self remain protected.
The access row stays below and outside the scrolling member list, without a visible
section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock
icon, a compact audience Select (**Who can read**) with **Public** / **Private**
-labels, a hint explaining the audience and selected participation default,
-and the trailing **New people can** Select (Participate / View only), with short
+labels, a hint explaining the audience and, for public channels, the selected participation default,
+and a public-only trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
lock icon inside the dropdown. The audience Select uses the shared text-link
treatment; the participation Select is outlined and vertically centered against
@@ -752,6 +752,15 @@ Uncertain results survive closing the dialog and offer **Check save status**,
which reads without republishing. Opening or checking never writes. Private
channels with a Guest default remain intact when changing unrelated details.
+In the private-channel experiment, the footer omits **New people can** and says
+"Only people you add can read this channel." Ordinary human additions explicitly
+request Member (Participate), including when an older saved admission default is
+Guest. Existing roles are not changed; **Add as** still permits an individual
+View only or Admin choice. Public additions keep their admission-default behavior,
+agents retain Bot, and Solo/system admission is unchanged. The saved default is
+preserved so it reappears when the channel becomes public and the experiment can
+be reverted without a metadata migration.
+
Readers see General access as plain text from verified channel discovery metadata,
without calling the editing capability. Its audience description is followed by
“Viewing only. Ask an owner or admin to participate.” The private-channel summary
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 9a3a9c2e5a..8b3c33c0b8 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -112,7 +112,7 @@ const base: ChannelDetails = {
version: "v1",
name: "Alpha",
description: "Description",
- visibility: "private",
+ visibility: "public",
joinRole: "guest",
canEdit: true,
};
@@ -171,7 +171,7 @@ it.each(["public", "private"] as const)(
await screen.findByText(
visibility === "public"
? "Anyone in the community can find and view."
- : "New channel members can view.",
+ : "Only people you add can read this channel.",
),
).toBeVisible();
const option = visibility === "public" ? "Private" : "Public";
@@ -207,9 +207,35 @@ it.each(["public", "private"] as const)(
next,
expect.any(AbortSignal),
);
+ if (visibility === "public") {
+ expect(
+ screen.queryByRole("combobox", { name: "New people can" }),
+ ).not.toBeInTheDocument();
+ } else {
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent("View only");
+ }
+ },
+);
+it.each(["member", "guest"] as const)(
+ "hides the private %s admission default without rewriting it",
+ async (joinRole) => {
+ const h = harness();
+ h.load.mockResolvedValue({ ...base, visibility: "private", joinRole });
+ render(
+ ,
+ );
+ expect(
+ await screen.findByText("Only people you add can read this channel."),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("combobox", { name: "New people can" }),
+ ).not.toBeInTheDocument();
+ expect(h.save).not.toHaveBeenCalled();
},
);
-it("confirms new people's View only/Participate access independently of private reading access", async () => {
+it("confirms new people's View only/Participate access independently of public reading access", async () => {
const h = harness();
const user = userEvent.setup();
render( );
@@ -227,9 +253,9 @@ it("confirms new people's View only/Participate access independently of private
);
expect(
screen.getByRole("combobox", { name: "Who can read" }),
- ).toHaveTextContent("Private");
+ ).toHaveTextContent("Public");
expect(
- screen.getByText("New channel members can participate."),
+ screen.getByText("Anyone in the community can find and participate."),
).toBeVisible();
});
it("shows the absent Member default without saving, and a declined save reloads current access", async () => {
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 943dc77cc6..717ed368db 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -275,44 +275,46 @@ function ChannelGeneralAccessEditor({
: "Channel access could not be confirmed."
: base.visibility === "public"
? `Anyone in the community can find and ${base.joinRole === "guest" ? "view" : "participate"}.`
- : `New channel members can ${base.joinRole === "guest" ? "view" : "participate"}.`}
+ : "Only people you add can read this channel."}
-
- {
- if (
- (joinRole === "member" || joinRole === "guest") &&
- joinRole !== (base?.joinRole ?? "member")
- )
- choose({ joinRole }, 1);
- }}
- />
-
+ {base?.visibility !== "private" && (
+
+ {
+ if (
+ (joinRole === "member" || joinRole === "guest") &&
+ joinRole !== (base?.joinRole ?? "member")
+ )
+ choose({ joinRole }, 1);
+ }}
+ />
+
+ )}
{error && (
diff --git a/src/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts
index d91566c96e..e46377e85c 100644
--- a/src/features/channel-members/members.test.ts
+++ b/src/features/channel-members/members.test.ts
@@ -38,7 +38,11 @@ it.each([
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});
-function setup(type = "stream") {
+function setup(
+ type = "stream",
+ visibility: "private" | "public" = "private",
+ joinRole: "member" | "guest" = "member",
+) {
const viewer = keypair(),
relay = keypair(),
person = keypair();
@@ -94,7 +98,13 @@ function setup(type = "stream") {
signed(relay, {
kind: 39000,
content: "",
- tags: [["d", id], ["t", type], ["private"], ["name", "Design"]],
+ tags: [
+ ["d", id],
+ ["t", type],
+ [visibility],
+ ["join_role", joinRole],
+ ["name", "Design"],
+ ],
}),
roster(foreign ? person : relay, id, members, clock),
].filter((event) =>
@@ -194,7 +204,7 @@ function setup(type = "stream") {
addChannelMember(owner.session, id, person.pubkey, signal),
};
}
-it("adds a person without changing roles and confirms the live roster using membership capability alone", async () => {
+it("adds a private-channel participant explicitly and confirms the live roster using membership capability alone", async () => {
const t = setup();
await t.ready();
await t.add();
@@ -205,9 +215,7 @@ it("adds a person without changing roles and confirms the live roster using memb
["p", t.person.pubkey],
]),
);
- expect(
- t.publish.mock.calls[0]?.[0].tags.some(([tag]) => tag === "role"),
- ).toBe(false);
+ expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "member"]);
expect(t.session.channels.list().channels[0]?.members).toContain(
t.person.pubkey,
);
@@ -221,6 +229,30 @@ it("uses the bot role for an existing library agent", async () => {
await t.add();
expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "bot"]);
});
+it("adds a participant despite a private channel's saved Guest default", async () => {
+ const t = setup("stream", "private", "guest");
+ await t.ready();
+ await t.managedAdd();
+ expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "member"]);
+});
+it.each(["member", "guest"] as const)(
+ "leaves the public %s admission default to the relay",
+ async (joinRole) => {
+ const t = setup("stream", "public", joinRole);
+ await t.ready();
+ await t.add();
+ expect(
+ t.publish.mock.calls[0]?.[0].tags.some(([tag]) => tag === "role"),
+ ).toBe(false);
+ },
+);
+it("does not resend an addition for someone already in the private channel", async () => {
+ const t = setup("stream", "private", "guest");
+ await t.ready();
+ t.confirmPerson();
+ await t.managedAdd();
+ expect(t.publish).not.toHaveBeenCalled();
+});
it("preserves a rejected operation and retries its exact identity", async () => {
const t = setup();
await t.ready();
diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts
index 28b644246a..090ad73bf1 100644
--- a/src/features/channel-members/members.ts
+++ b/src/features/channel-members/members.ts
@@ -160,7 +160,8 @@ export async function addChannelMember(
([tag, value]) => tag === "p" && value === pubkey,
) &&
!event.tags.some(
- ([tag, value]) => tag === "role" && value !== "bot",
+ ([tag, value]) =>
+ tag === "role" && value !== "bot" && value !== "member",
),
);
if (pending?.guarded && !channel()?.members?.includes(pubkey))
@@ -194,6 +195,16 @@ export async function addChannelMember(
.snapshot()
.identities.some((agent) => agent.pubkey === pubkey) ||
session.profiles.snapshot().get(pubkey)?.isAgent;
+ const current = channel();
+ // Private channel additions grant participation explicitly, even if an older
+ // saved admission default is Guest. Solo and system admission stay separate.
+ const role = isAgent
+ ? "bot"
+ : !soloParticipant &&
+ current?.visibility === "private" &&
+ (current.channelType === "stream" || current.channelType === "forum")
+ ? "member"
+ : undefined;
beforeDispatch?.();
const id =
pending?.event.id ??
@@ -204,7 +215,7 @@ export async function addChannelMember(
tags: [
["h", channelId],
["p", pubkey],
- ...(isAgent ? [["role", "bot"]] : []),
+ ...(role ? [["role", role]] : []),
],
},
recovery,
From 7970632e0659b2af45d28afc9e9d22a1090c0302 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 10:04:39 -0700
Subject: [PATCH 55/63] feat(channels): choose participation before making a
channel public
Show the existing participation dropdown in the private-to-public confirmation.
Stage its choice with visibility and save one channel-details update after
confirmation. Keep Cancel, saved defaults and individual roles intact.
Cover cancellation, both default roles, one signed publication and matching
readback of both settings. Reuse shared AlertDialog, Field and Select controls.
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/channels.md | 7 ++-
.../channels/ChannelGeneralAccess.test.tsx | 50 ++++++++++++++++
src/bundled/channels/ChannelGeneralAccess.tsx | 59 +++++++++++++------
src/features/relay/channel-details.test.ts | 42 +++++++++++++
4 files changed, 139 insertions(+), 19 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 8c4cbed64e..207defbc42 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -735,7 +735,12 @@ icon, a compact audience Select (**Who can read**) with **Public** / **Private**
labels, a hint explaining the audience and, for public channels, the selected participation default,
and a public-only trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
-lock icon inside the dropdown. The audience Select uses the shared text-link
+lock icon inside the dropdown. When making a private channel public, the confirmation
+includes the same **New people can** dropdown. It starts with the saved public default
+(Participate if absent); the selected role and public visibility publish in a single
+details update after confirmation. Cancel saves neither. Both choices make the full
+history readable by the community; View only limits new people's contributions.
+The audience Select uses the shared text-link
treatment; the participation Select is outlined and vertically centered against
the audience row, with its popup aligned to the trigger’s right edge. On narrow screens the role control wraps
under the audience. The confirmation explains “People already in the channel keep
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 8b3c33c0b8..3df5577f98 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -235,6 +235,56 @@ it.each(["member", "guest"] as const)(
expect(h.save).not.toHaveBeenCalled();
},
);
+it.each(["member", "guest", undefined] as const)(
+ "stages public participation from the private %s default until confirmation",
+ async (joinRole) => {
+ const h = harness();
+ const current = { ...base, visibility: "private" as const, joinRole };
+ h.load.mockResolvedValue(current);
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const label = joinRole === "guest" ? "View only" : "Participate";
+ const nextRole = joinRole === "guest" ? "member" : "guest";
+ const nextLabel = nextRole === "guest" ? "View only" : "Participate";
+ const control = await select(user, "Who can read", "Public");
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent(label);
+ await select(user, "New people can", nextLabel);
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent(nextLabel);
+ expect(h.save).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ await waitFor(() => expect(control).toHaveFocus());
+ expect(control).toHaveTextContent("Private");
+ expect(h.save).not.toHaveBeenCalled();
+
+ await select(user, "Who can read", "Public");
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent(label);
+ await select(user, "New people can", nextLabel);
+ const next: ChannelDetails = {
+ ...current,
+ visibility: "public",
+ joinRole: nextRole,
+ };
+ h.load.mockResolvedValue(next);
+ await user.click(screen.getByRole("button", { name: "Change access" }));
+ await waitFor(() => expect(control).toHaveTextContent("Public"));
+ expect(h.save).toHaveBeenCalledExactlyOnceWith(
+ current,
+ next,
+ expect.any(AbortSignal),
+ );
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent(nextLabel);
+ },
+);
it("confirms new people's View only/Participate access independently of public reading access", async () => {
const h = harness();
const user = userEvent.setup();
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 717ed368db..eb2a9a432e 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -16,6 +16,7 @@ import { Button } from "../../shared/design-system/ui/Button";
import { AlertDialog } from "../../shared/design-system/ui/AlertDialog";
import { Select } from "../../shared/design-system/ui/Select";
import { Avatar } from "../../shared/design-system/ui/Avatar";
+import { Field } from "../../shared/design-system/ui/Field";
import type { CommunityReader } from "../../features/communities/service";
import { communityName } from "../../features/communities/names";
import { communityFromScope } from "../../features/relay/gifs";
@@ -29,6 +30,23 @@ import styles from "./ChannelMembersDialog.module.css";
import { privacyConsequence } from "./ChannelPrivacyConfirmation";
const noCommunitySubscription = () => () => {};
+const participationOptions = [
+ {
+ label: "",
+ options: [
+ {
+ value: "member",
+ label: "Participate",
+ description: "Send messages, reply, and react.",
+ },
+ {
+ value: "guest",
+ label: "View only",
+ description: "Read messages and threads.",
+ },
+ ],
+ },
+];
/** General access uses the same signed save/readback owner as channel details.
* Choices are confirmed individually, like the member rows above this section. */
@@ -288,23 +306,7 @@ function ChannelGeneralAccessEditor({
value={base ? (base.joinRole ?? "member") : ""}
placeholder={loading ? "Loading access…" : "Access unavailable"}
disabled={locked}
- groups={[
- {
- label: "",
- options: [
- {
- value: "member",
- label: "Participate",
- description: "Send messages, reply, and react.",
- },
- {
- value: "guest",
- label: "View only",
- description: "Read messages and threads.",
- },
- ],
- },
- ]}
+ groups={participationOptions}
onValueChange={(joinRole) => {
if (
(joinRole === "member" || joinRole === "guest") &&
@@ -371,7 +373,28 @@ function ChannelGeneralAccessEditor({
>
}
- />
+ >
+ {base.visibility === "private" && choice.visibility === "public" && (
+ New people can}
+ nativeLabel={false}
+ >
+ {
+ if (joinRole === "member" || joinRole === "guest")
+ setChoice((current) => current && { ...current, joinRole });
+ }}
+ />
+
+ )}
+
)}
);
diff --git a/src/features/relay/channel-details.test.ts b/src/features/relay/channel-details.test.ts
index 906a740585..88edc4fe65 100644
--- a/src/features/relay/channel-details.test.ts
+++ b/src/features/relay/channel-details.test.ts
@@ -140,6 +140,48 @@ it.each([undefined, "member"] as const)(
},
);
+it.each(["member", "guest"] as const)(
+ "publishes public visibility and the %s admission choice together and verifies both",
+ async (joinRole) => {
+ const h = harness();
+ const initialRole = joinRole === "member" ? "guest" : "member";
+ const metadata = (visibility: string, role: string) =>
+ h.record(39000, [
+ ...h.metadata("original", "Old description", visibility).tags.slice(1),
+ ["join_role", role],
+ ]);
+ h.set([metadata("private", initialRole), ...h.events().slice(1)]);
+ const roster = h.entry(2);
+ const base = await h.owner.capability.load(id);
+ const next = { ...base, visibility: "public" as const, joinRole };
+ // A readback confirming visibility alone cannot confirm this combined intent.
+ h.publish.mockImplementationOnce(async () => {
+ h.set([metadata("public", initialRole), ...h.events().slice(1)]);
+ });
+ await expect(h.owner.capability.save(base, next)).rejects.toThrow(
+ "may have been saved",
+ );
+ expect(h.sign).toHaveBeenCalledOnce();
+ expect(h.sign.mock.calls[0]?.[0].tags).toEqual([
+ ["h", id],
+ ["name", base.name],
+ ["about", base.description],
+ ["visibility", "open"],
+ ["join_role", joinRole],
+ ]);
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.owner.capability.snapshot(id)).toEqual({
+ draft: next,
+ status: "unconfirmed",
+ });
+ h.set([metadata("public", joinRole), ...h.events().slice(1)]);
+ await h.owner.capability.check(id);
+ expect(h.owner.capability.snapshot(id)).toBeUndefined();
+ expect(h.entry(2)).toBe(roster);
+ expect(h.publish).toHaveBeenCalledOnce();
+ },
+);
+
it.each([undefined, "member", "guest"])(
"only sends a changed admission role (base %s)",
(joinRole) => {
From 95a975601744fbaa223168027b78d799f820e7bf Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 10:22:19 -0700
Subject: [PATCH 56/63] test(channels): cover public and private member access
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/ChannelMembersDialog.test.tsx | 166 ++++++++++--------
1 file changed, 89 insertions(+), 77 deletions(-)
diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx
index d58c6c4008..e57876e34f 100644
--- a/src/bundled/channels/ChannelMembersDialog.test.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.test.tsx
@@ -2390,84 +2390,96 @@ it("does not offer Solo agent additions when the existing admission API is unava
expect(t.publish).not.toHaveBeenCalled();
});
-it("shows personal contribution roles and General access in the members dialog", async () => {
- const h = await setup();
- cleanup();
- const id = "11111111-1111-4111-8111-111111111111";
- let role = "member";
- const query = h.query.getMockImplementation();
- if (!query) throw new Error("Missing query");
- h.query.mockImplementation(async (filters) => [
- ...(await query(filters)).map((event) =>
- event.kind === 39002
- ? signed(h.relay, {
- ...event,
- created_at: event.created_at + (role === "member" ? 1 : 2),
- tags: [
- ["d", id],
- ["p", h.viewer.pubkey, "", role],
- ],
- })
- : event,
- ),
- signed(h.relay, { kind: 39001, content: "", tags: [["d", id]] }),
- ]);
- const details = {
- channelId: id,
- version: "v1",
- name: "Channel",
- description: "",
- visibility: "private" as const,
- joinRole: "guest" as const,
- canEdit: true,
- };
- const save = vi.fn(async () => {});
- render(
-
- details,
- save,
- check: async () => {},
- snapshot: () => undefined,
- subscribe: () => () => {},
- },
- }}
- channelId={id}
- />
- ,
- );
- await h.user.click(screen.getByRole("button", { name: "Access" }));
- expect(
- await screen.findByRole("region", { name: "General access" }),
- ).toBeVisible();
- await vi.waitFor(() =>
+it.each(["public", "private"] as const)(
+ "shows personal contribution roles and %s General access in the members dialog",
+ async (visibility) => {
+ const h = await setup();
+ cleanup();
+ const id = "11111111-1111-4111-8111-111111111111";
+ let role = "member";
+ const query = h.query.getMockImplementation();
+ if (!query) throw new Error("Missing query");
+ h.query.mockImplementation(async (filters) => [
+ ...(await query(filters)).map((event) =>
+ event.kind === 39002
+ ? signed(h.relay, {
+ ...event,
+ created_at: event.created_at + (role === "member" ? 1 : 2),
+ tags: [
+ ["d", id],
+ ["p", h.viewer.pubkey, "", role],
+ ],
+ })
+ : event,
+ ),
+ signed(h.relay, { kind: 39001, content: "", tags: [["d", id]] }),
+ ]);
+ const details = {
+ channelId: id,
+ version: "v1",
+ name: "Channel",
+ description: "",
+ visibility,
+ joinRole: "guest" as const,
+ canEdit: true,
+ };
+ const save = vi.fn(async () => {});
+ render(
+
+ details,
+ save,
+ check: async () => {},
+ snapshot: () => undefined,
+ subscribe: () => () => {},
+ },
+ }}
+ channelId={id}
+ />
+ ,
+ );
+ await h.user.click(screen.getByRole("button", { name: "Access" }));
expect(
- screen.getByRole("combobox", { name: "New people can" }),
- ).toBeEnabled(),
- );
- expect(
- screen.getByRole("combobox", { name: "New people can" }),
- ).toHaveTextContent("View only");
- expect(
- await within(
- screen.getByRole("region", { name: "Member list" }),
- ).findAllByText("Participating"),
- ).not.toHaveLength(0);
- role = "guest";
- await h.user.click(
- screen.getByRole("button", { name: "Refresh member data" }),
- );
- expect(
- await within(
- screen.getByRole("region", { name: "Member list" }),
- ).findByText("Viewing only"),
- ).toBeVisible();
- expect(save).not.toHaveBeenCalled();
-});
+ await screen.findByRole("region", { name: "General access" }),
+ ).toBeVisible();
+ await vi.waitFor(() =>
+ expect(
+ screen.getByRole("combobox", { name: "Who can read" }),
+ ).toBeEnabled(),
+ );
+ if (visibility === "public") {
+ expect(
+ screen.getByRole("combobox", { name: "New people can" }),
+ ).toHaveTextContent("View only");
+ } else {
+ expect(
+ screen.queryByRole("combobox", { name: "New people can" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText("Only people you add can read this channel."),
+ ).toBeVisible();
+ }
+ expect(
+ await within(
+ screen.getByRole("region", { name: "Member list" }),
+ ).findAllByText("Participating"),
+ ).not.toHaveLength(0);
+ role = "guest";
+ await h.user.click(
+ screen.getByRole("button", { name: "Refresh member data" }),
+ );
+ expect(
+ await within(
+ screen.getByRole("region", { name: "Member list" }),
+ ).findByText("Viewing only"),
+ ).toBeVisible();
+ expect(save).not.toHaveBeenCalled();
+ },
+);
it.each([false, true])(
"explains reading and contribution access without joining or editing as a Guest %s",
From 0667b4c18b04b404164939a81d0b96721ffd8b20 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 10:58:57 -0700
Subject: [PATCH 57/63] fix(relay): restore previews from equal metadata
versions
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/features/relay/discovery-permissions.test.ts | 6 +++++-
src/features/relay/discovery.ts | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/features/relay/discovery-permissions.test.ts b/src/features/relay/discovery-permissions.test.ts
index ad2b458572..9c6fe2f1d4 100644
--- a/src/features/relay/discovery-permissions.test.ts
+++ b/src/features/relay/discovery-permissions.test.ts
@@ -1,5 +1,6 @@
import { expect, it } from "vitest";
import { DiscoveryState } from "./discovery";
+import { eventDto } from "./events";
import { keypair, metadata, signed } from "./testing";
const relay = keypair(),
@@ -94,7 +95,10 @@ it.each(["guest", "member"])(
state.accept(membership);
state.deny(id);
expect(state.canAccess(id)).toBe(false);
- state.resume(id, publicMetadata);
+ const fresh = eventDto(publicMetadata);
+ expect(fresh).not.toBe(publicMetadata);
+ expect(fresh.id).toBe(publicMetadata.id);
+ state.resume(id, fresh);
expect(state.canAccess(id)).toBe(true);
expect(state.canParticipate(id)).toBe(false);
expect(state.canWrite(id)).toBe(false);
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index 89c854a807..a2161bf438 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -174,7 +174,7 @@ export class DiscoveryState {
metadata.pubkey === this.relayAuthor &&
tag(metadata, "d") === id &&
openMetadata(metadata) &&
- newer(this.metadata.get(id), metadata) === metadata
+ newer(this.metadata.get(id), metadata)?.id === metadata.id
) {
const changed = !this.confirmedPreviews.has(id);
this.confirmedPreviews.add(id);
From 6e4b5f54a3ba994221a19bded66389959d37d5bd Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 11:00:49 -0700
Subject: [PATCH 58/63] fix(relay): omit departed viewer roles from previews
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/features/relay/discovery.ts | 2 +-
.../relay/session-permissions.test.ts | 24 +++++++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index a2161bf438..d02be1b8c8 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -311,7 +311,7 @@ export class DiscoveryState {
// The relay tags a system roster too, so a roster that arrives before its
// metadata (the full pass reads names later) never lists as a channel.
const roster = this.rosters.get(id);
- const viewerRole = this.viewerRole(id);
+ const viewerRole = this.authorized(id) ? this.viewerRole(id) : undefined;
const type = this.isSolo(id)
? "solo"
: event
diff --git a/src/features/relay/session-permissions.test.ts b/src/features/relay/session-permissions.test.ts
index 782224c0f2..f80ce5ecb2 100644
--- a/src/features/relay/session-permissions.test.ts
+++ b/src/features/relay/session-permissions.test.ts
@@ -84,6 +84,11 @@ async function setup(
member = false;
time++;
}
+ if (event.kind === 9021) {
+ member = true;
+ role = joinRole;
+ time++;
+ }
if (
event.kind === 9001 &&
event.tags.some(([key, value]) => key === "p" && value === agent.pubkey)
@@ -292,6 +297,25 @@ it("retains guest history, details and Leave", async () => {
expect(h.session.channels.list().channels).toHaveLength(0);
});
+it("drops a departed Guest's role when a newer public preview permits rejoining in the same session", async () => {
+ const h = await setup("guest");
+ await h.session.channelLifecycle.run("leave", id);
+ // Advance metadata independently of the equal-version preview regression.
+ await h.default("member");
+ await h.session.channels.resolve?.([id]);
+ const preview = h.session.channels.get?.(id);
+ expect(preview).toMatchObject({ readOnly: true, joinRole: "member" });
+ expect(preview?.viewerRole).toBeUndefined();
+ expect(h.session.channels.list().channels).toHaveLength(0);
+ await h.session.channelLifecycle.run("join", id);
+ expect(h.publish.mock.calls.map(([event]) => event.kind)).toEqual([
+ 9022, 9021,
+ ]);
+ expect(h.session.channels.get?.(id)).toMatchObject({ viewerRole: "member" });
+ h.session.messages.send(id, "Rejoined participant");
+ await vi.waitFor(() => expect(h.publish).toHaveBeenCalledTimes(3));
+});
+
it("lets a Guest read canvas content and history without granting save access", async () => {
const h = await setup("guest");
expect(await h.session.canvas.read(id)).toMatchObject({
From cc752589ed4c4e1c1d0c31ec36113b982b605bd7 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 11:04:38 -0700
Subject: [PATCH 59/63] fix(channels): retain guest membership actions
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../channels/ChannelCompletion.test.tsx | 6 ++
src/bundled/channels/ChannelCompletion.tsx | 9 +-
.../channels/useChannelTabState.test.tsx | 9 ++
src/bundled/channels/useChannelTabState.ts | 7 +-
src/bundled/inbox/InboxPage.test.tsx | 44 ++++++++
src/bundled/inbox/InboxPage.tsx | 2 +-
.../messages/MessageManagement.test.tsx | 100 ++++++++++++++----
src/features/messages/MessageManagement.tsx | 23 ++--
8 files changed, 166 insertions(+), 34 deletions(-)
diff --git a/src/bundled/channels/ChannelCompletion.test.tsx b/src/bundled/channels/ChannelCompletion.test.tsx
index 2be1d0467a..12b648abbf 100644
--- a/src/bundled/channels/ChannelCompletion.test.tsx
+++ b/src/bundled/channels/ChannelCompletion.test.tsx
@@ -108,6 +108,11 @@ it("offers joined streams/forums, including private and still-confirming channel
channel("stream"),
channel("forum", { channelType: "forum" }),
channel("private", { private: true }),
+ channel("private-guest", {
+ private: true,
+ readOnly: true,
+ viewerRole: "guest",
+ }),
channel("huddle", { huddle: true }),
channel("archived", { archived: true }),
// The real store's shape for a joined channel restored from cache.
@@ -126,6 +131,7 @@ it("offers joined streams/forums, including private and still-confirming channel
"cached",
"forum",
"private",
+ "private-guest",
"stream",
"unknown",
]);
diff --git a/src/bundled/channels/ChannelCompletion.tsx b/src/bundled/channels/ChannelCompletion.tsx
index c1b1bd47e8..7609cf0857 100644
--- a/src/bundled/channels/ChannelCompletion.tsx
+++ b/src/bundled/channels/ChannelCompletion.tsx
@@ -32,14 +32,19 @@ function joined(channel: ChannelSummary, viewer: string | undefined) {
return (
!!viewer &&
referable(channel) &&
- (!channel.readOnly || !!channel.cached) &&
+ (!channel.readOnly || channel.viewerRole === "guest" || !!channel.cached) &&
!!channel.members?.includes(viewer)
);
}
/** An open channel the viewer can preview but hasn't joined. */
function open(channel: ChannelSummary) {
- return !!channel.readOnly && !channel.private && referable(channel);
+ return (
+ !!channel.readOnly &&
+ channel.viewerRole !== "guest" &&
+ !channel.private &&
+ referable(channel)
+ );
}
/** The channel name rule the other channel pickers use (`matchName`), without
diff --git a/src/bundled/channels/useChannelTabState.test.tsx b/src/bundled/channels/useChannelTabState.test.tsx
index ce6ecd5a27..70bcb504bf 100644
--- a/src/bundled/channels/useChannelTabState.test.tsx
+++ b/src/bundled/channels/useChannelTabState.test.tsx
@@ -1013,6 +1013,15 @@ test.each(["missing", "readOnly", "archived", "cached"])(
},
);
+test("a joined Guest can open a readable thread without writing access", () => {
+ const session = tabSession([
+ { id: "alpha", name: "Alpha", readOnly: true, viewerRole: "guest" },
+ ]);
+ const tabs = channelTabs(session);
+ expect(tabs.open("alpha", "r1")).toBe(true);
+ expect(tabs.list("alpha")).toHaveLength(1);
+});
+
test("an agent's tab name stays with the tab until cleared", () => {
const session = tabSession();
const tabs = channelTabs(session);
diff --git a/src/bundled/channels/useChannelTabState.ts b/src/bundled/channels/useChannelTabState.ts
index 2d47cc8009..2034409b8b 100644
--- a/src/bundled/channels/useChannelTabState.ts
+++ b/src/bundled/channels/useChannelTabState.ts
@@ -352,7 +352,12 @@ export function channelTabs(session: RelaySession, navigation?: Navigation) {
const channel = session.channels
.list()
.channels.find((item) => item.id === channelId);
- if (!channel || channel.readOnly || channel.archived || channel.cached)
+ if (
+ !channel ||
+ (channel.readOnly && channel.viewerRole !== "guest") ||
+ channel.archived ||
+ channel.cached
+ )
throw new Error("Your owner cannot open threads in this channel.");
if (threads(channelId).some((thread) => thread.root === rootId))
return false;
diff --git a/src/bundled/inbox/InboxPage.test.tsx b/src/bundled/inbox/InboxPage.test.tsx
index ab137d292f..dadd4ab125 100644
--- a/src/bundled/inbox/InboxPage.test.tsx
+++ b/src/bundled/inbox/InboxPage.test.tsx
@@ -1517,6 +1517,7 @@ function fixture(
soloChannel?: boolean;
memberAgents?: 1 | 2;
readCapability?: "read-only" | "unsupported";
+ guest?: boolean;
} = {},
) {
const viewer = keypair(),
@@ -1666,6 +1667,18 @@ function fixture(
]
: []),
];
+ if (options.guest) {
+ const roster = events[0];
+ if (!roster) throw new Error("Missing channel roster");
+ events[0] = signed(relayKey, {
+ ...roster,
+ tags: roster.tags.map((tag) =>
+ tag[0] === "p" && tag[1] === viewer.pubkey
+ ? ["p", viewer.pubkey, "", "guest"]
+ : tag,
+ ),
+ });
+ }
if (options.readCapability) {
journal = {
...newReadJournal(),
@@ -2261,6 +2274,37 @@ it("Mentions orders and classifies matching senders and reads only through match
).toMatchObject({ messageId: progress.id, unreadCount: 1 });
});
+it("lets a joined Guest read an Inbox item and change its personal unread state", async () => {
+ const h = fixture({ guest: true });
+ render(h.view);
+ await screen.findByText("Please review this");
+ await chooseFilter("Mentions");
+ expect(h.session.channels.get?.("room")).toMatchObject({
+ viewerRole: "guest",
+ readOnly: true,
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Open Alice in #Design" }),
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("img", { name: "Unread" }),
+ ).not.toBeInTheDocument(),
+ );
+ expect(
+ screen.queryByText(
+ "Inbox action expired. Close and reopen the conversation.",
+ ),
+ ).not.toBeInTheDocument();
+ fireEvent.click(await openRowMenu());
+ await waitFor(() =>
+ expect(h.journal()?.localUnread[`msg:${h.mention.id}`]).toBeTypeOf(
+ "number",
+ ),
+ );
+ expect(h.session.channels.get?.("room")?.viewerRole).toBe("guest");
+});
+
async function openRowMenu(
method: "context" | "keyboard" | "contextKey" = "context",
) {
diff --git a/src/bundled/inbox/InboxPage.tsx b/src/bundled/inbox/InboxPage.tsx
index 04160a28d9..31f49d93ae 100644
--- a/src/bundled/inbox/InboxPage.tsx
+++ b/src/bundled/inbox/InboxPage.tsx
@@ -784,7 +784,7 @@ function InboxList({
!!channel &&
!!current &&
!current.cached &&
- !current.readOnly &&
+ (!current.readOnly || current.viewerRole === "guest") &&
!current.archived &&
current.channelType === channel.channelType &&
!!current.members?.includes(scope.viewer) &&
diff --git a/src/features/messages/MessageManagement.test.tsx b/src/features/messages/MessageManagement.test.tsx
index 9069a73b58..815f3ca594 100644
--- a/src/features/messages/MessageManagement.test.tsx
+++ b/src/features/messages/MessageManagement.test.tsx
@@ -71,6 +71,7 @@ async function fixture(
agentOwner: "viewer" | "other" | undefined = undefined,
chatCard = false,
shareRead?: Promise,
+ viewerRole: "member" | "guest" = "member",
) {
vi.stubGlobal(
"ResizeObserver",
@@ -180,7 +181,14 @@ async function fixture(
if (filter.kinds?.includes(39000) || filter.kinds?.includes(39002))
return [
metadata(relay, "room", "Room"),
- roster(relay, "room", [viewer.pubkey, peer.pubkey]),
+ signed(relay, {
+ ...roster(relay, "room", [viewer.pubkey, peer.pubkey]),
+ tags: [
+ ["d", "room"],
+ ["p", viewer.pubkey, "", viewerRole],
+ ["p", peer.pubkey, "", "member"],
+ ],
+ }),
];
if (filter.kinds?.includes(0))
return [
@@ -564,32 +572,78 @@ it("reverses an own message force from its menu without changing notification el
expect(h.publications).toHaveLength(0);
});
-it("toggles actual unread state immediately without a dialog", async () => {
- const h = await fixture(false, true);
- const target = {
- kind: "message" as const,
- channelId: "room",
- messageId: h.original.id,
- };
- expect(h.owner.session.unread.attention("room", h.original.id).unread).toBe(
- true,
- );
- fireEvent.click(await screen.findByRole("menuitem", { name: "Mark read" }));
- await waitFor(() =>
- expect(h.owner.session.unread.attention("room", h.original.id).unread).toBe(
+it.each(["member", "guest"] as const)(
+ "lets a joined %s toggle actual unread state and follow without writing",
+ async (role) => {
+ const h = await fixture(
false,
- ),
- );
- expect(screen.queryByRole("dialog")).toBeNull();
- fireEvent.click(screen.getByRole("button", { name: "Message actions" }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "Mark unread" }));
- await waitFor(() =>
+ true,
+ false,
+ false,
+ false,
+ undefined,
+ false,
+ undefined,
+ role,
+ );
+ const target = {
+ kind: "message" as const,
+ channelId: "room",
+ messageId: h.original.id,
+ };
expect(h.owner.session.unread.attention("room", h.original.id).unread).toBe(
true,
- ),
+ );
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Mark read" }));
+ await waitFor(() =>
+ expect(
+ h.owner.session.unread.attention("room", h.original.id).unread,
+ ).toBe(false),
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Message actions" }));
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Mark unread" }),
+ );
+ await waitFor(() =>
+ expect(
+ h.owner.session.unread.attention("room", h.original.id).unread,
+ ).toBe(true),
+ );
+ expect(h.owner.session.unread.snapshot(target).observedCount).toBe(1);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Message actions" }));
+ expect(screen.queryByRole("menuitem", { name: "Edit message" })).toBeNull();
+ expect(
+ screen.queryByRole("menuitem", { name: "Delete message" }),
+ ).toBeNull();
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Follow thread" }),
+ );
+ await waitFor(() =>
+ expect(h.owner.session.unread.following("room", h.original.id)).toBe(
+ true,
+ ),
+ );
+ expect(h.publications).toHaveLength(0);
+ },
+);
+
+it("keeps editing and ordinary deletion hidden from a Guest who authored an earlier message", async () => {
+ const h = await fixture(
+ true,
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ false,
+ undefined,
+ "guest",
);
- expect(h.owner.session.unread.snapshot(target).observedCount).toBe(1);
- expect(screen.queryByRole("dialog")).toBeNull();
+ expect(h.owner.session.channels.get?.("room")?.viewerRole).toBe("guest");
+ expect(screen.queryByRole("menuitem", { name: "Edit message" })).toBeNull();
+ expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
expect(h.publications).toHaveLength(0);
});
diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx
index 9014b8a110..c698381596 100644
--- a/src/features/messages/MessageManagement.tsx
+++ b/src/features/messages/MessageManagement.tsx
@@ -244,17 +244,19 @@ export function MessageManagement({
);
}
-/** Read-state, edit and delete items share one gate: a managed, delivered row
- * in a writable channel. */
+/** Personal read/follow actions need current membership, not writing access. */
function useManagedMessage(row: ChannelMessage, session: RelaySession) {
const management = useContext(Management);
- const writable = useListedChannel(
+ const joined = useListedChannel(
session.channels,
row.channelId,
- (channel) => !!channel && !channel.readOnly,
+ (channel) =>
+ !!channel &&
+ !channel.cached &&
+ (!channel.readOnly || channel.viewerRole === "guest"),
);
return management &&
- writable &&
+ joined &&
!row.membership &&
!row.diff &&
(!row.delivery || ["accepted", "seen"].includes(row.delivery))
@@ -325,6 +327,11 @@ export function MessageManagementItems({
row.channelId,
(channel) => channel?.channelType === "dm",
);
+ const writable = useListedChannel(
+ session.channels,
+ row.channelId,
+ (channel) => !!channel && !channel.readOnly && !channel.cached,
+ );
const threadRootId = row.threadRootId ?? row.id;
const following = useSyncExternalStore(session.unread.subscribeSync, () =>
session.unread.following(row.channelId, threadRootId),
@@ -343,7 +350,8 @@ export function MessageManagementItems({
item.event.tags.some(([name, id]) => name === "e" && id === row.id),
);
const own = row.authorId === session.viewer && !archived;
- const canEdit = own && editor?.current && lastEditableMessage(session, [row]);
+ const canEdit =
+ writable && own && editor?.current && lastEditableMessage(session, [row]);
const authorization =
row.authorId !== session.viewer &&
session.viewer &&
@@ -351,7 +359,8 @@ export function MessageManagementItems({
ownership.owner === session.viewer
? { agentId: row.authorId, ownerId: session.viewer }
: undefined;
- const canDelete = (own || !!authorization) && session.outbox?.supports(5);
+ const canDelete =
+ writable && (own || !!authorization) && session.outbox?.supports(5);
const act = (action: () => void) =>
afterClose ? afterClose(action) : action();
return (
From 4ba3273bf5671c2f3a1aae4e10e74f9024445963 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 11:11:34 -0700
Subject: [PATCH 60/63] fix(messages): enable verified guest owner deletion
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
.../messages/MessageManagement.test.tsx | 138 ++++++++++++------
src/features/messages/MessageManagement.tsx | 19 ++-
src/features/relay/session.ts | 3 +-
3 files changed, 110 insertions(+), 50 deletions(-)
diff --git a/src/features/messages/MessageManagement.test.tsx b/src/features/messages/MessageManagement.test.tsx
index 815f3ca594..6f5f5173c3 100644
--- a/src/features/messages/MessageManagement.test.tsx
+++ b/src/features/messages/MessageManagement.test.tsx
@@ -45,7 +45,7 @@ import {
signReadState,
// @ts-expect-error Exercise the production codec with disposable identities.
} from "../../../browser-host/read-state.mjs";
-import type { RelayEvent } from "../relay/events";
+import { eventDto, type RelayEvent } from "../relay/events";
composerDOMFixture();
const owners: ReturnType[] = [];
@@ -117,30 +117,32 @@ async function fixture(
);
const agentProfile =
agent && agentOwnerKey
- ? signed(agent, {
- kind: 0,
- content: JSON.stringify({ name: "Test agent" }),
- tags: [
- [
- "auth",
- agentOwnerKey.pubkey,
- "kind=0&created_at<1700000001",
- bytesToHex(
- schnorr.sign(
- new Uint8Array(
- await crypto.subtle.digest(
- "SHA-256",
- new TextEncoder().encode(
- `nostr:agent-auth:${agent.pubkey}:kind=0&created_at<1700000001`,
+ ? eventDto(
+ signed(agent, {
+ kind: 0,
+ content: JSON.stringify({ name: "Test agent" }),
+ tags: [
+ [
+ "auth",
+ agentOwnerKey.pubkey,
+ "kind=0&created_at<1700000001",
+ bytesToHex(
+ schnorr.sign(
+ new Uint8Array(
+ await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(
+ `nostr:agent-auth:${agent.pubkey}:kind=0&created_at<1700000001`,
+ ),
),
),
+ agentOwnerKey.secret,
),
- agentOwnerKey.secret,
),
- ),
+ ],
],
- ],
- })
+ }),
+ )
: undefined;
const publications: {
event: RelayEvent;
@@ -356,32 +358,78 @@ it("edits in the composer, retains a rejected change and retries the same operat
);
});
-it("offers a confirmed NIP-09 deletion for an agent owned by the viewer", async () => {
- const h = await fixture(false, false, false, false, false, "viewer");
- const item = await screen.findByRole("menuitem", { name: "Delete message" });
- expect(item).toBeVisible();
- fireEvent.click(item);
- const confirmation = await screen.findByRole("alertdialog", {
- name: "Delete message?",
- });
- expect(confirmation).toHaveTextContent(
- "This requests removal of this message from Buzz’s relay.",
- );
- expect(confirmation).toHaveTextContent("People may still have copies.");
- expect(h.publications).toHaveLength(0);
- fireEvent.click(screen.getByRole("button", { name: "Delete" }));
- await waitFor(() => expect(h.publications).toHaveLength(1));
- expect(h.publication(0).event.kind).toBe(5);
- expect(h.publication(0).event.pubkey).toBe(h.owner.session.viewer);
- expect(h.publication(0).event.tags).toContainEqual(["e", h.original.id]);
- await act(async () => h.publication(0).result.resolve());
-});
+it.each(["member", "guest"] as const)(
+ "offers confirmed owner-agent deletion and retry to a joined %s",
+ async (role) => {
+ const h = await fixture(
+ false,
+ false,
+ false,
+ false,
+ false,
+ "viewer",
+ false,
+ undefined,
+ role,
+ );
+ const item = await screen.findByRole("menuitem", {
+ name: "Delete message",
+ });
+ await waitFor(() =>
+ expect(h.owner.session.profiles.owner?.(h.original.pubkey)).toBe(
+ h.owner.session.viewer,
+ ),
+ );
+ expect(item).toBeVisible();
+ fireEvent.click(item);
+ const confirmation = await screen.findByRole("alertdialog", {
+ name: "Delete message?",
+ });
+ expect(confirmation).toHaveTextContent(
+ "This requests removal of this message from Buzz’s relay.",
+ );
+ expect(confirmation).toHaveTextContent("People may still have copies.");
+ expect(h.publications).toHaveLength(0);
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(h.publications).toHaveLength(1));
+ expect(h.publication(0).event.kind).toBe(5);
+ expect(h.publication(0).event.pubkey).toBe(h.owner.session.viewer);
+ expect(h.publication(0).event.tags).toContainEqual(["e", h.original.id]);
+ if (role === "guest") {
+ await act(async () =>
+ h.publication(0).result.reject(new PublishRejected("Deletion refused")),
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Deletion refused",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ await waitFor(() => expect(h.publications).toHaveLength(2));
+ expect(h.publication(1).event.id).toBe(h.publication(0).event.id);
+ await act(async () => h.publication(1).result.resolve());
+ } else await act(async () => h.publication(0).result.resolve());
+ },
+);
-it("does not offer agent-message deletion to a non-owner", async () => {
- await fixture(false, false, false, false, false, "other");
- await screen.findByRole("menuitem", { name: /Mark (unread|read)/ });
- expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
-});
+it.each(["member", "guest"] as const)(
+ "does not offer agent-message deletion to a %s non-owner",
+ async (role) => {
+ await fixture(
+ false,
+ false,
+ false,
+ false,
+ false,
+ "other",
+ false,
+ undefined,
+ role,
+ );
+ await screen.findByRole("menuitem", { name: /Mark (unread|read)/ });
+ expect(
+ screen.queryByRole("menuitem", { name: "Delete message" }),
+ ).toBeNull();
+ },
+);
it("does not expose edit or delete for another person's message", async () => {
await fixture(false);
diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx
index c698381596..de6d4c19ad 100644
--- a/src/features/messages/MessageManagement.tsx
+++ b/src/features/messages/MessageManagement.tsx
@@ -143,7 +143,10 @@ export function MessageManagement({
((!channelId || channelId === selection.row.channelId) &&
channels.channels.some(
(channel) =>
- channel.id === selection.row.channelId && !channel.readOnly,
+ channel.id === selection.row.channelId &&
+ !channel.cached &&
+ (!channel.readOnly ||
+ (channel.viewerRole === "guest" && !!selection.authorization)),
));
useEffect(() => {
if (!available) setSelection(undefined);
@@ -360,7 +363,7 @@ export function MessageManagementItems({
? { agentId: row.authorId, ownerId: session.viewer }
: undefined;
const canDelete =
- writable && (own || !!authorization) && session.outbox?.supports(5);
+ ((writable && own) || !!authorization) && session.outbox?.supports(5);
const act = (action: () => void) =>
afterClose ? afterClose(action) : action();
return (
@@ -486,7 +489,9 @@ function DeleteMessageDialog({
(channel) =>
channel.id === row.channelId &&
!channel.archived &&
- !channel.readOnly,
+ !channel.cached &&
+ (!channel.readOnly ||
+ (channel.viewerRole === "guest" && !!authorization)),
)
)
throw new Error(
@@ -578,7 +583,13 @@ export function MessageManagementStatus() {
)}
session.outbox?.retry(item.event.id)}
>
Retry message update
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 1804da54b2..159cd5c892 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -2224,7 +2224,8 @@ export function createRelaySession(
(id, owner) =>
!closed &&
(channels.canWrite(id) ||
- // This authorization is supplied only by editOwnedAgent after verification.
+ // editOwnedAgent and messages.remove supply owner evidence;
+ // validateChannelWrite rechecks it before signing and publication.
(!!owner && channels.canParticipate(id))),
transport?.scope,
writer && (!writer.kinds || writer.kinds.includes(1984))
From 3ed454fd6d46276d54c76dedbcae5403c1d947d7 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 11:12:31 -0700
Subject: [PATCH 61/63] test(browser): retain personal guest message actions
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
tests/browser/channel-permissions.spec.mjs | 30 ++++++++++++++++++++--
1 file changed, 28 insertions(+), 2 deletions(-)
diff --git a/tests/browser/channel-permissions.spec.mjs b/tests/browser/channel-permissions.spec.mjs
index 38feedbd4a..9677ab2280 100644
--- a/tests/browser/channel-permissions.spec.mjs
+++ b/tests/browser/channel-permissions.spec.mjs
@@ -22,7 +22,7 @@ const permalink = (app, messageId) =>
}),
)}`;
-async function readPermalink(page, app, messageId) {
+async function readPermalink(page, app, messageId, joined = false) {
await page.goto(permalink(app, messageId));
const thread = page.getByRole("region", {
name: "Thread messages",
@@ -55,6 +55,32 @@ async function readPermalink(page, app, messageId) {
await expect(
page.getByRole("button", { name: /^(React|Add reaction)/ }),
).toHaveCount(0);
+ const readAction = page.getByRole("menuitem", {
+ name: /^(Mark read|Mark unread)$/,
+ });
+ const followAction = page.getByRole("menuitem", {
+ name: /^(Follow thread|Unfollow thread)$/,
+ });
+ if (joined) {
+ await expect(readAction).toBeVisible();
+ const wasFollowing = (await followAction.textContent()).includes(
+ "Unfollow",
+ );
+ await followAction.click();
+ await hoverMessage(row);
+ await row
+ .getByRole("button", { name: "More message actions", exact: true })
+ .click();
+ await expect(
+ page.getByRole("menuitem", {
+ name: wasFollowing ? "Follow thread" : "Unfollow thread",
+ exact: true,
+ }),
+ ).toBeVisible();
+ } else {
+ await expect(readAction).toHaveCount(0);
+ await expect(followAction).toHaveCount(0);
+ }
await page.keyboard.press("Escape");
}
@@ -99,7 +125,7 @@ test.describe("Guest-default permalinks", () => {
page,
app,
}) => {
- await readPermalink(page, app, app.exact.target.id);
+ await readPermalink(page, app, app.exact.target.id, true);
await page
.getByRole("button", { name: "Channel actions", exact: true })
.click();
From 8df47a2ad53811ba8a4348f4c08ff3b6386f0c94 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 11:45:07 -0700
Subject: [PATCH 62/63] test(messages): settle queued submissions before
removal
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
tests/browser/follow-up-queue.spec.mjs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/browser/follow-up-queue.spec.mjs b/tests/browser/follow-up-queue.spec.mjs
index b07dc1150b..1f26e36c07 100644
--- a/tests/browser/follow-up-queue.spec.mjs
+++ b/tests/browser/follow-up-queue.spec.mjs
@@ -227,9 +227,16 @@ test("queue focus survives preview release and pointer removal", async ({
await editor.press("End");
await editor.pressSequentially("Keep this row");
await editor.press("Enter");
+ // Admission is asynchronous: settle the first submission before typing into
+ // the draft it clears, then establish both rows before testing removal.
+ await expect(queue).toContainText("Keep this row");
+ await expect(editor).not.toContainText("Keep this row");
+ await expect(editor).toHaveAttribute("contenteditable", "true");
await editor.press("End");
await editor.pressSequentially("Remove last row");
await editor.press("Enter");
+ await expect(queue).toContainText("Remove last row");
+ await expect(queue.getByRole("listitem")).toHaveCount(2);
const remove = queue.getByRole("button", { name: "Remove queued message" });
await queue.getByRole("listitem").last().hover();
// WebKit does not focus clicked buttons; focus explicitly to cover the same
From 81151ee4f69513eed68edc21b397a30d98ebf6eb Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 29 Oct 2026 12:08:42 -0700
Subject: [PATCH 63/63] fix(messages): gate retry on current ownership
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
src/bundled/channels/ConversationTab.test.tsx | 6 +-
.../conversation/EmbeddedThread.test.tsx | 2 +-
.../messages/MessageManagement.test.tsx | 99 +++++++++++-
src/features/messages/MessageManagement.tsx | 44 +++--
src/features/relay/session.ts | 151 ++++++++++--------
.../owned-agent-message-deletion.spec.mjs | 40 +++++
6 files changed, 258 insertions(+), 84 deletions(-)
diff --git a/src/bundled/channels/ConversationTab.test.tsx b/src/bundled/channels/ConversationTab.test.tsx
index a49bc470ee..ca7fe4e757 100644
--- a/src/bundled/channels/ConversationTab.test.tsx
+++ b/src/bundled/channels/ConversationTab.test.tsx
@@ -138,11 +138,13 @@ it.each(["thread", "conversation"] as const)(
{ id: "beta", name: "Beta" },
],
};
- // Only management consumes this session; the independent child views above
- // are stubbed, so the fixture supplies just its list and outbox boundary.
+ // Only management consumes this session; independent child views above are
+ // stubbed. These writable tabs have static eligibility/profile evidence.
const session = {
outbox: owner.outbox,
channels: { list: () => channels, subscribeList: () => () => {} },
+ profiles: { subscribe: () => () => {} },
+ messages: { canRetry: () => true },
} as unknown as RelaySession;
render(
() => {},
},
agentChoices: createAgentLibrary(undefined).queries,
- messages: { retry() {} },
+ messages: { retry() {}, canRetry: () => true },
media: () => undefined,
unread,
outbox,
diff --git a/src/features/messages/MessageManagement.test.tsx b/src/features/messages/MessageManagement.test.tsx
index 6f5f5173c3..b22a317002 100644
--- a/src/features/messages/MessageManagement.test.tsx
+++ b/src/features/messages/MessageManagement.test.tsx
@@ -73,6 +73,8 @@ async function fixture(
shareRead?: Promise,
viewerRole: "member" | "guest" = "member",
) {
+ let role = viewerRole,
+ rosterTime = 1700000000;
vi.stubGlobal(
"ResizeObserver",
class {
@@ -115,7 +117,7 @@ async function fixture(
...(originalMention ? [["p", peer.pubkey]] : []),
],
);
- const agentProfile =
+ let agentProfile =
agent && agentOwnerKey
? eventDto(
signed(agent, {
@@ -185,9 +187,10 @@ async function fixture(
metadata(relay, "room", "Room"),
signed(relay, {
...roster(relay, "room", [viewer.pubkey, peer.pubkey]),
+ created_at: rosterTime,
tags: [
["d", "room"],
- ["p", viewer.pubkey, "", viewerRole],
+ ["p", viewer.pubkey, "", role],
["p", peer.pubkey, "", "member"],
],
}),
@@ -297,6 +300,25 @@ async function fixture(
owner,
original,
publications,
+ async setRole(next: "member" | "guest") {
+ role = next;
+ rosterTime++;
+ await owner.session.read([{ kinds: [39002], "#d": ["room"], limit: 1 }]);
+ },
+ async revokeOwner() {
+ assert.exists(agent);
+ agentProfile = eventDto(
+ signed(agent, {
+ kind: 0,
+ created_at: 1700000001,
+ tags: [],
+ content: JSON.stringify({ name: "Test agent" }),
+ }),
+ );
+ await owner.session.read([
+ { kinds: [0], authors: [agent.pubkey], limit: 1 },
+ ]);
+ },
preview: () => view.rerender( ),
present: (presented: boolean) =>
view.rerender( ),
@@ -881,6 +903,79 @@ it("recovers an uncertain deletion after the row and dialog disappear", async ()
await act(async () => h.publication(1).result.resolve());
});
+it.each(["failed", "unknown"] as const)(
+ "disables %s own-message deletion retry after demotion to Guest",
+ async (delivery) => {
+ const h = await fixture();
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Delete message" }),
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(h.publications).toHaveLength(1));
+ await act(async () =>
+ h
+ .publication(0)
+ .result.reject(
+ delivery === "failed"
+ ? new PublishRejected("Deletion refused")
+ : new Error("Connection lost"),
+ ),
+ );
+ fireEvent.click(await screen.findByText("Close", { exact: true }));
+ const retry = await screen.findByRole("button", {
+ name: "Retry message update",
+ });
+ expect(retry).toBeEnabled();
+ await act(async () => h.setRole("guest"));
+ expect(h.owner.session.channels.get?.("room")?.viewerRole).toBe("guest");
+ await waitFor(() => expect(retry).toBeDisabled());
+ fireEvent.click(retry);
+ expect(h.publications).toHaveLength(1);
+ },
+);
+
+it("retries an owned-agent deletion without its row and disables retry when ownership is revoked", async () => {
+ const h = await fixture(
+ false,
+ false,
+ false,
+ false,
+ false,
+ "viewer",
+ false,
+ undefined,
+ "guest",
+ );
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Delete message" }),
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(h.publications).toHaveLength(1));
+ await act(async () =>
+ h.publication(0).result.reject(new Error("Connection lost")),
+ );
+ fireEvent.click(await screen.findByText("Close", { exact: true }));
+ expect(h.owner.session.channels.window("room").rows).toHaveLength(0);
+ const retry = await screen.findByRole("button", {
+ name: "Retry message update",
+ });
+ expect(retry).toBeEnabled();
+ fireEvent.click(retry);
+ await waitFor(() => expect(h.publications).toHaveLength(2));
+ expect(h.publication(1).event.id).toBe(h.publication(0).event.id);
+ await act(async () =>
+ h.publication(1).result.reject(new PublishRejected("Deletion refused")),
+ );
+ const revokedRetry = await screen.findByRole("button", {
+ name: "Retry message update",
+ });
+ expect(revokedRetry).toBeEnabled();
+ await act(async () => h.revokeOwner());
+ await waitFor(() => expect(revokedRetry).toBeDisabled());
+ fireEvent.click(revokedRetry);
+ expect(h.publications).toHaveLength(2);
+});
+
it("waits for confirmed membership before entering a restored channel visit", async () => {
const h = await fixture();
cleanup();
diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx
index de6d4c19ad..62c64ae67e 100644
--- a/src/features/messages/MessageManagement.tsx
+++ b/src/features/messages/MessageManagement.tsx
@@ -549,9 +549,6 @@ export function MessageManagementStatus() {
const [error, setError] = useState();
if (!management) return null;
const { session, channelId, operations } = management;
- const channel = session.channels
- .list()
- .channels.find((item) => item.id === channelId);
const pending = operations.filter(
(item) =>
(item.event.kind === 40003 ||
@@ -582,18 +579,7 @@ export function MessageManagementStatus() {
same operation; dismissing does not undo it.
)}
- session.outbox?.retry(item.event.id)}
- >
- Retry message update
-
+
{
try {
@@ -618,3 +604,31 @@ export function MessageManagementStatus() {
>
);
}
+
+function RetryMessageUpdate({
+ session,
+ event,
+}: {
+ session: RelaySession;
+ event: OutgoingEvent["event"];
+}) {
+ const eligible = useSyncExternalStore(
+ (listener) => {
+ const offChannels = session.channels.subscribeList(listener);
+ const offProfiles = session.profiles.subscribe(listener);
+ return () => {
+ offChannels();
+ offProfiles();
+ };
+ },
+ () => session.messages.canRetry(event),
+ );
+ return (
+ session.outbox?.retry(event.id)}
+ >
+ Retry message update
+
+ );
+}
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 159cd5c892..5b527e05ec 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -2212,73 +2212,96 @@ export function createRelaySession(
},
})
: undefined,
- messages: createMessages(
- writes?.outbox,
- transport?.viewer,
- (id) =>
- local().find((item) => item.event.id === id)?.event ??
- recent.peek(id)?.event ??
- retainedEvent(id),
- emoji.tags,
- validateMentions,
- (id, owner) =>
- !closed &&
- (channels.canWrite(id) ||
- // editOwnedAgent and messages.remove supply owner evidence;
- // validateChannelWrite rechecks it before signing and publication.
- (!!owner && channels.canParticipate(id))),
- transport?.scope,
- writer && (!writer.kinds || writer.kinds.includes(1984))
- ? async (template) => {
- if (closed) throw new Error("Relay session closed");
- const signal = AbortSignal.any([
- lifetime.signal,
- AbortSignal.timeout(10_000),
- ]);
- // Race each step so the deadline holds even if the writer ignores `signal`.
- const aborted = new Promise((_, reject) => {
- const fail = () => reject(signal.reason);
- if (signal.aborted) fail();
- else signal.addEventListener("abort", fail, { once: true });
- });
- const signed = await Promise.race([
- writer.sign(template, signal),
- aborted,
- ]);
- signal.throwIfAborted();
- await Promise.race([writer.publish(signed, signal), aborted]);
- }
- : undefined,
- transport?.archiveAuthority,
- profiles.queries,
- (channelId) => {
+ messages: Object.freeze({
+ ...createMessages(
+ writes?.outbox,
+ transport?.viewer,
+ (id) =>
+ local().find((item) => item.event.id === id)?.event ??
+ recent.peek(id)?.event ??
+ retainedEvent(id),
+ emoji.tags,
+ validateMentions,
+ (id, owner) =>
+ !closed &&
+ (channels.canWrite(id) ||
+ // editOwnedAgent and messages.remove supply owner evidence;
+ // validateChannelWrite rechecks it before signing and publication.
+ (!!owner && channels.canParticipate(id))),
+ transport?.scope,
+ writer && (!writer.kinds || writer.kinds.includes(1984))
+ ? async (template) => {
+ if (closed) throw new Error("Relay session closed");
+ const signal = AbortSignal.any([
+ lifetime.signal,
+ AbortSignal.timeout(10_000),
+ ]);
+ // Race each step so the deadline holds even if the writer ignores `signal`.
+ const aborted = new Promise((_, reject) => {
+ const fail = () => reject(signal.reason);
+ if (signal.aborted) fail();
+ else signal.addEventListener("abort", fail, { once: true });
+ });
+ const signed = await Promise.race([
+ writer.sign(template, signal),
+ aborted,
+ ]);
+ signal.throwIfAborted();
+ await Promise.race([writer.publish(signed, signal), aborted]);
+ }
+ : undefined,
+ transport?.archiveAuthority,
+ profiles.queries,
+ (channelId) => {
+ const channel = channels.queries
+ .list()
+ .channels.find((item) => item.id === channelId);
+ return channel?.channelType === "dm" &&
+ channel.members?.length === 2 &&
+ transport?.viewer &&
+ channel.members.includes(transport.viewer)
+ ? channel.members.filter((key) => key !== transport.viewer)
+ : [];
+ },
+ (id) => [
+ ...new Map(
+ [
+ ...local().map((item) => item.event),
+ ...recent.entries().map(([, item]) => item.event),
+ ...retainedThreadEvents(),
+ ...retainedChannelEvents(),
+ ]
+ .filter((event) =>
+ event.tags.some(
+ ([name, value]) => name === "buzz:copy-of" && value === id,
+ ),
+ )
+ .map((event) => [event.id, event]),
+ ).values(),
+ ],
+ ),
+ /** UI eligibility only; signing/publication recheck the same role proof. */
+ canRetry(event: Pick) {
+ const id = event.tags.find(([key]) => key === "h")?.[1];
const channel = channels.queries
.list()
- .channels.find((item) => item.id === channelId);
- return channel?.channelType === "dm" &&
- channel.members?.length === 2 &&
- transport?.viewer &&
- channel.members.includes(transport.viewer)
- ? channel.members.filter((key) => key !== transport.viewer)
- : [];
+ .channels.find((item) => item.id === id);
+ if (
+ closed ||
+ !channel ||
+ channel.archived ||
+ channel.cached ||
+ !channels.canParticipate(channel.id)
+ )
+ return false;
+ try {
+ validateChannelWrite(event);
+ return true;
+ } catch {
+ return false;
+ }
},
- (id) => [
- ...new Map(
- [
- ...local().map((item) => item.event),
- ...recent.entries().map(([, item]) => item.event),
- ...retainedThreadEvents(),
- ...retainedChannelEvents(),
- ]
- .filter((event) =>
- event.tags.some(
- ([name, value]) => name === "buzz:copy-of" && value === id,
- ),
- )
- .map((event) => [event.id, event]),
- ).values(),
- ],
- ),
+ }),
/** An owned bounded thread reader. Dispose on close; the session retains access/lifetime authority. */
thread(
channelId: string,
diff --git a/tests/browser/owned-agent-message-deletion.spec.mjs b/tests/browser/owned-agent-message-deletion.spec.mjs
index 4b4c5b2d05..dd09fe9063 100644
--- a/tests/browser/owned-agent-message-deletion.spec.mjs
+++ b/tests/browser/owned-agent-message-deletion.spec.mjs
@@ -85,16 +85,56 @@ test("the verified owner confirms a NIP-09 delete for an agent message", async (
path: testInfo.outputPath("owned-agent-delete-confirmation.png"),
});
+ // The actual recovery control must retain eligibility after its dialog closes;
+ // role/ownership revocation matrices stay in the session/component tests.
+ let rejected;
+ await page.route("**/api/relay/**/publish", async (route) => {
+ const event = route.request().postDataJSON();
+ if (event.kind !== 5 || rejected) return route.continue();
+ rejected = event;
+ await route.fulfill({
+ json: {
+ accepted: false,
+ event_id: event.id,
+ message: "Fixture deletion refusal",
+ },
+ });
+ });
await confirmation
.getByRole("button", { name: "Delete", exact: true })
.click();
+ await expect(confirmation.getByRole("alert")).toContainText(
+ "Fixture deletion refusal",
+ );
+ await confirmation
+ .getByRole("button", { name: "Close", exact: true })
+ .filter({ hasText: /^Close$/ })
+ .click();
+ await expect(confirmation).toHaveCount(0);
+ const retry = page.getByRole("button", {
+ name: "Retry message update",
+ exact: true,
+ });
+ await expect(retry).toBeEnabled();
+ await retry.click();
await expect(row).toHaveCount(0);
await expect(confirmation).toHaveCount(0);
+ // Row removal is optimistic; wait for the broker publication before reading it.
+ await expect
+ .poll(() =>
+ app.report.publications.filter(
+ ({ event: item }) =>
+ item.kind === 5 &&
+ item.tags.some(([name, id]) => name === "e" && id === event.id),
+ ),
+ )
+ .toHaveLength(1);
const deletion = app.report.publications.find(
({ event: item }) =>
item.kind === 5 &&
item.tags.some(([name, id]) => name === "e" && id === event.id),
).event;
+ expect(deletion.id).toBe(rejected.id);
expect(deletion.pubkey).toBe(app.viewer);
expect(deletion.tags.filter(([name]) => name !== "client-id")).toEqual([
["h", "alpha"],