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 1713ff1f31..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: "",
@@ -3464,7 +3476,20 @@ test("details routes are narrow, require the live owner, and do not widen lifecy
expect(signed).toMatchObject(change);
await transport.channelDetails.publish(signed, signal);
}
+ for (const role of ["member", "guest"]) {
+ const change = {
+ ...template,
+ tags: [...template.tags, ["join_role", role]],
+ };
+ expect((await h.post("sign", change)).status).toBe(400);
+ expect((await h.post("channel-lifecycle-sign", change)).status).toBe(400);
+ const signed = await transport.channelDetails.sign(change, signal);
+ expect(signed).toMatchObject(change);
+ await transport.channelDetails.publish(signed, signal);
+ }
for (const tags of [
+ [...template.tags, ["join_role", "admin"]],
+ [...template.tags, ["join_role", "guest"], ["join_role", "member"]],
[...template.tags.slice(0, 3), ["ttl", "+1"]],
[...template.tags.slice(0, 3), ["ttl", "1\n"]],
[...template.tags.slice(0, 3), ["ttl", "1"], ["ttl", ""]],
@@ -3493,13 +3518,13 @@ test("details routes are narrow, require the live owner, and do not widen lifecy
expect(reopened).toMatchObject(reopening);
expect((await h.post("publish", reopened)).status).toBe(400);
await transport.channelDetails.publish(reopened, signal);
- expect(h.publications).toHaveLength(4);
+ expect(h.publications).toHaveLength(6);
const foreign = finalizeEvent(
structuredClone(template),
new Uint8Array(32).fill(5),
);
expect((await h.post("channel-details-publish", foreign)).status).toBe(400);
- expect(h.publications).toHaveLength(4);
+ expect(h.publications).toHaveLength(6);
} finally {
live?.dispose();
await h.close();
diff --git a/docs/channels.md b/docs/channels.md
index cd2689fa47..c0390ac267 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -518,9 +518,10 @@ navigation, not a new archived-channel directory.
Search also finds active public channels the viewer has not joined, by name,
labeled **Public channel · not joined** after joined matches. Opening one shows the
-read-only preview. The preview offers **Join channel**; after the relay confirms
+read-only preview. Member-default previews offer **Join channel**; after the relay confirms
membership, the channel joins the sidebar and the composer becomes available and
-focused. Joining a channel is not available for DMs, private, archived or session
+focused. Guest-default previews omit Join and preserve reading without membership.
+Joining a channel is not available for DMs, private, archived or session
conversations. See [relay queries](relay-queries.md#public-channel-name-search-and-join).
Unarchive publishes the existing narrow `9002` command with `archived=false` and
requires fresh relay metadata with a missing/false archive tag before updating
@@ -604,17 +605,15 @@ No dismissal saves, retries, replaces setup or confirms a destructive action.
## Editing channel details
-Channel Settings shows the signed name, description and explicit visibility for
-ordinary channels; missing visibility stays **Not available**, not implicitly
-Public. The centered title opens the same editor, revealing a pencil with a short
+Channel Settings shows the signed name and description for ordinary channels. The centered title opens the same editor, revealing a pencil with a short
left-to-right fade on hover or keyboard focus (always visible on touch, no animation
for reduced motion). The pencil follows the final text line;
balanced side padding keeps the text centered and lets long titles wrap without clipping it.
-Description and Visibility have small pencils immediately after their labels,
+Description has a small pencil after its label,
revealed with a short left-to-right fade on whole-row hover or keyboard focus
(always visible on touch, no animation for reduced motion) without shifting layout;
-each whole row opens the shared Dialog with one Name/Description/Duration/Private draft.
-Opening Description focuses its textarea; the title and Visibility still focus Name.
+the whole row opens the shared Dialog with one Name/Description/Duration draft.
+Opening Description focuses its textarea; the title focuses Name.
Only authorized editors get the interactive rows; other viewers retain plain metadata.
Members has a right chevron and opens the existing member list. Channel ID has a
small inline copy icon with the same hover/focus/touch behavior; selecting its row
@@ -623,8 +622,9 @@ growing the row; hovering or focusing does not open a hint or replay old feedbac
Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone
Edit details button, header-menu Edit details entry and informational Channel type
row are omitted.
-Duration uses Create's Ongoing/Temporary cards, and Private uses the same
-switch in the action row. These controls stage changes; neither publishes immediately.
+Duration uses Create's Ongoing/Temporary cards. Access controls live in the members
+dialog, separate from the text and lifespan draft. These details controls stage
+changes; none publishes immediately.
**Save changes** submits the draft together and is enabled only for valid, changed values.
**Cancel** explicitly discards edits and closes without confirmation. Close, Escape
and backdrop clicks close untouched forms immediately; changed drafts first show
@@ -672,8 +672,8 @@ keyboard navigation switch immediately; hidden controls are inert and excluded
from accessibility, with one complete accessible description per state.
Steps are **1 day ↔ 7 days ↔ 2 weeks**, starting at seven days, with
unavailable directions disabled. Switching to Ongoing and back retains the chosen
-time within the draft. Private reuses the shared switch, left-aligned in each
-footer with matching enabled/disabled label treatment. Both forms use a compact
+time within the draft. Create keeps its Private switch in the footer; Edit details
+leaves access management to the members dialog. Both forms use a compact
12px header-to-body gap and 16px field spacing. Write ownership stays with each
workflow.
@@ -686,7 +686,7 @@ Description on submission; Edit preserves its exact text and can explicitly clea
Both dialogs opt into backdrop dismissal and guard Close, Escape and backdrop
dismissal with an in-dialog discard confirmation when the draft differs. Comparison covers raw
-text, staged privacy and effective duration. Reverting
+text, effective duration and, for Create, staged privacy. Reverting
all fields removes the warning. Pending operations still block dismissal; once
settled, closing abandons any uncertain Create. Returning from a discard confirmation focuses Name and retains the draft.
@@ -725,29 +725,61 @@ and membership (`39002`) for the exact channel, plus current session participati
Only direct channel owners/admins may edit ordinary stream/forum channels. Cached,
read-only, archived, DM and Solo chat views do not offer this editor. A local
key, delegated agent role or community-admin status does not imply channel authority.
-Public ↔ private is supported as a draft choice, never an immediate mutation.
-In both Create and Edit, a Private toggle normally replaces the form with a confirmation
-step in the same modal. The whole surface crossfades using `motion/react`: the form scales from
-1 to 1.05 and blurs while confirmation sharpens from 0.95 to 1. Returning reverses
-the transition; reduced motion and keyboard navigation swap immediately. Outgoing
-controls are inert, with one backdrop and focus trap throughout. This happens
-before changing the switch, including a reversal back to the saved
-value. There is one backdrop and focus trap, not a second overlaid modal. Public → private says
-“Only channel members will have access.” Private → public explicitly warns that everyone in
-this community can view the channel's full history. Continue
-only stages the choice; Create channel or Save changes still performs the write. Cancel/Escape or
-the confirmation's Close control leaves the switch unchanged and returns to the
-intact form with focus on the switch. All other draft fields—including Create's
-selected destination—survive either path. The consequence
-is shown once in the confirmation body, connected as the dialog's accessible description.
-**Don’t show me this again** is saved only on **Continue** and skips both public and
-private warnings. It is a device-local preference shared by Create and Edit for the
-same community/viewer scope, using the existing view-state storage. Existing opt-outs
-for either direction also skip both warnings. Cancel, Escape,
-Close and backdrop dismissal do not remember it. If storage is unavailable or invalid,
-the warning remains enabled. Skipping the warning still only stages the choice:
-Create/Save, authorization and discard protection are unchanged. A busy creation
-cannot open confirmation.
+Create's Private switch stages a choice behind the existing in-modal consequence
+confirmation. The device-local opt-out applies to that Create warning; Continue
+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 **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.
+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, 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. 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
+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. 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**,
+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
+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.
+
In Edit, visibility is sent only when changed (`private` or `open`);
text-only edits omit it so they do not reopen the channel in the remaining write race.
The relay remains the final authority.
@@ -776,8 +808,8 @@ survives failed reloads, so an untouched duration follows the next successful re
while an explicit change to Ongoing remains staged. This baseline never grants
editing authority: a failed read still clears the current base and disables Save.
If a failed reload has lost the prior base, privacy adopts the fresh value
-conservatively: a stale public text draft must not become reopening intent. Choose
-visibility again after that recovery. Inspect the retained edits before
+conservatively: a stale public text draft must not become reopening intent.
+Inspect the retained edits before
saving again. A lost publication response or failed/mismatched readback locks the submitted draft and
offers **Check save status**, which only reads and never republishes. Uncertain
intent survives panel close/reopen and cache clear within the same session; no
@@ -1011,18 +1043,42 @@ another non-owner member:
| Target role | Change role | Remove from this channel |
| --- | --- | --- |
-| Admin / Member / Guest | Admin / Member, excluding the current role | Yes |
-| Bot | Admin / Member, after explicit confirmation | Yes |
+| Admin / Member / Guest | Admin / Member / Guest, excluding the current role | 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.
-The menu deliberately omits **Make guest** while Guest's permission contract is
-unsettled: the inspected relay message path does not enforce the role's documented
-read-only meaning, while Git push policy does distinguish Guest from Member.
-Existing Guest roles remain available to the permission/confirmation flow and can be deliberately changed to Member or
-Admin, or removed; they are never automatically converted. This is a menu-only
-restriction, not a change to relay semantics or the broker's supported commands.
+The menu includes **Make guest** through the existing deliberate confirmation
+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
+contribution access separate. The note explicitly preserves existing participants'
+access; a Guest default leaves designated writers able to contribute.
+Owners/admins save choices through the existing kind:9002 command. Missing `join_role` means Member, matching legacy relay
+behavior. An unrecognized or malformed explicit default maps to Guest so previews
+fail closed. These defaults never replace a participant's stored role.
+Unchanged or omitted defaults are not resent, and success requires matching fresh
+signed metadata; an older relay that ignores the choice cannot report success.
+
+A Guest's signed roster role makes the channel read-only independently of that
+default. Channel and thread history remain available, with a read-only notice
+instead of a composer or Join button. Nonmembers of a public Guest-default channel
+get the same reading view without Join; opening message or thread links does not
+create membership. Explicit promotion to Member restores the
+composer; changing only `join_role` does not. Guests retain Leave, and the relay
+remains the authority for every write.
+
+The session keeps membership-based reading/lifecycle access separate from writing
+permission. Direct guest writes and uploads are refused, and queued writes are
+checked again before signing and publication, including signed retries. The
+relay's verified agent-owner edit/delete exception is preserved; it does not
+permit ordinary posts or reactions.
+Guests can search the member directory without invitation controls. Public
+previews whose admission default is Guest also omit invitations; existing
+writing members retain them independently of the default.
Current members may also remove their own verified non-owner agents, but cannot
edit their roles or remove someone else's agent. The service verifies signed
diff --git a/docs/relay-queries.md b/docs/relay-queries.md
index 663c9b00f4..edb01cec08 100644
--- a/docs/relay-queries.md
+++ b/docs/relay-queries.md
@@ -361,7 +361,14 @@ channel through `refer`, as any channel-only link does. Raw
list, their label follows the exact reference, so a rename or lost access
shows at once rather than the directory's older name.
-A public preview offers **Join channel**. It sends the NIP-29 `9021` join request
+A Member-default public preview offers **Join channel**. Guest-default previews
+keep history and thread links readable without Join or writing controls, and
+opening a link creates no membership. After Leave, fresh signed public metadata
+can restore preview reading while retained membership stays denied; stale or
+private metadata cannot reopen reading. Missing defaults mean Member; malformed
+explicit defaults mean Guest.
+
+Join sends the NIP-29 `9021` join request
through the purpose-bound channel-lifecycle sign/publish route; generic signing
rejects `9021`. Before signing, the lifecycle owner re-reads signed metadata and
the viewer roster: a current roster entry needs no request, and only active public
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index 2e84516b3a..d4e2a1f9de 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -19,7 +19,7 @@ fn common(event: &EventTemplate) -> bool {
event.content.is_empty() && event.created_at <= i64::MAX as u64
}
fn details(event: &EventTemplate) -> bool {
- if event.kind != 9002 || !common(event) || !(3..=13).contains(&event.tags.len()) {
+ if event.kind != 9002 || !common(event) || !(3..=14).contains(&event.tags.len()) {
return false;
}
let (Some(id), Some(name), Some(about)) = (
@@ -47,6 +47,12 @@ fn details(event: &EventTemplate) -> bool {
index += 1;
}
// NIP-CL: trailing `t` tags replace the label set; one `["t", ""]` clears it.
+ if let Some(role) = tag(event, index, "join_role") {
+ if !matches!(role, "member" | "guest") {
+ return false;
+ }
+ index += 1;
+ }
let labels = &event.tags[index..];
let labels_valid = details_labels(labels);
uuid(id)
@@ -568,6 +574,41 @@ mod boundary_tests {
}
}
+ #[test]
+ fn details_admit_only_member_and_guest_admission_defaults() {
+ let host = IdentityHost::fixture();
+ for role in ["member", "guest"] {
+ let event =
+ serde_json::to_value(details_event(vec![vec!["join_role".into(), role.into()]]))
+ .unwrap();
+ let parsed = validate("channel-details", &event, None).unwrap();
+ assert!(validate("channel-lifecycle", &event, None).is_err());
+ let signed = tauri::async_runtime::block_on(host.sign(parsed)).unwrap();
+ assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_ok());
+ }
+ for extra in [
+ vec![vec!["join_role", "owner"]],
+ vec![vec!["join_role", "admin"]],
+ vec![vec!["join_role", "bot"]],
+ vec![vec!["join_role", ""]],
+ vec![vec!["join_role"]],
+ vec![vec!["join_role", "guest", "extra"]],
+ vec![vec!["join_role", "member"], vec!["join_role", "guest"]],
+ vec![vec!["join_role", "guest"], vec!["ttl", "60"]],
+ ] {
+ let template = details_event(
+ extra
+ .iter()
+ .map(|tag| tag.iter().map(|s| (*s).into()).collect())
+ .collect(),
+ );
+ let event = serde_json::to_value(&template).unwrap();
+ assert!(validate("channel-details", &event, None).is_err());
+ let signed = tauri::async_runtime::block_on(host.sign(template)).unwrap();
+ assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_err());
+ }
+ }
+
#[test]
fn details_reject_huddle_markers_at_sign_and_publish_boundaries() {
let mut event = details_event(vec![]);
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.
}
- expect(screen.getByRole("switch", { name: "Private" })).toBeChecked(),
- );
- expect(screen.getByRole("switch", { name: "Private" })).not.toHaveAttribute(
- "aria-disabled",
- "true",
+ expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(),
);
expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue(
"My renamed channel",
@@ -255,10 +245,6 @@ it("unknown outcomes survive remount and checking never invokes Save", async ()
);
await user.click(screen.getByRole("radio", { name: "Temporary" }));
expect(screen.getByRole("radio", { name: "Ongoing" })).toBeChecked();
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-disabled",
- "true",
- );
expect(
screen.queryByRole("button", { name: "Save changes" }),
).not.toBeInTheDocument();
@@ -354,7 +340,7 @@ it("keeps details readable without editing authority or host support", async ()
,
);
expect(screen.getByText(channel.description)).toBeVisible();
- expect(screen.getByText("Public")).toBeVisible();
+ expect(screen.queryByText("Who can read")).not.toBeInTheDocument();
const editor = screen.getByRole("complementary", {
name: "Channel settings",
hidden: true,
@@ -432,8 +418,7 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a
await user.tab();
expect(edit).toHaveFocus();
await user.tab();
- expect(screen.getByRole("button", { name: "Edit visibility" })).toHaveFocus();
- await user.tab();
+
expect(screen.getByRole("button", { name: "Copy channel id" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus();
@@ -613,7 +598,7 @@ it("a save that becomes uncertain reopens through a settings row in check-only r
await user.click(
screen.getByRole("button", { name: "Close edit channel details" }),
);
- await user.click(screen.getByRole("button", { name: "Edit visibility" }));
+ await user.click(screen.getByRole("button", { name: "Edit description" }));
expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue(draft.name);
expect(
screen.queryByRole("button", { name: "Save changes" }),
@@ -623,96 +608,6 @@ it("a save that becomes uncertain reopens through a settings row in check-only r
).toBeEnabled();
expect(h.save).toHaveBeenCalledOnce();
});
-it("warns before reopening a private channel, discards on Cancel, and changes visibility only on Save", async () => {
- const h = harness();
- const user = userEvent.setup();
- const privateBase = { ...base, visibility: "private" as const };
- h.load.mockResolvedValue(privateBase);
- render(
- ,
- );
- await user.click(await screen.findByRole("button", { name: "Edit details" }));
- const privacy = screen.getByRole("switch", { name: "Private" });
- expect(privacy).toBeChecked();
- await user.click(privacy);
- expect(
- screen.queryByRole("switch", { name: "Private" }),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
- "Everyone in this community will be able to view this channel’s full history.",
- );
- await user.click(screen.getByRole("button", { name: "Continue" }));
- expect(screen.getByRole("switch", { name: "Private" })).not.toBeChecked();
- expect(h.save).not.toHaveBeenCalled();
- await user.click(screen.getByRole("button", { name: "Cancel" }));
- expect(screen.getByRole("button", { name: "Edit details" })).toHaveFocus();
- await user.click(screen.getByRole("button", { name: "Edit details" }));
- expect(screen.getByRole("switch", { name: "Private" })).toBeChecked();
- expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
- await user.click(screen.getByRole("switch", { name: "Private" }));
- await user.click(screen.getByRole("button", { name: "Continue" }));
- await user.click(screen.getByRole("button", { name: "Save changes" }));
- expect(h.save).toHaveBeenCalledWith(
- privateBase,
- { ...privateBase, visibility: "public" },
- expect.any(AbortSignal),
- );
-});
-
-it.each([false, true])(
- "reload keeps explicit reopening but adopts untouched visibility (edited=%s)",
- async (edited) => {
- const h = harness();
- const user = userEvent.setup();
- const privateBase = { ...base, visibility: "private" as const };
- h.load.mockResolvedValue(privateBase);
- h.save.mockRejectedValueOnce(new Error("Channel details changed"));
- render(
- ,
- );
- await user.click(
- await screen.findByRole("button", { name: "Edit details" }),
- );
- await user.type(screen.getByRole("textbox", { name: "Name" }), " edited");
- if (edited) {
- await user.click(screen.getByRole("switch", { name: "Private" }));
- await user.click(screen.getByRole("button", { name: "Continue" }));
- }
- await user.click(screen.getByRole("button", { name: "Save changes" }));
- await screen.findByRole("alert");
- const reloaded = { ...privateBase, version: "v2" };
- h.load.mockResolvedValue(reloaded);
- await user.click(screen.getByRole("button", { name: "Reload details" }));
- await waitFor(() =>
- expect(
- screen.getByRole("button", { name: "Save changes" }),
- ).toBeEnabled(),
- );
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(!edited),
- );
- await user.click(screen.getByRole("button", { name: "Save changes" }));
- expect(h.save).toHaveBeenLastCalledWith(
- reloaded,
- {
- ...privateBase,
- name: "Alpha edited",
- visibility: edited ? "public" : "private",
- },
- expect.any(AbortSignal),
- );
- },
-);
-
it("failed reload never turns a stale public text draft into intent to reopen", async () => {
const h = harness();
const user = userEvent.setup();
@@ -737,7 +632,7 @@ it("failed reload never turns a stale public text draft into intent to reopen",
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(),
);
- expect(screen.getByRole("switch", { name: "Private" })).toBeChecked();
+
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(h.save).toHaveBeenLastCalledWith(
reloaded,
@@ -909,7 +804,7 @@ it.each([
);
});
-it("stages lifetime and privacy together until Save", async () => {
+it("stages lifetime until Save, preserving access", async () => {
const h = harness();
const user = userEvent.setup();
render(
@@ -927,13 +822,11 @@ it("stages lifetime and privacy together until Save", async () => {
await user.click(screen.getByRole("radio", { name: "Ongoing" }));
expect(save).toBeDisabled();
await user.click(screen.getByRole("radio", { name: "Temporary" }));
- await user.click(screen.getByRole("switch", { name: "Private" }));
- await user.click(screen.getByRole("button", { name: "Continue" }));
expect(h.save).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(h.save).toHaveBeenCalledWith(
base,
- { ...base, ttlSeconds: 604800, visibility: "private" },
+ { ...base, ttlSeconds: 604800 },
expect.any(AbortSignal),
);
});
@@ -1104,147 +997,6 @@ it("retains an explicit change to Ongoing through a failed conflict reload", asy
);
});
-it.each(["public", "private"] as const)(
- "replaces Edit for every Private toggle from %s without losing the draft",
- async (visibility) => {
- const h = harness();
- const user = userEvent.setup();
- h.load.mockResolvedValue({ ...base, visibility });
- render(
- ,
- );
- await user.click(
- await screen.findByRole("button", { name: "Edit details" }),
- );
- await user.type(screen.getByRole("textbox", { name: "Name" }), " draft");
- await user.type(
- screen.getByRole("textbox", { name: "Description" }),
- " draft",
- );
- await user.click(screen.getByRole("radio", { name: "Temporary" }));
- const initialPrivate = visibility === "private";
- const dialog = screen.getByRole("dialog");
- for (const dismissal of ["Cancel", "Escape", "Close"]) {
- await user.click(screen.getByRole("switch", { name: "Private" }));
- const confirmation = screen.getByRole("dialog");
- expect(confirmation).toBe(dialog);
- expect(screen.getAllByRole("dialog")).toHaveLength(1);
- expect(confirmation).toHaveAccessibleName(
- initialPrivate ? "Make channel public?" : "Make channel private?",
- );
- expect(
- screen.queryByRole("textbox", { name: "Name" }),
- ).not.toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: "Save changes" }),
- ).not.toBeInTheDocument();
- expect(
- within(confirmation).getByRole("button", { name: "Cancel" }),
- ).toHaveFocus();
- const consequence = initialPrivate
- ? "Everyone in this community will be able to view this channel’s full history."
- : "Only channel members will have access.";
- expect(confirmation).toHaveAccessibleDescription(consequence);
- await waitFor(() => expect(screen.getByText(consequence)).toBeVisible());
- expect(
- screen.getByText(consequence).closest(".buzz-dialog-body"),
- ).toHaveAttribute("data-footer-gap", "compact");
- expect(
- screen.queryByText(/This change takes effect/),
- ).not.toBeInTheDocument();
- const remember = screen.getByRole("checkbox", {
- name: "Don’t show me this again",
- });
- expect(remember).not.toBeChecked();
- await user.click(remember);
- if (dismissal === "Escape") await user.keyboard("{Escape}");
- else
- await user.click(
- within(confirmation).getByRole("button", {
- name:
- dismissal === "Close" ? "Back to edit channel details" : "Cancel",
- }),
- );
- expect(
- screen.queryByRole("dialog", { name: /Make channel/ }),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("dialog", { name: "Edit channel details" })).toBe(
- dialog,
- );
- expect(screen.getByRole("switch", { name: "Private" })).toHaveFocus();
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(initialPrivate),
- );
- expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue(
- "Alpha draft",
- );
- expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue(
- `${base.description} draft`,
- );
- expect(screen.getByRole("radio", { name: "Temporary" })).toBeChecked();
- }
- await user.click(screen.getByRole("switch", { name: "Private" }));
- await user.click(screen.getByRole("button", { name: "Continue" }));
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(!initialPrivate),
- );
- expect(screen.getByRole("switch", { name: "Private" })).toHaveFocus();
- expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled();
- // Toggling back also confirms, even though it returns to the saved state.
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- initialPrivate ? "Make channel private?" : "Make channel public?",
- );
- await user.click(screen.getByRole("button", { name: "Continue" }));
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(initialPrivate),
- );
- expect(h.save).not.toHaveBeenCalled();
- // Escape in the form asks before discarding the entire draft.
- await user.keyboard("{Escape}");
- await user.click(screen.getByRole("button", { name: "Discard changes" }));
- await user.click(screen.getByRole("button", { name: "Edit details" }));
- expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Alpha");
- expect(screen.getByRole("radio", { name: "Ongoing" })).toBeChecked();
- expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
- },
-);
-
-it("drops an open privacy confirmation when the destination changes", async () => {
- const h = harness();
- const user = userEvent.setup();
- const view = render(
- ,
- );
- await user.click(await screen.findByRole("button", { name: "Edit details" }));
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toBeVisible();
- h.load.mockResolvedValue({ ...base, channelId: "other" });
- view.rerender(
- ,
- );
- await screen.findByRole("button", { name: "Edit details" });
- expect(
- screen.queryByRole("dialog", { name: /Make channel/ }),
- ).not.toBeInTheDocument();
- expect(h.save).not.toHaveBeenCalled();
-});
-
it("renders status rows only when they have content, not an empty grid row beside Edit", async () => {
const h = harness();
const loading = deferred();
@@ -1561,7 +1313,7 @@ it("protects retained text after a failed authority reload", async () => {
);
});
-it.each(["description", "visibility", "channel name"])(
+it.each(["description", "channel name"])(
"opens the full editor from the %s action and returns focus without saving",
async (field) => {
const h = harness();
@@ -1646,3 +1398,36 @@ it.each([
).not.toBeInTheDocument();
expect(h.load).not.toHaveBeenCalled();
});
+
+it("keeps invited-only Read access intact when editing details", async () => {
+ const h = harness();
+ const current = {
+ ...base,
+ visibility: "private" as const,
+ joinRole: "guest" as const,
+ };
+ h.load.mockResolvedValue(current);
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(await screen.findByRole("button", { name: "Edit details" }));
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("switch", { name: "Private" }),
+ ).not.toBeInTheDocument();
+ await user.type(
+ screen.getByRole("textbox", { name: "Description" }),
+ " updated",
+ );
+ await user.click(screen.getByRole("button", { name: "Save changes" }));
+ expect(h.save).toHaveBeenCalledWith(
+ current,
+ { ...current, description: `${current.description} updated` },
+ expect.any(AbortSignal),
+ );
+});
diff --git a/src/bundled/channels/ChannelDetailsEditor.tsx b/src/bundled/channels/ChannelDetailsEditor.tsx
index a565638a6a..638cc940eb 100644
--- a/src/bundled/channels/ChannelDetailsEditor.tsx
+++ b/src/bundled/channels/ChannelDetailsEditor.tsx
@@ -18,14 +18,8 @@ import {
} from "../../features/relay/channel-details-protocol";
import { Button } from "../../shared/design-system/ui/Button";
import { Dialog } from "../../shared/design-system/ui/Dialog";
-import {
- ChannelPrivacyConfirmation,
- skipPrivacyConfirmation,
- rememberPrivacyConfirmation,
-} from "./ChannelPrivacyConfirmation";
import { ChannelDurationField } from "./ChannelDurationField";
import { ChannelTextField } from "./ChannelTextField";
-import { Switch } from "../../shared/design-system/ui/Switch";
import { DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS } from "../../features/relay/channel-writes";
import styles from "./Channels.module.css";
@@ -58,7 +52,6 @@ export function ChannelDetailsEditor({
capability,
id,
scope,
- skipWarning: false,
loading: true,
editing: false,
focusField: "name" as "name" | "description",
@@ -67,7 +60,6 @@ export function ChannelDetailsEditor({
temporaryTtl: undefined as number | undefined,
// Comparison only: retained through failed reads, never used as authority.
baselineTtl: undefined as number | undefined,
- privacyChoice: undefined as ChannelDetailsDraft["visibility"] | undefined,
error: "",
base: undefined as ChannelDetails | undefined,
draft: undefined as ChannelDetailsDraft | undefined,
@@ -82,7 +74,6 @@ export function ChannelDetailsEditor({
const busy = useRef(false);
const nameInput = useRef(null);
const descriptionInput = useRef(null);
- const privacyCancel = useRef(null);
const keepEditing = useRef(null);
const confirmDiscard = view.confirmDiscard && !attempt;
const previousDiscard = useRef(confirmDiscard);
@@ -91,21 +82,9 @@ export function ChannelDetailsEditor({
(confirmDiscard ? keepEditing.current : nameInput.current)?.focus();
previousDiscard.current = confirmDiscard;
}, [view.editing, confirmDiscard]);
- const previousPrivacyChoice = useRef(view.privacyChoice);
- const privateSwitch = useRef(null);
const editButton = useRef(null);
const formId = useId();
- const privateControlId = useId();
const statusId = useId();
- useLayoutEffect(() => {
- if (view.editing && view.privacyChoice !== previousPrivacyChoice.current) {
- (view.privacyChoice
- ? privacyCancel.current
- : privateSwitch.current
- )?.focus();
- }
- previousPrivacyChoice.current = view.privacyChoice;
- }, [view.editing, view.privacyChoice]);
const patch = useCallback(
(
next:
@@ -121,22 +100,20 @@ export function ChannelDetailsEditor({
);
const load = useCallback(
async (signal: AbortSignal) => {
- patch({ loading: true, error: "", privacyChoice: undefined });
+ patch({ loading: true, error: "" });
try {
const base = await capability.load(id, signal);
if (!signal.aborted)
patch((old) => ({
base,
baselineTtl: base.ttlSeconds,
- // Preserve explicit choices, but adopt remote visibility/lifetime
- // for untouched fields. Unknown prior privacy must never reopen.
+ // Access is managed in Members; text/lifespan edits adopt its
+ // current values without silently changing anybody's access.
draft: old.draft
? {
...old.draft,
- visibility:
- old.base && old.draft.visibility !== old.base.visibility
- ? old.draft.visibility
- : base.visibility,
+ visibility: base.visibility,
+ joinRole: base.joinRole,
ttlSeconds:
old.draft.ttlSeconds === old.baselineTtl
? base.ttlSeconds
@@ -181,6 +158,7 @@ export function ChannelDetailsEditor({
(normalized.name !== view.base.name ||
normalized.description !== view.base.description ||
normalized.visibility !== view.base.visibility ||
+ normalized.joinRole !== view.base.joinRole ||
normalized.ttlSeconds !== view.base.ttlSeconds);
const temporaryTtl =
draft?.ttlSeconds ??
@@ -189,7 +167,6 @@ export function ChannelDetailsEditor({
DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS;
const canSave =
view.editing &&
- !view.privacyChoice &&
!confirmDiscard &&
!locked &&
canEdit &&
@@ -211,7 +188,6 @@ export function ChannelDetailsEditor({
patch({
editing: false,
error: "",
- privacyChoice: undefined,
confirmDiscard: false,
});
};
@@ -224,11 +200,11 @@ export function ChannelDetailsEditor({
draft.name !== view.base.name ||
draft.description !== view.base.description ||
draft.visibility !== view.base.visibility ||
+ draft.joinRole !== view.base.joinRole ||
draft.ttlSeconds !== view.base.ttlSeconds);
const requestClose = () => {
if (pending || busy.current) return;
- if (view.privacyChoice) patch({ privacyChoice: undefined });
- else if (confirmDiscard) patch({ confirmDiscard: false });
+ if (confirmDiscard) patch({ confirmDiscard: false });
else if (unsaved) patch({ confirmDiscard: true });
else close();
};
@@ -340,34 +316,24 @@ export function ChannelDetailsEditor({
{
if (!open) requestClose();
}}
dismissOnOutsideClick
- title={
- confirmDiscard
- ? "Discard changes?"
- : view.privacyChoice
- ? view.privacyChoice === "private"
- ? "Make channel private?"
- : "Make channel public?"
- : "Edit channel details"
- }
+ title={confirmDiscard ? "Discard changes?" : "Edit channel details"}
description={
confirmDiscard
? "Your channel details have unsaved changes."
: undefined
}
closeLabel={
- confirmDiscard || view.privacyChoice
+ confirmDiscard
? "Back to edit channel details"
: "Close edit channel details"
}
@@ -393,57 +359,8 @@ export function ChannelDetailsEditor({
Discard changes
>
- ) : view.privacyChoice ? (
- <>
- patch({ privacyChoice: undefined })}
- >
- Cancel
-
- {
- if (draft && view.privacyChoice && !locked && canEdit) {
- if (view.skipWarning) rememberPrivacyConfirmation(scope);
- patch({
- draft: { ...draft, visibility: view.privacyChoice },
- privacyChoice: undefined,
- });
- }
- }}
- >
- Continue
-
- >
) : (
<>
- {draft && (
-
- {
- if (locked || !canEdit) return;
- const visibility = checked ? "private" : "public";
- if (skipPrivacyConfirmation(scope))
- patch({ draft: { ...draft, visibility } });
- else
- patch({
- privacyChoice: visibility,
- skipWarning: false,
- });
- }}
- />
-
- Private
-
-
- )}
{attempt ? "Close" : "Cancel"}
@@ -471,13 +388,7 @@ export function ChannelDetailsEditor({
)
}
>
- {confirmDiscard ? null : view.privacyChoice ? (
- patch({ skipWarning })}
- />
- ) : (
+ {confirmDiscard ? null : (
)}
diff --git a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
index 62bab86ad8..903cdb7989 100644
--- a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
+++ b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx
@@ -6,7 +6,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { createRelaySession } from "../../features/relay/session";
import type { ChannelDetailsCapability } from "../../features/relay/channel-details";
import { readView, writeView } from "../../shared/view-state";
-import { ChannelDetailsEditor } from "./ChannelDetailsEditor";
+import { ChannelGeneralAccess } from "./ChannelGeneralAccess";
import { CreateChannelDialog } from "./CreateChannelDialog";
import {
skipPrivacyConfirmation,
@@ -21,7 +21,7 @@ afterEach(() => {
localStorage.clear();
});
-function editor(visibility: "public" | "private", currentScope = scope) {
+function access(visibility: "public" | "private") {
const channel = { id: "alpha", name: "Alpha", description: "", visibility };
const base = {
...channel,
@@ -41,17 +41,13 @@ function editor(visibility: "public" | "private", currentScope = scope) {
return {
save,
element: (
-
+
),
};
}
it.each(["private", "public"] as const)(
- "opting out from %s skips both directions across Create, reopening, and Edit; writes remain explicit",
+ "opting out from %s skips Create warnings after reopening; General access still confirms immediate writes",
async (choice) => {
const user = userEvent.setup();
const onCreate = vi.fn(async () => {});
@@ -112,83 +108,72 @@ it.each(["private", "public"] as const)(
});
reopened.unmount();
- const edit = editor(opposite);
+ const edit = access(opposite);
render(edit.element);
+ const control = await screen.findByRole("combobox", {
+ name: "Who can read",
+ });
+ await vi.waitFor(() => expect(control).toBeEnabled());
+ await user.click(control);
await user.click(
- await screen.findByRole("button", { name: "Edit details" }),
- );
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- "Edit channel details",
- );
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(choice === "private"),
- );
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- "Edit channel details",
- );
- expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute(
- "aria-checked",
- String(opposite === "private"),
+ await screen.findByRole("option", {
+ name: choice === "private" ? "Private" : "Public",
+ }),
);
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(edit.save).not.toHaveBeenCalled();
- // Opting out of privacy warnings does not opt out of unsaved-draft protection.
- await user.keyboard("{Escape}");
- expect(screen.getByRole("dialog")).toHaveAccessibleName("Discard changes?");
+ expect(
+ screen.getByRole("alertdialog", { name: "Change general access?" }),
+ ).toBeVisible();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Keep editing" }));
- await user.click(screen.getByRole("button", { name: "Save changes" }));
- expect(edit.save).toHaveBeenCalledExactlyOnceWith(
- expect.anything(),
- expect.objectContaining({ visibility: choice }),
- expect.any(AbortSignal),
- );
+ expect(edit.save).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ await vi.waitFor(() => expect(control).toHaveFocus());
+ expect(edit.save).not.toHaveBeenCalled();
},
);
it.each(["private", "public"] as const)(
- "remembers an Edit %s confirmation only after Continue and isolates community/viewer scope",
+ "remembers a Create %s warning only after Continue and isolates community/viewer scope",
async (choice) => {
const user = userEvent.setup();
- const opposite = choice === "private" ? "public" : "private";
- const edit = editor(opposite);
- const view = render(edit.element);
- await user.click(
- await screen.findByRole("button", { name: "Edit details" }),
+ const create = (currentScope: string) => (
+ {}}
+ onCreate={async () => {}}
+ session={{ ...store.session, scope: currentScope }}
+ destinations={[]}
+ initialGroup=""
+ groupsReady
+ />
);
+ const view = render(create(scope));
+ if (choice === "public") {
+ await user.click(screen.getByRole("switch", { name: "Private" }));
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ }
await user.click(screen.getByRole("switch", { name: "Private" }));
await user.click(
screen.getByRole("checkbox", { name: "Don’t show me this again" }),
);
expect(skipPrivacyConfirmation(scope)).toBe(false);
- await user.click(screen.getByRole("button", { name: "Continue" }));
- await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- "Edit channel details",
- );
await user.click(screen.getByRole("button", { name: "Cancel" }));
- await user.click(screen.getByRole("button", { name: "Edit details" }));
+ expect(skipPrivacyConfirmation(scope)).toBe(false);
await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- "Edit channel details",
+ await user.click(
+ screen.getByRole("checkbox", { name: "Don’t show me this again" }),
);
- expect(edit.save).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ expect(skipPrivacyConfirmation(scope)).toBe(true);
view.unmount();
for (const otherScope of [
"other-community:viewer",
"community:other-viewer",
]) {
- const other = render(editor(opposite, otherScope).element);
- await user.click(
- await screen.findByRole("button", { name: "Edit details" }),
- );
+ const other = render(create(otherScope));
await user.click(screen.getByRole("switch", { name: "Private" }));
- expect(screen.getByRole("dialog")).toHaveAccessibleName(
- `Make channel ${choice}?`,
- );
+ expect(
+ screen.getByRole("dialog", { name: "Make channel private?" }),
+ ).toBeVisible();
other.unmount();
}
},
diff --git a/src/bundled/channels/ChannelPrivacyConfirmation.tsx b/src/bundled/channels/ChannelPrivacyConfirmation.tsx
index 3d4990e2a7..468fbf1562 100644
--- a/src/bundled/channels/ChannelPrivacyConfirmation.tsx
+++ b/src/bundled/channels/ChannelPrivacyConfirmation.tsx
@@ -5,7 +5,7 @@ import { readView, writeView } from "../../shared/view-state";
type Visibility = "private" | "public";
const preferenceKey = "channel-privacy-confirmation:dismissed";
-// View intent only: shared by Create/Edit and both directions, never authorization.
+// View intent only: Create warnings in both directions, never authorization.
export function skipPrivacyConfirmation(scope: string) {
// Honor opt-outs saved before the two directions shared one preference.
return [
@@ -19,6 +19,12 @@ export function rememberPrivacyConfirmation(scope: string) {
writeView(scope, preferenceKey, true);
}
+export function privacyConsequence(visibility: Visibility) {
+ return visibility === "private"
+ ? "Only channel members will have access."
+ : "Everyone in this community will be able to view this channel’s full history.";
+}
+
export function ChannelPrivacyConfirmation({
visibility,
checked,
@@ -33,9 +39,7 @@ export function ChannelPrivacyConfirmation({
return (
- {visibility === "private"
- ? "Only channel members will have access."
- : "Everyone in this community will be able to view this channel’s full history."}
+ {privacyConsequence(visibility)}
{!hidePreference && (
{channel.channelType !== "solo" &&
channel.channelType !== "dm" && (
- <>
- edit.open(trigger, "description")
- : undefined
- }
- />
-
- >
+ edit.open(trigger, "description")
+ : undefined
+ }
+ />
)}
{channel.members && (
{
if (!joiningChannel || !current) return;
if (current.id !== joiningChannel) setJoiningChannel(undefined);
- else if (!current.readOnly) {
+ else if (!current.readOnly || current.viewerRole === "guest") {
setJoiningChannel(undefined);
- setComposerFocus((value) => value + 1);
+ if (current.viewerRole === "guest")
+ historyContainer.current?.focus({ preventScroll: true });
+ else setComposerFocus((value) => value + 1);
}
}, [joiningChannel, current]);
const CurrentChannelIcon = channelIcon(current);
@@ -1595,7 +1597,7 @@ function ChannelWorkspace({
setupTools={
current &&
handoff &&
- !current.readOnly &&
+ (!current.readOnly || current.viewerRole === "guest") &&
current.channelType !== "dm" && (
)}
- {current?.readOnly && !current.cached && (
- setJoiningChannel(current.id)}
- onHeightChange={setJoinNoticeHeight}
- />
- )}
+ {current?.readOnly &&
+ (!current.cached || current.viewerRole === "guest") && (
+ setJoiningChannel(current.id)}
+ onHeightChange={setJoinNoticeHeight}
+ />
+ )}
{!cached && (
)}
- {current && (!current.readOnly || current.cached) && (
-
- ) : undefined
- }
- onSend={onComposerSend}
- focusRequest={composerFocus}
- />
- )}
+ {current &&
+ current.viewerRole !== "guest" &&
+ (!current.readOnly || current.cached) && (
+
+ ) : undefined
+ }
+ onSend={onComposerSend}
+ focusRequest={composerFocus}
+ />
+ )}
>
);
const workspace = (
@@ -1778,6 +1785,7 @@ function ChannelWorkspace({
>
{current && membersOpen && (
channels, subscribeList: () => () => {} },
+ profiles: { subscribe: () => () => {} },
+ messages: { canRetry: () => true },
} as unknown as RelaySession;
render(
[
@@ -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(
@@ -238,9 +242,21 @@ async function setup(
},
choose: async (name: string) => {
await user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
+ );
+ const roleChoice =
+ name === "Make admin"
+ ? "Admin"
+ : name === "Make member"
+ ? "Participating"
+ : name === "Make guest"
+ ? "Viewing only"
+ : undefined;
+ await user.click(
+ roleChoice
+ ? await screen.findByRole("menuitemradio", { name: roleChoice })
+ : await screen.findByRole("menuitem", { name }),
);
- await user.click(await screen.findByRole("menuitem", { name }));
expect(onOpenLink).not.toHaveBeenCalled();
return screen.findByRole("dialog", {
name:
@@ -251,6 +267,137 @@ 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)",
+ 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 Read messages and threads."
+ : role === "member"
+ ? "Participant Send messages, reply, and react."
+ : "Admin Manage this channel and its members.",
+ }),
+ );
+ 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"
+ : role === "member"
+ ? "Add as participant"
+ : "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"
+ : role === "member"
+ ? "Participating"
+ : "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 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" }));
+ 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 Manage this channel and its members.",
+ }),
+ );
+ 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"],
@@ -664,42 +811,67 @@ it("confirms removal and removes only the confirmed roster entry", async () => {
expect(t.session.channels.get?.(id)?.members).not.toContain(t.target.pubkey);
});
it.each(["member", "guest"])(
- "keeps ordinary invitations usable for %s without admin controls",
+ "keeps the %s member directory usable without admin controls",
async (actor) => {
const t = await setup(actor);
await expectProfileOnly(t.user, "Morgan");
expect(screen.getByRole("searchbox")).toHaveAttribute(
"placeholder",
- "Add people and agents",
+ actor === "guest" ? "Search members" : "Add people and agents",
);
},
);
-it.each([
- ["member", ["Make admin", "Remove from channel"]],
- ["admin", ["Make member", "Remove from channel"]],
- ["guest", ["Make admin", "Make member", "Remove from channel"]],
-] as const)(
- "omits Guest assignment while preserving the verified %s role and supported actions",
- async (role, actions) => {
+it.each(["member", "admin", "guest"] as const)(
+ "shows the verified %s role beside an ellipsis menu until confirmation",
+ async (role) => {
const t = await setup("owner", role);
const row = required(screen.getByText("Morgan").closest("li"));
+ const caption =
+ role === "member"
+ ? "Participating"
+ : role === "guest"
+ ? "Viewing only"
+ : "Admin";
+ const actions = within(row).getByRole("button", {
+ name: "Actions for Morgan",
+ });
+ expect(within(row).getByText(caption)).toBeVisible();
+ expect(actions).not.toHaveTextContent(caption);
expect(
- within(row).getByRole("button", { name: /Open profile/ }),
- ).toHaveAccessibleName(new RegExp(`, ${role}$`));
- await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
- );
+ within(row).queryByRole("button", { name: "Access for Morgan" }),
+ ).not.toBeInTheDocument();
+ expect(
+ within(row).queryByText(role === "guest" ? "Guest" : "Member"),
+ ).not.toBeInTheDocument();
+ await t.user.click(actions);
await screen.findByRole("menuitem", { name: "Remove from channel" });
expect(
- screen.getAllByRole("menuitem").map((item) => item.textContent),
- ).toEqual(["View profile", ...actions]);
+ screen.getAllByRole("menuitemradio").map((item) => item.textContent),
+ ).toEqual(["Admin", "Participating", "Viewing only"]);
+ expect(
+ screen.getByRole("menuitemradio", { name: caption }),
+ ).toHaveAttribute("aria-checked", "true");
await t.user.keyboard("{Escape}");
+ await vi.waitFor(() => expect(actions).toHaveFocus());
expect(
within(row).getByRole("button", { name: /Open profile/ }),
).toHaveAccessibleName(new RegExp(`, ${role}$`));
expect(t.publish).not.toHaveBeenCalled();
},
);
+
+it("makes a member a guest only after explicit confirmation", async () => {
+ const t = await setup("owner", "member");
+ const dialog = await t.choose("Make guest");
+ expect(dialog).toHaveTextContent("from member to guest");
+ expect(t.publish).not.toHaveBeenCalled();
+ await t.user.click(
+ within(dialog).getByRole("button", { name: "Make guest" }),
+ );
+ await vi.waitFor(() => expect(t.publish).toHaveBeenCalledOnce());
+ expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "guest"]);
+});
+
it("changes an existing Guest to Member only after explicit confirmation", async () => {
const t = await setup("owner", "guest");
const dialog = await t.choose("Make member");
@@ -751,13 +923,15 @@ it.each([false, true])(
],
).toBe("bot");
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
expect(
await screen.findByRole("menuitem", { name: "Remove from channel" }),
).toBeVisible();
- expect(screen.getByRole("menuitem", { name: "Make admin" })).toBeVisible();
- expect(screen.getByRole("menuitem", { name: "Make member" })).toBeVisible();
+ expect(screen.getByRole("menuitemradio", { name: "Admin" })).toBeVisible();
+ expect(
+ screen.getByRole("menuitemradio", { name: "Participating" }),
+ ).toBeVisible();
expect(t.publish).not.toHaveBeenCalled();
await t.user.click(
screen.getByRole("menuitem", { name: "Remove from channel" }),
@@ -954,7 +1128,7 @@ it("retains roster and displays rejection with explicit refresh recovery", async
await t.user.click(
screen.getByRole("button", { name: "Refresh member data" }),
);
- await screen.findByRole("button", { name: "Actions for Morgan" });
+ await screen.findByRole("button", { name: /^Actions for Morgan$/ });
expect(t.publish).toHaveBeenCalledOnce();
});
@@ -1273,7 +1447,11 @@ async function expectProfileOnly(
user: ReturnType,
name: string,
) {
- await user.click(screen.getByRole("button", { name: `Actions for ${name}` }));
+ await user.click(
+ screen.getByRole("button", {
+ name: new RegExp(`^Actions for ${name}$`),
+ }),
+ );
expect(
(await screen.findAllByRole("menuitem")).map((item) => item.textContent),
).toEqual(["View profile"]);
@@ -1291,7 +1469,7 @@ it.each(["ellipsis", "context", "keyboard", "context-key"])(
const row = required(screen.getByText("Morgan").closest("li"));
if (entry === "ellipsis") {
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
} else if (entry === "context") {
fireEvent.contextMenu(row, { clientX: 100, clientY: 100, button: 2 });
@@ -1307,7 +1485,7 @@ it.each(["ellipsis", "context", "keyboard", "context-key"])(
}
expect(
(await screen.findAllByRole("menuitem")).map((item) => item.textContent),
- ).toEqual(["View profile", "Make admin", "Remove from channel"]);
+ ).toEqual(["View profile", "Remove from channel"]);
expect(t.onOpenLink).not.toHaveBeenCalled();
expect(t.publish).not.toHaveBeenCalled();
await t.user.click(screen.getByRole("menuitem", { name: "View profile" }));
@@ -1322,7 +1500,9 @@ it.each(["ellipsis", "context", "keyboard", "context-key"])(
it("keeps Members and restores the menu trigger when profile navigation is declined", async () => {
const t = await setup();
t.onOpenLink.mockReturnValue(false);
- const actions = screen.getByRole("button", { name: "Actions for Morgan" });
+ const actions = screen.getByRole("button", {
+ name: /^Actions for Morgan$/,
+ });
await t.user.click(actions);
await t.user.click(
await screen.findByRole("menuitem", { name: "View profile" }),
@@ -1399,7 +1579,9 @@ it.each(["Carl", "Owner", "Morgan"])(
async (name) => {
const t = await setup();
await t.user.click(
- screen.getByRole("button", { name: `Actions for ${name}` }),
+ screen.getByRole("button", {
+ name: new RegExp(`^Actions for ${name}$`),
+ }),
);
const view = await screen.findByRole("menuitem", { name: "View profile" });
expect(
@@ -1431,14 +1613,14 @@ it.each([
navigation,
);
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
expect(
(await screen.findAllByRole("menuitem")).map((item) => item.textContent),
).toEqual(offered ? ["View profile", "Send message"] : ["View profile"]);
await t.user.keyboard("{Escape}");
await t.user.click(
- screen.getByRole("button", { name: "Actions for Carl" }),
+ screen.getByRole("button", { name: /^Actions for Carl$/ }),
);
expect(
await screen.findByRole("menuitem", { name: "View profile" }),
@@ -1467,7 +1649,9 @@ it("opens a confirmed human DM once, then hands navigation off without a members
release = resolve;
}),
);
- const actions = screen.getByRole("button", { name: "Actions for Morgan" });
+ const actions = screen.getByRole("button", {
+ name: /^Actions for Morgan$/,
+ });
await t.user.click(actions);
await t.user.click(
await screen.findByRole("menuitem", { name: "Send message" }),
@@ -1508,7 +1692,9 @@ it.each(["read", "navigation"])(
if (failure === "read")
t.openMessage.mockRejectedValueOnce(new Error("DM unavailable"));
else t.onOpenConversation.mockReturnValueOnce(false);
- const actions = screen.getByRole("button", { name: "Actions for Morgan" });
+ const actions = screen.getByRole("button", {
+ name: /^Actions for Morgan$/,
+ });
await t.user.click(actions);
await t.user.click(
await screen.findByRole("menuitem", { name: "Send message" }),
@@ -1553,7 +1739,7 @@ it("closing Members aborts the DM waiter and prevents late navigation", async ()
}),
);
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
await t.user.click(
await screen.findByRole("menuitem", { name: "Send message" }),
@@ -1672,14 +1858,12 @@ it.each(["bot", "member"])(
name: "Open owner profile: Carl (you)",
});
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
const remove = await screen.findByRole("menuitem", {
name: "Remove from channel",
});
- expect(
- screen.queryByRole("menuitem", { name: /^Make / }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("menuitemradio")).not.toBeInTheDocument();
await t.user.click(remove);
const dialog = await screen.findByRole("dialog", {
name: "Remove agent from channel",
@@ -1738,15 +1922,13 @@ it.each(["other", "invalid"] as const)(
).toHaveAttribute("aria-busy", "false"),
);
await t.user.click(
- screen.getByRole("button", { name: "Actions for Morgan" }),
+ screen.getByRole("button", { name: /^Actions for Morgan$/ }),
);
await screen.findByRole("menuitem", { name: "View profile" });
expect(
screen.queryByRole("menuitem", { name: "Remove from channel" }),
).not.toBeInTheDocument();
- expect(
- screen.queryByRole("menuitem", { name: /^Make / }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("menuitemradio")).not.toBeInTheDocument();
},
);
@@ -1754,6 +1936,13 @@ it.each(["owner", "admin"])(
"lets %s explicitly promote a bot while preserving verified agent identity",
async (actor) => {
const t = await setup(actor, "bot", true, undefined, true);
+ await t.user.click(
+ screen.getByRole("button", { name: "Actions for Morgan" }),
+ );
+ expect(
+ await screen.findByRole("menuitemradio", { name: "Participating" }),
+ ).toHaveAttribute("aria-checked", "true");
+ await t.user.keyboard("{Escape}");
const dialog = await t.choose("Make admin");
expect(dialog).toHaveTextContent("from bot to admin");
expect(t.publish).not.toHaveBeenCalled();
diff --git a/src/bundled/channels/MemberAdministration.tsx b/src/bundled/channels/MemberAdministration.tsx
index cd6b14c71a..2275256818 100644
--- a/src/bundled/channels/MemberAdministration.tsx
+++ b/src/bundled/channels/MemberAdministration.tsx
@@ -21,6 +21,8 @@ import {
MenuTrigger,
MenuPopup,
MenuSeparator,
+ MenuRadioGroup,
+ MenuRadioItem,
} from "../../shared/design-system/ui/Menu";
import { DotsThreeIcon } from "../../shared/design-system/icons";
import styles from "./ChannelMembersDialog.module.css";
@@ -126,6 +128,16 @@ export function MemberRow({
pubkey,
verifiedOwner,
);
+ const roleCaption =
+ role === "member" || role === "bot"
+ ? "Participating"
+ : role === "guest"
+ ? "Viewing only"
+ : role === "owner"
+ ? "Owner"
+ : role === "admin"
+ ? "Admin"
+ : undefined;
const locked =
state.operation?.status === "pending" ||
state.operation?.status === "uncertain";
@@ -166,15 +178,27 @@ export function MemberRow({
{(canEdit || canRemove) && !locked && (
<>
- {canEdit &&
- editableMemberRoles
- // Guest assignment is hidden until its permission contract is settled.
- .filter((next) => next !== role && next !== "guest")
- .map((next) => (
- choose(next)}>
- Make {next}
-
+ {canEdit && (
+ {
+ const target = editableMemberRoles.find(
+ (value) => value === next,
+ );
+ if (target && target !== role) choose(target);
+ }}
+ >
+ {editableMemberRoles.map((next) => (
+
+ {next === "member"
+ ? "Participating"
+ : next === "guest"
+ ? "Viewing only"
+ : "Admin"}
+
))}
+
+ )}
{canEdit && }
{canRemove && (
choose("remove")}>
@@ -231,6 +255,11 @@ export function MemberRow({
{invitationAction}
{!invitationAction && (
+ {roleCaption && (
+
+ {roleCaption}
+
+ )}
{/* A regular trigger needs its own root: registering it on the
context root replaces Base UI's context-menu interaction owner. */}
{
+ 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 (
{
registerCanvas(ctx);
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/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 e6e8c32cb8..0120fb3d74 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,
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/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..53c585f24b 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", "member"] 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/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts
index 2ef220cbac..e46377e85c 100644
--- a/src/features/channel-members/members.test.ts
+++ b/src/features/channel-members/members.test.ts
@@ -7,10 +7,42 @@ import type { RelayEvent } from "../relay/events";
import { addChannelMember, canAddMembers } from "./members";
const cleanups: (() => void)[] = [];
+it.each([
+ ["guest", "member", false],
+ ["guest", "guest", false],
+ ["member", "guest", true],
+ ["owner", "guest", true],
+ [undefined, "guest", false],
+ [undefined, "member", true],
+] as const)(
+ "keeps invitation eligibility separate from the %s viewer's %s admission default",
+ (viewerRole, joinRole, allowed) => {
+ expect(
+ canAddMembers(
+ { viewer: "viewer", outbox: { supports: () => true } },
+ {
+ id: "channel",
+ name: "Updates",
+ channelType: "stream",
+ joinRole,
+ viewerRole,
+ members: viewerRole ? ["viewer"] : [],
+ ...(viewerRole === "guest" || !viewerRole
+ ? { readOnly: true as const }
+ : {}),
+ },
+ ),
+ ).toBe(allowed);
+ },
+);
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();
@@ -66,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) =>
@@ -166,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();
@@ -177,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,
);
@@ -193,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 866b232091..090ad73bf1 100644
--- a/src/features/channel-members/members.ts
+++ b/src/features/channel-members/members.ts
@@ -21,10 +21,11 @@ export function canAddMembers(
session.viewer &&
session.outbox?.supports(9000) &&
channel &&
+ channel.viewerRole !== "guest" &&
!channel.archived &&
expandable.has(channel.channelType ?? "") &&
(channel.members?.includes(session.viewer) ||
- (channel.readOnly && !channel.private))
+ (channel.readOnly && !channel.private && channel.joinRole !== "guest"))
);
}
@@ -159,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))
@@ -193,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 ??
@@ -203,7 +215,7 @@ export async function addChannelMember(
tags: [
["h", channelId],
["p", pubkey],
- ...(isAgent ? [["role", "bot"]] : []),
+ ...(role ? [["role", role]] : []),
],
},
recovery,
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/conversation/EmbeddedThread.test.tsx b/src/features/conversation/EmbeddedThread.test.tsx
index d2d1b44407..38d90753c3 100644
--- a/src/features/conversation/EmbeddedThread.test.tsx
+++ b/src/features/conversation/EmbeddedThread.test.tsx
@@ -172,7 +172,7 @@ function fixture(
subscribe: () => () => {},
},
agentChoices: createAgentLibrary(undefined).queries,
- messages: { retry() {} },
+ messages: { retry() {}, canRetry: () => true },
media: () => undefined,
unread,
outbox,
diff --git a/src/features/messages/ChannelJoinNotice.test.tsx b/src/features/messages/ChannelJoinNotice.test.tsx
index 96a7f1831a..1b5b80a34a 100644
--- a/src/features/messages/ChannelJoinNotice.test.tsx
+++ b/src/features/messages/ChannelJoinNotice.test.tsx
@@ -16,6 +16,30 @@ import { ChannelJoinNotice } from "./ChannelJoinNotice";
afterEach(cleanup);
+it.each([false, true])(
+ "keeps Guest-default previews read-only with stored Guest membership %s",
+ (guest) => {
+ const run = vi.fn();
+ const started = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Read-only channel" }),
+ ).toHaveTextContent("This channel is read-only.");
+ expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ expect(started).not.toHaveBeenCalled();
+ },
+);
+
function lifecycle(
run: ChannelLifecycleCapability["run"],
available = true,
diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx
index c013ab5fe7..b41b35d287 100644
--- a/src/features/messages/ChannelJoinNotice.tsx
+++ b/src/features/messages/ChannelJoinNotice.tsx
@@ -4,7 +4,7 @@ import { ChannelLifecycleUnconfirmed } from "../relay/channel-lifecycle";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Messages.module.css";
-/** A public preview offers Join; the membership roster, not this notice, enables the composer.
+/** Member-default public previews offer Join; the roster alone enables the composer.
* Confirmed membership can unmount this notice before its run settles, so the
* page records the join intent when the run starts, not when it resolves. */
export function ChannelJoinNotice({
@@ -12,6 +12,8 @@ export function ChannelJoinNotice({
ready = true,
lifecycle,
joinable,
+ guest = false,
+ joinRole,
onJoin,
onHeightChange,
}: {
@@ -19,6 +21,8 @@ export function ChannelJoinNotice({
ready?: boolean;
lifecycle?: ChannelLifecycleCapability | undefined;
joinable: boolean;
+ guest?: boolean;
+ joinRole?: "member" | "guest" | undefined;
onJoin(): void;
onHeightChange?(height: number): void;
}) {
@@ -68,11 +72,12 @@ export function ChannelJoinNotice({
},
);
};
- const canJoin = joinable && !!lifecycle?.available;
+ const readOnly = guest || joinRole === "guest";
+ const canJoin = !readOnly && joinable && !!lifecycle?.available;
return (
-
- {canJoin
- ? "Join channel to send messages"
- : "You’re previewing this channel."}
+
+ {readOnly
+ ? joinRole === "guest"
+ ? "This channel is read-only."
+ : "Read-only access"
+ : canJoin
+ ? "Join channel to send messages"
+ : "You’re previewing this channel."}
{canJoin && (
}
title="No messages yet."
- description="Start the conversation below."
+ description={
+ queries.channels.get?.(channelId)?.readOnly
+ ? undefined
+ : "Start the conversation below."
+ }
/>
)}
diff --git a/src/features/messages/MessageManagement.test.tsx b/src/features/messages/MessageManagement.test.tsx
index 9069a73b58..b22a317002 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[] = [];
@@ -71,7 +71,10 @@ async function fixture(
agentOwner: "viewer" | "other" | undefined = undefined,
chatCard = false,
shareRead?: Promise,
+ viewerRole: "member" | "guest" = "member",
) {
+ let role = viewerRole,
+ rosterTime = 1700000000;
vi.stubGlobal(
"ResizeObserver",
class {
@@ -114,32 +117,34 @@ async function fixture(
...(originalMention ? [["p", peer.pubkey]] : []),
],
);
- const agentProfile =
+ let 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;
@@ -180,7 +185,15 @@ 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]),
+ created_at: rosterTime,
+ tags: [
+ ["d", "room"],
+ ["p", viewer.pubkey, "", role],
+ ["p", peer.pubkey, "", "member"],
+ ],
+ }),
];
if (filter.kinds?.includes(0))
return [
@@ -287,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( ),
@@ -348,32 +380,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);
@@ -564,32 +642,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);
});
@@ -779,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 9014b8a110..62c64ae67e 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);
@@ -244,17 +247,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 +330,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 +353,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 +362,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 (
@@ -477,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(
@@ -535,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 ||
@@ -568,12 +579,7 @@ export function MessageManagementStatus() {
same operation; dismissing does not undo it.
)}
- session.outbox?.retry(item.event.id)}
- >
- Retry message update
-
+
{
try {
@@ -598,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/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx
index b7070327f9..12c9e6f472 100644
--- a/src/features/messages/ThreadPanel.test.tsx
+++ b/src/features/messages/ThreadPanel.test.tsx
@@ -1449,6 +1449,11 @@ it("passes readiness and direct Activity through to thread composition, and give
it.each([
{ channelType: "stream" as const, joinable: true },
{ channelType: "forum" as const, joinable: true },
+ {
+ channelType: "stream" as const,
+ joinRole: "guest" as const,
+ joinable: false,
+ },
{ channelType: "stream" as const, archived: true as const, joinable: false },
{ channelType: "solo" as const, joinable: false },
])(
@@ -1472,13 +1477,67 @@ it.each([
});
h.render();
expect(
- screen.getByRole("region", { name: "Channel preview" }),
+ screen.getByRole("region", {
+ name:
+ metadata.joinRole === "guest"
+ ? "Read-only channel"
+ : "Channel preview",
+ }),
).toBeVisible();
expect(screen.queryByRole("region", { name: "Composer" })).toBeNull();
expect(!!screen.queryByRole("button", { name: "Join" })).toBe(joinable);
},
);
+it("keeps guest threads readable without a reply composer or Join action across default changes", () => {
+ const h = messagesHarness();
+ const channel: ChannelSummary = {
+ id: "channel",
+ name: "General",
+ channelType: "stream",
+ viewerRole: "guest",
+ readOnly: true,
+ joinRole: "guest",
+ };
+ let list: ChannelList = { status: "ready", channels: [channel] };
+ const listeners = new Set<() => void>();
+ Object.assign(h.session, {
+ channels: {
+ list: () => list,
+ get: () => list.channels[0],
+ subscribeList: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ },
+ channelLifecycle: { available: true, run: vi.fn() },
+ });
+ h.render();
+ expect(
+ screen.getByRole("region", { name: "Read-only channel" }),
+ ).toHaveTextContent("This channel is read-only.");
+ expect(
+ screen.getByRole("region", { name: "Thread messages" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Composer" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
+ act(() => {
+ list = { ...list, channels: [{ ...channel, joinRole: "member" }] };
+ for (const listener of listeners) listener();
+ });
+ expect(
+ screen.getByRole("region", { name: "Read-only channel" }),
+ ).toHaveTextContent("Read-only access");
+ expect(screen.queryByRole("region", { name: "Composer" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
+ act(() => {
+ const { readOnly: _, ...promoted } = channel;
+ list = { ...list, channels: [{ ...promoted, viewerRole: "member" }] };
+ for (const listener of listeners) listener();
+ });
+ expect(screen.getByRole("region", { name: "Composer" })).toBeInTheDocument();
+});
+
it("keeps a thread preview until membership arrives, with failed joins retryable", async () => {
const h = messagesHarness();
const channel: ChannelSummary = {
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index 0d1d381a3d..f1538cee53 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -506,6 +506,7 @@ function ThreadMessages({
channelId,
(listed) => {
const channel = listed ?? session.channels?.get?.(channelId);
+ if (channel?.viewerRole === "guest") return "guest";
if (channel?.readOnly && !channel.cached) {
return !channel.archived &&
(channel.channelType === "stream" || channel.channelType === "forum")
@@ -517,7 +518,15 @@ function ThreadMessages({
: "other";
},
);
- const preview = channelAccess === "joinable" || channelAccess === "preview";
+ const preview =
+ channelAccess === "joinable" ||
+ channelAccess === "preview" ||
+ channelAccess === "guest";
+ const joinRole = useListedChannel(
+ session.channels,
+ channelId,
+ (listed) => (listed ?? session.channels?.get?.(channelId))?.joinRole,
+ );
const cached = useListedChannel(
session.channels,
channelId,
@@ -855,9 +864,14 @@ function ThreadMessages({
const [sent, setSent] = useState();
const [replyFocus, setReplyFocus] = useState(0);
useEffect(() => {
- if (!joining || channelAccess !== "member") return;
+ if (!joining || (channelAccess !== "member" && channelAccess !== "guest"))
+ return;
setJoining(false);
- if (active) setReplyFocus((value) => value + 1);
+ if (active) {
+ if (channelAccess === "guest")
+ scroller.current?.focus({ preventScroll: true });
+ else setReplyFocus((value) => value + 1);
+ }
}, [joining, channelAccess, active]);
const focusReply = useCallback(() => {
setReplyParent(undefined);
@@ -1602,6 +1616,8 @@ function ThreadMessages({
!!rows.length)
}
channelId={channelId}
+ guest={channelAccess === "guest"}
+ joinRole={joinRole}
lifecycle={session.channelLifecycle}
joinable={channelAccess === "joinable"}
onJoin={() => setJoining(true)}
diff --git a/src/features/relay/channel-details-protocol.ts b/src/features/relay/channel-details-protocol.ts
index 1836e20bbd..423d4c7f3c 100644
--- a/src/features/relay/channel-details-protocol.ts
+++ b/src/features/relay/channel-details-protocol.ts
@@ -16,6 +16,8 @@ export type ChannelDetailsDraft = Readonly<{
name: string;
description: string;
visibility: "public" | "private";
+ /** Admission default only; omission preserves existing relay behavior. */
+ joinRole?: "member" | "guest" | undefined;
/** Absent means ongoing; retain existing custom temporary intervals. */
ttlSeconds?: number | undefined;
/** NIP-CL labels. Absent leaves the relay's set unchanged; empty clears it. */
@@ -79,6 +81,12 @@ export function detailsDraftErrors(draft: ChannelDetailsDraft) {
? "Channel visibility could not be verified."
: undefined,
labels: labelsError(draft?.labels),
+ joinRole:
+ draft?.joinRole !== undefined &&
+ draft.joinRole !== "member" &&
+ draft.joinRole !== "guest"
+ ? "New participant role could not be verified."
+ : undefined,
};
}
@@ -109,7 +117,8 @@ export function validateDetailsDraft(draft: ChannelDetailsDraft): void {
errors.description ??
errors.visibility ??
errors.lifetime ??
- errors.labels;
+ errors.labels ??
+ errors.joinRole;
if (error) throw new Error(error);
}
@@ -129,6 +138,7 @@ export function detailsSettings(
([key, value]) => key === "p" && value === viewer,
)?.[2];
const visibility = channelVisibility(metadata);
+ const joinRole = channelJoinRole(metadata);
const name = exactLifecycleTag(metadata, "name");
const description = exactLifecycleTag(metadata, "about") ?? "";
const ttl = exactLifecycleTag(metadata, "ttl");
@@ -141,6 +151,7 @@ export function detailsSettings(
name,
description,
visibility,
+ ...(joinRole ? { joinRole } : {}),
...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
labels: channelIdentity(metadata).labels,
canEdit:
@@ -178,6 +189,9 @@ export function detailsTemplate(
],
]
: []),
+ ...(draft.joinRole !== undefined && draft.joinRole !== base.joinRole
+ ? [["join_role", draft.joinRole]]
+ : []),
// NIP-CL: `t` tags replace the set; one empty `t` clears it; none keeps it.
...(draft.labels !== undefined &&
!sameLabels(draft.labels, base.labels ?? [])
@@ -212,6 +226,15 @@ export function channelTtl(event: RelayEvent): number | undefined {
}
}
+/** Legacy absence means Member; malformed explicit defaults fail closed to Guest. */
+export function channelJoinRole(event: RelayEvent): "member" | "guest" {
+ const tags = event.tags.filter(([key]) => key === "join_role");
+ if (tags.length === 0) return "member";
+ return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "member"
+ ? "member"
+ : "guest";
+}
+
/** A relay metadata TTL must be an unambiguous positive i32; only absence is ongoing. */
function parseDetailsTtl(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
@@ -242,7 +265,16 @@ export function validateDetailsTemplate(event: EventTemplate): void {
!["h", "name", "about"].every(
(key, index) => event.tags[index]?.[0] === key,
) ||
- ![[], ["visibility"], ["ttl"], ["visibility", "ttl"]].some(
+ ![
+ [],
+ ["visibility"],
+ ["ttl"],
+ ["visibility", "ttl"],
+ ["join_role"],
+ ["visibility", "join_role"],
+ ["ttl", "join_role"],
+ ["visibility", "ttl", "join_role"],
+ ].some(
(keys) =>
keys.every((key, index) => event.tags[index + 3]?.[0] === key) &&
event.tags.slice(keys.length + 3).every(([key]) => key === "t"),
@@ -254,12 +286,16 @@ export function validateDetailsTemplate(event: EventTemplate): void {
if (visibility && !["private", "open"].includes(visibility[1] ?? ""))
throw new Error("Invalid channel details command.");
const ttl = event.tags.find(([key]) => key === "ttl")?.[1];
+ const joinRole = event.tags.find(([key]) => key === "join_role")?.[1];
+ if (joinRole !== undefined && joinRole !== "member" && joinRole !== "guest")
+ throw new Error("Invalid channel details command.");
lifecycleChannelId(event.tags[0]?.[1] ?? "");
validateDetailsDraft({
name: event.tags[1]?.[1] ?? "",
description: event.tags[2]?.[1] ?? "",
visibility: visibility?.[1] === "private" ? "private" : "public",
ttlSeconds: ttl === "" ? undefined : parseDetailsTtl(ttl),
+ ...(joinRole ? { joinRole } : {}),
...(event.tags.some(([key]) => key === "t") ? { labels } : {}),
});
}
diff --git a/src/features/relay/channel-details.test.ts b/src/features/relay/channel-details.test.ts
index 09b00cb0c3..88edc4fe65 100644
--- a/src/features/relay/channel-details.test.ts
+++ b/src/features/relay/channel-details.test.ts
@@ -3,6 +3,7 @@ import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools";
import { createChannelDetails } from "./channel-details";
import {
channelVisibility,
+ channelJoinRole,
canonicalDetailsName,
detailsSettings,
detailsTemplate,
@@ -106,6 +107,134 @@ function harness(role = "owner", labels = false) {
},
};
}
+it.each([undefined, "member"] as const)(
+ "changes admission defaults from %s without changing the signed roster, and requires matching readback",
+ async (initialRole) => {
+ const h = harness();
+ const withRole = (role: string) =>
+ h.record(39000, [...h.metadata().tags.slice(1), ["join_role", role]]);
+ if (initialRole) h.set([withRole(initialRole), ...h.events().slice(1)]);
+ const originalRoster = h.entry(2);
+ const base = await h.owner.capability.load(id);
+ expect(base.joinRole).toBe(initialRole ?? "member");
+ const next = { ...base, joinRole: "guest" as const };
+ // A relay OK that ignores the new tag must not report success.
+ h.publish.mockImplementationOnce(async () => {});
+ await expect(h.owner.capability.save(base, next)).rejects.toThrow(
+ "may have been saved",
+ );
+ expect(h.sign.mock.calls[0]?.[0].tags).toContainEqual([
+ "join_role",
+ "guest",
+ ]);
+ expect(
+ h.sign.mock.calls[0]?.[0].tags.some(
+ ([key]) => key === "p" || key === "role",
+ ),
+ ).toBe(false);
+ h.set([withRole("guest"), ...h.events().slice(1)]);
+ await h.owner.capability.check(id);
+ expect(h.entry(2)).toBe(originalRoster);
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.owner.capability.snapshot(id)).toBeUndefined();
+ },
+);
+
+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) => {
+ const base = { ...draft, joinRole } as typeof draft & {
+ joinRole?: "member" | "guest";
+ };
+ expect(
+ detailsTemplate(id, base, base).tags.some(([key]) => key === "join_role"),
+ ).toBe(false);
+ for (const role of ["member", "guest"] as const) {
+ const event = detailsTemplate(id, { ...base, joinRole: role }, base);
+ expect(() => validateDetailsTemplate(event)).not.toThrow();
+ expect(event.tags.filter(([key]) => key === "join_role")).toEqual(
+ role === joinRole ? [] : [["join_role", role]],
+ );
+ }
+ },
+);
+
+it("rejects malformed or elevated admission defaults at the host boundary", () => {
+ const event = detailsTemplate(id, draft, draft);
+ for (const tags of [
+ [["join_role", "owner"]],
+ [["join_role", "admin"]],
+ [["join_role", "bot"]],
+ [["join_role", ""]],
+ [["join_role"]],
+ [["join_role", "guest", "extra"]],
+ [
+ ["join_role", "member"],
+ ["join_role", "guest"],
+ ],
+ [
+ ["join_role", "guest"],
+ ["ttl", "60"],
+ ],
+ ]) {
+ expect(() =>
+ validateDetailsTemplate({ ...event, tags: [...event.tags, ...tags] }),
+ ).toThrow();
+ }
+ const h = harness();
+ expect(channelJoinRole(h.record(39000, []))).toBe("member");
+ for (const tags of [
+ [["join_role", "owner"]],
+ [
+ ["join_role", "member"],
+ ["join_role", "guest"],
+ ],
+ ])
+ expect(channelJoinRole(h.record(39000, tags))).toBe("guest");
+});
+
it("saves only after two fresh authority checks and projects confirmed readback", async () => {
const h = harness();
const base = await h.owner.capability.load(id);
@@ -415,6 +544,35 @@ it("retains edits on rejection but locks unknown outcomes until read-only confir
expect(h.owner.capability.snapshot(id)).toBeUndefined();
expect(h.publish).toHaveBeenCalledTimes(2);
});
+it.each([
+ ["initial read", "Could not verify current channel access"],
+ ["signing", "Could not sign the channel change"],
+ ["final read", "Could not recheck channel access after signing"],
+] as const)(
+ "identifies a failed %s without publishing or retrying",
+ async (phase, context) => {
+ const h = harness();
+ const base = await h.owner.capability.load(id);
+ // Native IPC can reject with a plain string; read adapters use Error.
+ const failure =
+ phase === "signing"
+ ? "Relay request failed (503)"
+ : new Error("Relay request failed (503)");
+ if (phase === "signing") h.sign.mockRejectedValueOnce(failure);
+ else {
+ if (phase === "final read") h.read.mockResolvedValueOnce(h.events());
+ h.read.mockRejectedValueOnce(failure);
+ }
+ await expect(h.owner.capability.save(base, draft)).rejects.toMatchObject({
+ message: `${context}: Relay request failed (503)`,
+ cause: failure,
+ });
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(h.sign).toHaveBeenCalledTimes(phase === "initial read" ? 0 : 1);
+ expect(h.read).toHaveBeenCalledTimes(phase === "final read" ? 3 : 2);
+ expect(h.owner.capability.snapshot(id)).toBeUndefined();
+ },
+);
it("receipt alone is not success and repeated Save cannot publish twice", async () => {
const h = harness();
const base = await h.owner.capability.load(id);
diff --git a/src/features/relay/channel-details.ts b/src/features/relay/channel-details.ts
index cfb4b9fc7b..21c0a56735 100644
--- a/src/features/relay/channel-details.ts
+++ b/src/features/relay/channel-details.ts
@@ -123,6 +123,7 @@ export function createChannelDetails({
details.description !== draft.description ||
details.visibility !== draft.visibility ||
details.ttlSeconds !== draft.ttlSeconds ||
+ (draft.joinRole !== undefined && details.joinRole !== draft.joinRole) ||
(draft.labels !== undefined &&
JSON.stringify([...new Set(draft.labels)].sort()) !==
JSON.stringify([...(details.labels ?? [])].sort()))
@@ -214,6 +215,7 @@ export function createChannelDetails({
)
throw channelIdentityUnsupported();
let publicationStarted = false;
+ let phase = "Could not verify current channel access";
attempts.set(id, Object.freeze({ draft, status: "saving" }));
emit();
try {
@@ -233,6 +235,7 @@ export function createChannelDetails({
};
await authorize();
const template = detailsTemplate(id, draft, base);
+ phase = "Could not sign the channel change";
const signed = await writer.sign(structuredClone(template), signal);
signal.throwIfAborted();
if (
@@ -245,6 +248,7 @@ export function createChannelDetails({
!verifyEvent(signed)
)
throw new Error("Signer changed the channel details command.");
+ phase = "Could not recheck channel access after signing";
await authorize();
signal.throwIfAborted();
if (active?.() === false)
@@ -262,6 +266,12 @@ 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)}`,
+ { cause: error },
+ );
throw error;
} finally {
emit();
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 2c0b4195e1..cf9e4cb3cf 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -22,13 +22,17 @@ export type ChannelSummary = Readonly<{
metadataPending?: boolean | undefined;
/** Explicit signed visibility; absent means unknown, not public. */
visibility?: "public" | "private" | undefined;
+ /** Default for future participants; never an existing participant's role. */
+ joinRole?: "member" | "guest" | undefined;
+ /** Viewer role from the relay-signed roster, independent of admission defaults. */
+ viewerRole?: "owner" | "admin" | "member" | "guest" | "bot" | undefined;
preview?: string | undefined;
/** Newest verified user-visible activity for sidebar ordering, in Unix
* seconds. An own send counts once the relay accepts it. */
lastActivityAt?: number | undefined;
/** Activity has been observed or read successfully, including an empty result. */
activityKnown?: true;
- /** Readable public nonmember channel; not part of the joined roster. */
+ /** Writing unavailable: public preview, cached membership, or a guest. */
readOnly?: true;
/** Downloaded membership is display-only pending current relay confirmation. */
cached?: true;
diff --git a/src/features/relay/discovery-permissions.test.ts b/src/features/relay/discovery-permissions.test.ts
new file mode 100644
index 0000000000..9c6fe2f1d4
--- /dev/null
+++ b/src/features/relay/discovery-permissions.test.ts
@@ -0,0 +1,149 @@
+import { expect, it } from "vitest";
+import { DiscoveryState } from "./discovery";
+import { eventDto } from "./events";
+import { keypair, metadata, signed } from "./testing";
+
+const relay = keypair(),
+ viewer = keypair();
+const id = "permissions";
+const roster = (role: string, time: number) =>
+ signed(relay, {
+ kind: 39002,
+ content: "",
+ created_at: time,
+ tags: [
+ ["d", id],
+ ["p", viewer.pubkey, "", role],
+ ],
+ });
+const details = (role: string, time: number) =>
+ metadata(relay, id, "Updates", time, [
+ ["public"],
+ ["t", "stream"],
+ ["join_role", role],
+ ]);
+
+it("keeps a guest joined and readable across admission-default changes until explicitly promoted", () => {
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(details("guest", 10));
+ state.accept(roster("guest", 10));
+ expect(state.channels()).toHaveLength(1);
+ expect(state.get(id)).toMatchObject({
+ viewerRole: "guest",
+ joinRole: "guest",
+ readOnly: true,
+ });
+ expect(state.canAccess(id)).toBe(true);
+ expect(state.canParticipate(id)).toBe(true);
+ expect(state.canWrite(id)).toBe(false);
+ expect(state.suspend(id)).toBe(false);
+ state.accept(details("member", 20));
+ expect(state.get(id)).toMatchObject({
+ viewerRole: "guest",
+ joinRole: "member",
+ readOnly: true,
+ });
+ expect(state.canWrite(id)).toBe(false);
+ state.accept(roster("member", 20));
+ expect(state.get(id)?.readOnly).toBeUndefined();
+ expect(state.canWrite(id)).toBe(true);
+ state.accept(roster("guest", 30));
+ expect(state.canWrite(id)).toBe(false);
+ state.accept(roster("member", 20));
+ expect(state.canWrite(id)).toBe(false);
+});
+
+it.each(["owner", "admin", "member", "bot"])(
+ "preserves an existing %s's writing access when the default becomes guest",
+ (role) => {
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(details("member", 10));
+ state.accept(roster(role, 10));
+ state.accept(details("guest", 20));
+ expect(state.get(id)).toMatchObject({
+ viewerRole: role,
+ joinRole: "guest",
+ });
+ expect(state.get(id)?.readOnly).toBeUndefined();
+ expect(state.canWrite(id)).toBe(true);
+ },
+);
+
+it("does not turn a public nonmember or cached guest into a writer", () => {
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(details("member", 10));
+ expect(state.canWrite(id)).toBe(false);
+ state.accept(roster("guest", 10), true);
+ expect(state.canWrite(id)).toBe(false);
+ expect(state.get(id)).toMatchObject({
+ cached: true,
+ readOnly: true,
+ viewerRole: "guest",
+ });
+ state.accept(roster("guest", 10));
+ expect(state.get(id)?.cached).toBeUndefined();
+ expect(state.canWrite(id)).toBe(false);
+});
+
+it.each(["guest", "member"])(
+ "restores only public reading after a %s leaves, requiring fresh metadata without restoring membership",
+ (role) => {
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ const publicMetadata = details("guest", 10);
+ const membership = roster(role, 10);
+ state.accept(publicMetadata);
+ state.accept(membership);
+ state.deny(id);
+ expect(state.canAccess(id)).toBe(false);
+ 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);
+ state.accept(membership); // Replaying the retained roster is not a promotion.
+ expect(state.canParticipate(id)).toBe(false);
+ state.accept(
+ metadata(relay, id, "Private", 20, [["private"], ["t", "stream"]]),
+ );
+ expect(state.canAccess(id)).toBe(false);
+ state.resume(id, publicMetadata); // An older public snapshot cannot reopen it.
+ expect(state.canAccess(id)).toBe(false);
+ state.resume(
+ id,
+ metadata(viewer, id, "Forged", 30, [["public"], ["t", "stream"]]),
+ );
+ expect(state.canAccess(id)).toBe(false);
+ const reopened = details("guest", 30);
+ state.accept(reopened);
+ state.resume(id, reopened);
+ expect(state.canAccess(id)).toBe(true);
+ expect(state.canParticipate(id)).toBe(false);
+ state.deny(id);
+ expect(state.canAccess(id)).toBe(false);
+ state.resume(id, reopened);
+ state.denyAll();
+ expect(state.canAccess(id)).toBe(false);
+ state.accept(roster("member", 40));
+ expect(state.canWrite(id)).toBe(true);
+ },
+);
+
+it("does not grant writing through a contradictory duplicate guest entry", () => {
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(details("member", 10));
+ state.accept(
+ signed(relay, {
+ kind: 39002,
+ content: "",
+ tags: [
+ ["d", id],
+ ["p", viewer.pubkey, "", "member"],
+ ["p", viewer.pubkey, "", "guest"],
+ ],
+ }),
+ );
+ expect(state.get(id)).toMatchObject({ viewerRole: "guest", readOnly: true });
+ expect(state.canWrite(id)).toBe(false);
+});
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index bf139589b7..d02be1b8c8 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -1,4 +1,8 @@
-import { channelTtl, channelVisibility } from "./channel-details-protocol";
+import {
+ channelJoinRole,
+ channelTtl,
+ channelVisibility,
+} from "./channel-details-protocol";
import { huddleParent } from "../huddle/lifecycle";
import { soloLabels } from "../solo/labels";
import { objectBody } from "./body";
@@ -42,6 +46,8 @@ export class DiscoveryState {
private denied = new Set();
/** Denied only because a complete read omitted them; a later one can list them again. */
private absent = new Set();
+ /** Fresh public resolution can restore reading while membership remains denied. */
+ private confirmedPreviews = new Set();
private suspended = new Set();
private complete = false;
private cached = new Set();
@@ -94,6 +100,8 @@ export class DiscoveryState {
if (next === previous) return confirmed;
const accessible = this.canAccess(id);
map.set(id, next);
+ if (event.kind === 39000 && !this.open(id))
+ this.confirmedPreviews.delete(id);
if (event.kind === 39002) {
this.absent.delete(id);
if (cached) this.cached.add(id);
@@ -145,11 +153,12 @@ export class DiscoveryState {
}
this.accessRevision++;
this.suspended.delete(id);
+ this.confirmedPreviews.delete(id);
this.denied.add(id);
}
/** A CLOSED preview is unreadable pending fresh evidence, not permanently denied. */
suspend(id: string): boolean {
- if (!this.get(id)?.readOnly) return false;
+ if (this.authorized(id) || !this.get(id)?.readOnly) return false;
this.suspended.add(id);
this.accessRevision++;
return true;
@@ -157,8 +166,21 @@ export class DiscoveryState {
suspendedChannels(): string[] {
return [...this.suspended];
}
- resume(id: string): boolean {
- return this.suspended.delete(id);
+ resume(id: string, metadata?: RelayEvent): boolean {
+ const resumed = this.suspended.delete(id);
+ if (
+ this.denied.has(id) &&
+ metadata?.kind === 39000 &&
+ metadata.pubkey === this.relayAuthor &&
+ tag(metadata, "d") === id &&
+ openMetadata(metadata) &&
+ newer(this.metadata.get(id), metadata)?.id === metadata.id
+ ) {
+ const changed = !this.confirmedPreviews.has(id);
+ this.confirmedPreviews.add(id);
+ return resumed || changed;
+ }
+ return resumed;
}
/** A complete viewer-scoped roster read proves absence: rosters it omitted no longer include the viewer. */
rosterVersions(): ReadonlyMap {
@@ -194,6 +216,7 @@ export class DiscoveryState {
this.complete = true;
this.starting.clear();
this.absent.clear();
+ this.confirmedPreviews.clear();
for (const id of [...this.rosters.keys(), ...this.metadata.keys()])
this.denied.add(id);
}
@@ -208,7 +231,9 @@ export class DiscoveryState {
/** Unknown is not denied until a complete roster or local snapshot closes that boundary. */
canAccess(id: string): boolean {
if (this.undecided(id)) return true;
- if (this.denied.has(id) || this.suspended.has(id)) return false;
+ if (this.suspended.has(id)) return false;
+ if (this.denied.has(id))
+ return this.confirmedPreviews.has(id) && this.open(id);
const roster = this.rosters.get(id);
if (roster && hasTag(roster, "p", this.viewer)) return true;
return (
@@ -225,12 +250,27 @@ export class DiscoveryState {
const roster = this.rosters.get(id);
return (
this.canAccess(id) &&
+ !this.denied.has(id) &&
!this.cached.has(id) &&
(roster
? hasTag(roster, "p", this.viewer)
: !this.complete && !this.open(id))
);
}
+ canWrite(id: string): boolean {
+ return this.canParticipate(id) && this.viewerRole(id) !== "guest";
+ }
+ private viewerRole(id: string): ChannelSummary["viewerRole"] {
+ const entries = this.rosters
+ .get(id)
+ ?.tags.filter(([key, value]) => key === "p" && value === this.viewer);
+ // A contradictory guest entry cannot restore writing through another role.
+ if (entries?.some((entry) => entry[3] === "guest")) return "guest";
+ const role = entries?.length === 1 ? entries[0]?.[3] : undefined;
+ return ["owner", "admin", "member", "bot"].includes(role ?? "")
+ ? (role as ChannelSummary["viewerRole"])
+ : undefined;
+ }
authorized(id: string): boolean {
const roster = this.rosters.get(id);
return !this.denied.has(id) && !!roster && hasTag(roster, "p", this.viewer);
@@ -271,6 +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.authorized(id) ? this.viewerRole(id) : undefined;
const type = this.isSolo(id)
? "solo"
: event
@@ -305,14 +346,16 @@ export class DiscoveryState {
id,
...(huddle || legacyHuddle ? { huddle: true as const } : {}),
...(huddle ? { parentChannelId: huddle } : {}),
- ...(!this.authorized(id) || this.cached.has(id)
+ ...(!this.authorized(id) || this.cached.has(id) || viewerRole === "guest"
? { readOnly: true as const }
: {}),
+ ...(viewerRole ? { viewerRole } : {}),
...(this.cached.has(id) ? { cached: true as const } : {}),
name: this.name(id),
...(event
? {
visibility: channelVisibility(event),
+ joinRole: channelJoinRole(event),
description: tag(event, "about") ?? "",
}
: {}),
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/mentions.test.ts b/src/features/relay/mentions.test.ts
index 300b9b6cc2..aecac712d9 100644
--- a/src/features/relay/mentions.test.ts
+++ b/src/features/relay/mentions.test.ts
@@ -173,7 +173,7 @@ it("typed names create no recipient tags; unconfirmed or forged membership canno
);
await h.members(undefined, 1700000000, namesake);
expect(() => h.session.messages.send("c", "@Honey", [honey.pubkey])).toThrow(
- "Join the conversation before posting",
+ "You don’t have write access to this conversation.",
);
expect(h.sign).not.toHaveBeenCalled();
expect(h.publish).not.toHaveBeenCalled();
diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts
index 2e48164cce..7e2599016c 100644
--- a/src/features/relay/messages.ts
+++ b/src/features/relay/messages.ts
@@ -42,7 +42,10 @@ export function createMessages(
find: (id: string) => EventData | undefined,
emojiTags: (content: string) => string[][],
validateMentions: (channelId: string, pubkeys: readonly string[]) => void,
- canParticipate: (channelId: string) => boolean = () => true,
+ canWrite: (
+ channelId: string,
+ owner?: AgentOwnerAuthorization,
+ ) => boolean = () => true,
relayOrigin?: string,
/** Resolves on relay OK. Reports are never echoed, so they bypass the outbox. */
publishReport?: (template: EventTemplate) => Promise,
@@ -55,9 +58,13 @@ export function createMessages(
/** Events whose `buzz:copy-of` tag names this message id (sent-to-channel copies). */
copiesOf: (id: string) => readonly EventData[] = () => [],
) {
- const writer = (kind: number, channelId: string) => {
- if (!canParticipate(channelId))
- throw new Error("Join the conversation before posting");
+ const writer = (
+ kind: number,
+ channelId: string,
+ owner?: AgentOwnerAuthorization,
+ ) => {
+ if (!canWrite(channelId, owner))
+ throw new Error("You don’t have write access to this conversation.");
if (!outbox?.supports(kind))
throw new Error("This connection cannot publish that operation");
return outbox;
@@ -171,7 +178,7 @@ export function createMessages(
)
throw new Error("Reload the message before editing its attachments.");
const sendEdit = (targetId: string) =>
- writer(40003, channelId).send({
+ writer(40003, channelId, ownerEdit ? authorization : undefined).send({
kind: 40003,
content: text(content),
tags: [
@@ -236,7 +243,7 @@ export function createMessages(
const all = [...ids, ...new Set(linked.map((event) => event.id))];
const removed = all.length <= 100 ? all : ids;
const targets = [...originals, ...(all.length <= 100 ? linked : [])];
- return writer(5, channelId).send({
+ return writer(5, channelId, authorization).send({
kind: 5,
content: "",
tags: [
diff --git a/src/features/relay/owned-agent-edits.test.ts b/src/features/relay/owned-agent-edits.test.ts
index b6fdd63fa0..9004798b25 100644
--- a/src/features/relay/owned-agent-edits.test.ts
+++ b/src/features/relay/owned-agent-edits.test.ts
@@ -108,112 +108,125 @@ it("admits owner edits only after verifying the current signed profile, and revo
}
});
-it("publishes an owned-agent edit through the real session and refuses a foreign agent", async () => {
- const owner = keypair(),
- agent = keypair(),
- stranger = keypair(),
- relay = keypair();
- const wire = scriptedTransport(owner.pubkey, relay.pubkey);
- const published: RelayEvent[] = [];
- let live!: LiveCallbacks;
- const connection = createRelaySession(
- {
- ...wire.transport,
- archiveAuthority: relay.pubkey,
- writer: {
- sign: async (template) => signed(owner, template),
- publish: async (event) => {
- published.push(event);
+it.each(["member", "guest"])(
+ "publishes a %s owner's agent edit through the real session and refuses a foreign agent",
+ async (role) => {
+ const owner = keypair(),
+ agent = keypair(),
+ stranger = keypair(),
+ relay = keypair();
+ const wire = scriptedTransport(owner.pubkey, relay.pubkey);
+ const published: RelayEvent[] = [];
+ let live!: LiveCallbacks;
+ const connection = createRelaySession(
+ {
+ ...wire.transport,
+ archiveAuthority: relay.pubkey,
+ writer: {
+ sign: async (template) => signed(owner, template),
+ publish: async (event) => {
+ published.push(event);
+ },
+ },
+ subscribe(callbacks) {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
},
},
- subscribe(callbacks) {
- live = callbacks;
- return { update() {}, retry() {}, dispose() {} };
- },
- },
- { outboxStorage: { load: () => [], save() {} } },
- );
- const profile = (author: typeof agent, controller: typeof owner) =>
- eventDto(
- signed(author, {
- kind: 0,
- created_at: 10,
- content: "{}",
- tags: [
- [
- "auth",
- controller.pubkey,
- "",
- bytesToHex(
- schnorr.sign(
- createHash("sha256")
- .update(`nostr:agent-auth:${author.pubkey}:`)
- .digest(),
- controller.secret,
+ { outboxStorage: { load: () => [], save() {} } },
+ );
+ const profile = (author: typeof agent, controller: typeof owner) =>
+ eventDto(
+ signed(author, {
+ kind: 0,
+ created_at: 10,
+ content: "{}",
+ tags: [
+ [
+ "auth",
+ controller.pubkey,
+ "",
+ bytesToHex(
+ schnorr.sign(
+ createHash("sha256")
+ .update(`nostr:agent-auth:${author.pubkey}:`)
+ .digest(),
+ controller.secret,
+ ),
),
- ),
+ ],
],
- ],
- }),
- );
- const original = message(agent, "c", "Whole answer", 11);
- const foreign = message(stranger, "c", "Foreign answer", 11);
- try {
- connection.session.channels.ensure("c");
- live.receive([
- roster(relay, "c", [owner.pubkey, agent.pubkey, stranger.pubkey], 1),
- original,
- foreign,
- profile(agent, owner),
- profile(stranger, stranger),
- ]);
- await connection.session.outbox?.ready();
- await expect(
- connection.session.messages.editOwnedAgent(
- foreign.id,
- "Forged",
- foreign.id,
- ),
- ).rejects.toThrow("verified owner");
- expect(published.filter((event) => event.kind === 40003)).toEqual([]);
- const id = await connection.session.messages.editOwnedAgent(
- original.id,
- "Spoken",
- original.id,
- );
- await vi.waitFor(() =>
- expect(published.some((event) => event.id === id)).toBe(true),
- );
- const edit = published.find((event) => event.id === id);
- if (!edit) throw new Error("Missing owner edit");
- expect(edit).toMatchObject({
- kind: 40003,
- pubkey: owner.pubkey,
- content: "Spoken",
- });
- expect(edit.tags).toContainEqual(["e", original.id]);
- live.receive([edit]);
- await vi.waitFor(() =>
- expect(
- connection.session.channels
- .window("c")
- .rows.find((row) => row.id === original.id)?.content,
- ).toBe("Spoken"),
- );
- const removed = await connection.session.messages.editOwnedAgent(
- original.id,
- "",
- original.id,
- );
- await vi.waitFor(() =>
- expect(
- published.some((event) => event.id === removed && event.kind === 5),
- ).toBe(true),
- );
- } finally {
- connection.dispose();
- }
-});
+ }),
+ );
+ const original = message(agent, "c", "Whole answer", 11);
+ const foreign = message(stranger, "c", "Foreign answer", 11);
+ try {
+ connection.session.channels.ensure("c");
+ live.receive([
+ signed(relay, {
+ kind: 39002,
+ created_at: 1,
+ content: "",
+ tags: [
+ ["d", "c"],
+ ["p", owner.pubkey, "", role],
+ ["p", agent.pubkey, "", "bot"],
+ ["p", stranger.pubkey, "", "bot"],
+ ],
+ }),
+ original,
+ foreign,
+ profile(agent, owner),
+ profile(stranger, stranger),
+ ]);
+ await connection.session.outbox?.ready();
+ await expect(
+ connection.session.messages.editOwnedAgent(
+ foreign.id,
+ "Forged",
+ foreign.id,
+ ),
+ ).rejects.toThrow("verified owner");
+ expect(published.filter((event) => event.kind === 40003)).toEqual([]);
+ const id = await connection.session.messages.editOwnedAgent(
+ original.id,
+ "Spoken",
+ original.id,
+ );
+ await vi.waitFor(() =>
+ expect(published.some((event) => event.id === id)).toBe(true),
+ );
+ const edit = published.find((event) => event.id === id);
+ if (!edit) throw new Error("Missing owner edit");
+ expect(edit).toMatchObject({
+ kind: 40003,
+ pubkey: owner.pubkey,
+ content: "Spoken",
+ });
+ expect(edit.tags).toContainEqual(["e", original.id]);
+ live.receive([edit]);
+ await vi.waitFor(() =>
+ expect(
+ connection.session.channels
+ .window("c")
+ .rows.find((row) => row.id === original.id)?.content,
+ ).toBe("Spoken"),
+ );
+ const removed = await connection.session.messages.editOwnedAgent(
+ original.id,
+ "",
+ original.id,
+ );
+ await vi.waitFor(() =>
+ expect(
+ published.some((event) => event.id === removed && event.kind === 5),
+ ).toBe(true),
+ );
+ } finally {
+ connection.dispose();
+ }
+ },
+);
it("lets the original author preserve attachments from a verified owner edit", () => {
const author = keypair(),
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/features/relay/send-to-channel.test.ts b/src/features/relay/send-to-channel.test.ts
index 9b4f18dfa9..2d3b416d18 100644
--- a/src/features/relay/send-to-channel.test.ts
+++ b/src/features/relay/send-to-channel.test.ts
@@ -96,7 +96,7 @@ it("rejects third-party authors, pending messages, mismatched destinations and u
expect(() => messages.sendToChannel(invalid)).toThrow();
}
expect(() => setup([root, reply], false).messages.sendToChannel(row)).toThrow(
- /Join/,
+ /write access/,
);
expect(() => setup([root]).messages.sendToChannel(row)).toThrow(/Load/);
expect(outbox.snapshot()).toHaveLength(0);
diff --git a/src/features/relay/session-attachments.test.ts b/src/features/relay/session-attachments.test.ts
index 86065ac45b..606bb53443 100644
--- a/src/features/relay/session-attachments.test.ts
+++ b/src/features/relay/session-attachments.test.ts
@@ -148,11 +148,11 @@ it("blocks upload and attachment sending after membership loss or session dispos
h.session.attachments.upload(file, "c", new AbortController().signal),
).rejects.toMatchObject({ code: "denied" });
expect(() => h.session.messages.send("c", "", [], [attachment])).toThrow(
- /Join/,
+ /write access/,
);
expect(upload).not.toHaveBeenCalled();
h.dispose();
- expect(() => h.session.messages.send("c", "hello")).toThrow(/Join/);
+ expect(() => h.session.messages.send("c", "hello")).toThrow(/write access/);
});
it("rejects other-community attachment URLs, invalid roots and genuinely empty messages before enqueue", async () => {
diff --git a/src/features/relay/session-permissions.test.ts b/src/features/relay/session-permissions.test.ts
new file mode 100644
index 0000000000..f80ce5ecb2
--- /dev/null
+++ b/src/features/relay/session-permissions.test.ts
@@ -0,0 +1,365 @@
+import { afterEach, assert, expect, it, vi } from "vitest";
+import type { EventTemplate } from "nostr-tools";
+import { createRelaySession } from "./session";
+import { PublishRejected } from "./outbox";
+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[] = [];
+afterEach(() => {
+ for (const owner of owners.splice(0)) owner.dispose();
+});
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+async function setup(
+ initialRole = "member",
+ uploadAttachment?: AttachmentUpload,
+) {
+ const viewer = keypair(),
+ relay = keypair(),
+ peer = keypair(),
+ agent = keypair();
+ let role = initialRole,
+ member = true,
+ joinRole = "guest",
+ time = 1700000000,
+ 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,
+ created_at: time,
+ content: "",
+ tags: [
+ ["d", id],
+ ["name", "Updates"],
+ ["t", "stream"],
+ ["public"],
+ ["join_role", joinRole],
+ ],
+ }),
+ signed(relay, {
+ kind: 39001,
+ created_at: time,
+ content: "",
+ tags: [
+ ["d", id],
+ ["p", peer.pubkey, "owner"],
+ ],
+ }),
+ signed(relay, {
+ kind: 39002,
+ created_at: time,
+ content: "",
+ tags: [
+ ["d", id],
+ ["p", peer.pubkey, "", "owner"],
+ ...(member ? [["p", viewer.pubkey, "", role]] : []),
+ ...(agentJoined ? [["p", agent.pubkey, "", "bot"]] : []),
+ ],
+ }),
+ own,
+ root,
+ canvas,
+ ];
+ const sign = vi.fn(async (event: EventTemplate) => signed(viewer, event));
+ const publish = vi.fn(async (event: RelayEvent) => {
+ if (event.kind === 9022) {
+ 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)
+ ) {
+ agentJoined = false;
+ time++;
+ }
+ });
+ const owner = createRelaySession(
+ {
+ viewer: viewer.pubkey,
+ relayAuthor: relay.pubkey,
+ media: (url) => url,
+ writer: { sign, publish },
+ channelLifecycle: { sign, publish },
+ channelDetails: { sign, publish },
+ workflows: { runs: async () => ({ runs: [], next: null }) },
+ ...(uploadAttachment ? { uploadAttachment } : {}),
+ query: async (filters) =>
+ events().filter((event) =>
+ filters.some((filter) => matchesEvent(event, filter)),
+ ),
+ },
+ { outboxStorage: { load: () => [], save() {} } },
+ );
+ owners.push(owner);
+ owner.session.channels.refreshList?.();
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ await owner.session.read([{ kinds: [9], "#h": [id], limit: 10 }]);
+ await owner.session.outbox?.ready();
+ return {
+ ...owner,
+ sign,
+ publish,
+ own,
+ root,
+ viewer,
+ peer,
+ agent,
+ async role(next: string) {
+ role = next;
+ time++;
+ await owner.session.read([{ kinds: [39002], "#d": [id], limit: 1 }]);
+ },
+ async default(next: string) {
+ joinRole = next;
+ time++;
+ await owner.session.read([{ kinds: [39000], "#d": [id], limit: 1 }]);
+ },
+ };
+}
+
+it("rejects a guest's direct message operations before signing and restores them only after promotion", async () => {
+ const h = await setup("guest");
+ for (const action of [
+ () => h.session.messages.send(id, "Post"),
+ () => h.session.messages.reply(id, h.root.id, "Reply"),
+ () => h.session.messages.react(h.root.id, "👍"),
+ () => h.session.messages.edit(h.own.id, "Edit", h.own.id),
+ () => h.session.messages.remove([h.own.id]),
+ ])
+ expect(action).toThrow();
+ await h.default("member");
+ expect(() => h.session.messages.send(id, "Still guest")).toThrow();
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ await h.role("member");
+ h.session.messages.send(id, "Now member");
+ await vi.waitFor(() => expect(h.publish).toHaveBeenCalledOnce());
+});
+
+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");
+ const outbox = h.session.outbox;
+ assert.exists(outbox);
+ const workflow = kind === 30620 || kind === 46020;
+ const eventId = outbox.send({
+ kind,
+ content:
+ kind === 30620
+ ? "name: Updates\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: send\n action: send_message\n text: Hi\n"
+ : kind === 46020
+ ? ""
+ : "Write",
+ tags: [["h", id], workflow ? ["d", id] : ["e", h.root.id]],
+ });
+ await vi.waitFor(() =>
+ expect(
+ outbox.snapshot().find((entry) => entry.event.id === eventId),
+ ).toMatchObject({
+ delivery: "failed",
+ error: workflow
+ ? "Workflow command rejected; retain the draft and refresh the saved configuration."
+ : "You have read-only access to this channel.",
+ }),
+ );
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ },
+);
+
+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(),
+ gate = deferred();
+ h.sign.mockImplementationOnce(async (template) => {
+ started.resolve();
+ await gate.promise;
+ return signed(h.viewer, template);
+ });
+ const eventId = h.session.messages.send(id, "Queued before demotion");
+ try {
+ await started.promise;
+ await h.role("guest");
+ } finally {
+ gate.resolve();
+ }
+ await vi.waitFor(() =>
+ expect(
+ h.session.outbox?.snapshot().find((entry) => entry.event.id === eventId),
+ ).toMatchObject({
+ delivery: "failed",
+ error: "You have read-only access to this channel.",
+ }),
+ );
+ expect(h.publish).not.toHaveBeenCalled();
+});
+
+it("rechecks a signed retry after demotion without signing or publishing it again", async () => {
+ const h = await setup();
+ h.publish.mockRejectedValueOnce(new PublishRejected("Try later"));
+ const eventId = h.session.messages.send(id, "Signed before demotion");
+ await vi.waitFor(() =>
+ expect(h.session.outbox?.snapshot()[0]?.delivery).toBe("failed"),
+ );
+ await h.role("guest");
+ h.session.messages.retry(eventId);
+ await vi.waitFor(() =>
+ expect(h.session.outbox?.snapshot()[0]).toMatchObject({
+ delivery: "failed",
+ error: "You have read-only access to this channel.",
+ }),
+ );
+ expect(h.sign).toHaveBeenCalledOnce();
+ expect(h.publish).toHaveBeenCalledOnce();
+});
+
+it("retains guest history, details and Leave", async () => {
+ const h = await setup("guest");
+ expect(
+ await h.session.read([{ kinds: [9], "#h": [id], limit: 10 }]),
+ ).toHaveLength(2);
+ expect(await h.session.channelDetails.load(id)).toMatchObject({
+ canEdit: false,
+ });
+ expect(await h.session.channelLifecycle.load(id)).toMatchObject({
+ canLeave: true,
+ canArchive: false,
+ });
+ await h.session.channelLifecycle.run("leave", id);
+ expect(h.publish.mock.calls[0]?.[0].kind).toBe(9022);
+ 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({
+ 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();
+ const result = {
+ url: "https://relay.test/media/notes.txt",
+ name: "notes.txt",
+ size: 4,
+ type: "text/plain",
+ sha256: "a".repeat(64),
+ };
+ const upload = vi.fn(async () => {
+ started.resolve();
+ await gate.promise;
+ return result;
+ });
+ const h = await setup("guest", upload);
+ const attachments = h.session.attachments;
+ assert.exists(attachments);
+ const file = new File(["text"], "notes.txt");
+ await expect(
+ attachments.upload(file, id, new AbortController().signal),
+ ).rejects.toMatchObject({ code: "denied" });
+ expect(upload).not.toHaveBeenCalled();
+ await h.role("member");
+ const pending = attachments.upload(file, id, new AbortController().signal);
+ const rejected = expect(pending).rejects.toMatchObject({ code: "denied" });
+ try {
+ await started.promise;
+ await h.role("guest");
+ } finally {
+ gate.resolve();
+ }
+ await rejected;
+});
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 492b795d69..5b527e05ec 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -106,6 +106,7 @@ import { relayPartition, transportPartition } from "./partition";
import { browserThreadFollows } from "./thread-follows";
import { createMessages } from "./messages";
import { retryRead } from "./read-retry";
+import { messageAuthor } from "./fold";
import { createThreadView } from "./threads";
import { ByteLru } from "./budget";
import { createRelayProfiler } from "./profiling";
@@ -249,6 +250,7 @@ export function createRelaySession(
{
...writer,
async sign(template, signal) {
+ validateChannelWrite(template);
validateMentionEvent(template);
validateCanvasWrite(template);
workflows.validate({
@@ -288,6 +290,7 @@ export function createRelaySession(
return ready;
},
preparePublish: async (event, signal) => {
+ validateChannelWrite(event);
workflows.validate(event);
if (event.kind === 40100) {
const id = event.tags.find((t) => t[0] === "h")?.[1];
@@ -301,6 +304,7 @@ export function createRelaySession(
signal,
);
return () => {
+ validateChannelWrite(event);
workflows.validate(event);
validateCanvasWrite(event);
checkMentions?.();
@@ -1164,7 +1168,7 @@ export function createRelaySession(
.channels.find((item) => item.id === channelId);
if (
closed ||
- !channels.canParticipate(channelId) ||
+ !channels.canWrite(channelId) ||
!channel?.members ||
channel.archived ||
(transport?.subscribe && liveSnapshot.status !== "connected")
@@ -1178,9 +1182,39 @@ export function createRelaySession(
function validateCanvasWrite(event: Pick) {
if (event.kind !== 40100) return;
const id = event.tags.find((t) => t[0] === "h")?.[1];
- if (!id || closed || !channels.canParticipate(id))
+ if (!id || closed || !channels.canWrite(id))
throw new Error("Canvas access changed; your draft is kept");
}
+ /** 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.
+ const targets = event.tags.filter(([key]) => key === "e");
+ if (
+ (event.kind === 40003 || event.kind === 5) &&
+ targets.length &&
+ targets.every(([, target]) => {
+ const original =
+ local().find((item) => item.event.id === target)?.event ??
+ recent.peek(target ?? "")?.event ??
+ retainedEvent(target ?? "");
+ return (
+ original &&
+ [9, 40002].includes(original.kind) &&
+ original.tags.some(([key, value]) => key === "h" && value === id) &&
+ profiles.queries.owner?.(
+ messageAuthor(original, transport?.archiveAuthority),
+ ) === transport?.viewer
+ );
+ })
+ )
+ return;
+ throw new Error("You have read-only access to this channel.");
+ }
function validateMentionEvent(event: Pick) {
if (event.kind !== 9) return;
const recipients = event.tags.filter(([name]) => name === "p");
@@ -1377,7 +1411,8 @@ export function createRelaySession(
viewer: transport?.viewer ?? "",
community: transport?.scope ?? "",
signal: lifetime.signal,
- canWrite: (id) => !closed && channels.canParticipate(id),
+ canWrite: (id) => !closed && channels.canWrite(id),
+ canRead: (id) => !closed && channels.canParticipate(id),
delivered: channelWrites.delivered,
});
const soloPreferences = createSoloPreferences(
@@ -1994,7 +2029,7 @@ export function createRelaySession(
!closed &&
!signal.aborted &&
liveSnapshot.status === "connected" &&
- channels.canParticipate(channelId) &&
+ channels.canWrite(channelId) &&
channels.queries
.list()
.channels.some(
@@ -2118,11 +2153,11 @@ export function createRelaySession(
uploadLifetime.signal,
]);
combined.throwIfAborted();
- if (!channelId || closed || !channels.canParticipate(channelId))
+ if (!channelId || closed || !channels.canWrite(channelId))
throw new UploadError("denied");
const result = await uploadAttachment(file, combined, progress);
combined.throwIfAborted();
- if (!channels.canParticipate(channelId))
+ if (!channels.canWrite(channelId))
throw new UploadError("denied");
return result;
},
@@ -2177,68 +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) => !closed && 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,
@@ -2687,7 +2750,7 @@ export function createRelaySession(
}
const held = (id: string) => {
const summary = channels.queries.get?.(id);
- return !!summary && !summary.readOnly;
+ return !!summary && !summary.cached && channels.canParticipate(id);
};
/** Retire every queued and in-flight exact confirmation. Retirement is
* intentional, so unlike a failed lookup it schedules no fallback; the
@@ -3131,6 +3194,7 @@ export function createRelaySession(
revoked(route) &&
!previous.has(route.id) &&
route.channelId &&
+ !channels.canParticipate(route.channelId) &&
channels.queries.get?.(route.channelId)?.readOnly,
)
.map((route) => route.channelId as string);
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index 5f60983222..9fa88c8783 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -283,6 +283,8 @@ export function createChannelStore(
old.description === channel.description &&
old.metadataPending === pending &&
old.visibility === channel.visibility &&
+ old.joinRole === channel.joinRole &&
+ old.viewerRole === channel.viewerRole &&
old.preview === preview &&
old.hidden === channel.hidden &&
old.private === channel.private &&
@@ -1651,7 +1653,16 @@ export function createChannelStore(
(event.kind === 39002 && hasTag(event, "p", transport.viewer))),
)
)
- resumed = discovery.resume(id) || resumed;
+ resumed =
+ discovery.resume(
+ id,
+ events.find(
+ (event) =>
+ event.kind === 39000 &&
+ event.pubkey === transport.relayAuthor &&
+ tag(event, "d") === id,
+ ),
+ ) || resumed;
}
for (const id of ids) checkedMetadata.add(id);
// A bounded exact roster fills omissions from capped discovery. Apply grants
@@ -2055,7 +2066,10 @@ export function createChannelStore(
/** An open channel's current read-only preview, as name search admits it. */
function previewable(id: string) {
const channel = discovery?.get(id);
- return channel?.readOnly && !channel.archived && !channel.cached
+ return channel?.readOnly &&
+ !discovery?.authorized(id) &&
+ !channel.archived &&
+ !channel.cached
? channel
: undefined;
}
@@ -2986,6 +3000,7 @@ export function createChannelStore(
restore,
canAccess: authorized,
canParticipate: (id: string) => discovery?.canParticipate(id) ?? false,
+ canWrite: (id: string) => discovery?.canWrite(id) ?? false,
purgeAccess,
/** After an access purge's commit, reread names for retained authorized rows.
* One ensure per window, so one window over the directory budget spares the others. */
diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts
index 49c798db9e..984581c43c 100644
--- a/src/features/relay/typing.integration.test.ts
+++ b/src/features/relay/typing.integration.test.ts
@@ -92,46 +92,97 @@ it("publishes empty scoped pulses on the live socket without durable outbox admi
}
});
-it.each(["conversation", "access", "disconnect", "cache", "dispose"])(
- "drops a pulse whose signing crosses %s retirement",
- async (transition) => {
- const h = sender(),
- controller = new AbortController();
- let release!: () => void, started!: () => void;
- const gate = new Promise((resolve) => {
- release = resolve;
- });
- const signing = new Promise((resolve) => {
- started = resolve;
- });
- h.sign.mockImplementationOnce(async (template) => {
- started();
- await gate;
- return signed(h.viewer, template);
- });
- const pending = h.owner.session.typing.send(
+it("keeps incoming activity readable for a guest without publishing their typing", async () => {
+ const h = sender();
+ try {
+ h.live.receive([
+ signed(h.relay, {
+ kind: 39002,
+ created_at: 1700000001,
+ content: "",
+ tags: [
+ ["d", "dm"],
+ ["p", h.viewer.pubkey, "", "guest"],
+ ],
+ }),
+ ]);
+ await h.owner.session.typing.send(
"dm",
undefined,
- controller.signal,
+ new AbortController().signal,
);
- try {
- await signing;
- if (transition === "conversation") controller.abort();
- else if (transition === "access")
- h.live.receive([roster(h.relay, "dm", [], 1_800_000_000)]);
- else if (transition === "disconnect") {
- h.live.state({ status: "retrying", routes: [] });
- h.live.state({ status: "connected", routes: [] });
- } else if (transition === "cache") await h.owner.clearCache();
- else h.owner.dispose();
- } finally {
- release();
- }
- await pending;
+ expect(h.sign).not.toHaveBeenCalled();
expect(h.publish).not.toHaveBeenCalled();
+ h.live.receive([
+ signed(keypair(), {
+ kind: 20002,
+ created_at: Math.floor(Date.now() / 1000),
+ content: "",
+ tags: [["h", "dm"]],
+ }),
+ ]);
+ expect(h.owner.session.typing.snapshot()).toHaveLength(1);
+ } finally {
h.owner.dispose();
- },
-);
+ }
+});
+
+it.each([
+ "conversation",
+ "access",
+ "demotion",
+ "disconnect",
+ "cache",
+ "dispose",
+])("drops a pulse whose signing crosses %s retirement", async (transition) => {
+ const h = sender(),
+ controller = new AbortController();
+ let release!: () => void, started!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const signing = new Promise((resolve) => {
+ started = resolve;
+ });
+ h.sign.mockImplementationOnce(async (template) => {
+ started();
+ await gate;
+ return signed(h.viewer, template);
+ });
+ const pending = h.owner.session.typing.send(
+ "dm",
+ undefined,
+ controller.signal,
+ );
+ try {
+ await signing;
+ if (transition === "conversation") controller.abort();
+ else if (transition === "demotion")
+ h.live.receive([
+ signed(h.relay, {
+ kind: 39002,
+ created_at: 1700000001,
+ content: "",
+ tags: [
+ ["d", "dm"],
+ ["p", h.viewer.pubkey, "", "guest"],
+ ],
+ }),
+ ]);
+ else if (transition === "access")
+ h.live.receive([roster(h.relay, "dm", [], 1_800_000_000)]);
+ else if (transition === "disconnect") {
+ h.live.state({ status: "retrying", routes: [] });
+ h.live.state({ status: "connected", routes: [] });
+ } else if (transition === "cache") await h.owner.clearCache();
+ else h.owner.dispose();
+ } finally {
+ release();
+ }
+ await pending;
+ expect(h.publish).not.toHaveBeenCalled();
+ h.owner.dispose();
+});
it("rejects a signer that changes the requested typing scope", async () => {
const h = sender();
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index 51cc2f7bb4..5707d2f85c 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -886,6 +886,10 @@ extra-small capsule uses `text-caption`. For a caption-sized inline choice such
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 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/components.css b/src/shared/design-system/styles/components.css
index b5d35b2c5a..48c86844e8 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -434,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/styles/forms.css b/src/shared/design-system/styles/forms.css
index f87fc7bd7d..f5858d9933 100644
--- a/src/shared/design-system/styles/forms.css
+++ b/src/shared/design-system/styles/forms.css
@@ -301,6 +301,12 @@
inline-size: var(--space-5);
block-size: var(--space-5);
}
+ .buzz-select[data-appearance="link"] .buzz-button {
+ @apply text-body-sm;
+ align-items: flex-end;
+ 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),
.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 980b2524b3..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" | "glass-ghost";
+ appearance?: "outlined" | "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}
>
& {
+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". */
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 +45,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 65f384f8d6..dec5b52432 100644
--- a/src/shared/design-system/ui/registry.ts
+++ b/src/shared/design-system/ui/registry.ts
@@ -484,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",
],
@@ -923,6 +923,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"button size: xs | sm | md",
"disabled",
"align: start | center | end",
+ "compact link",
"compact glass-ghost (proposed)",
"compact value suffix",
"20px leading accessory: icon, avatar, or custom UI",
diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs
index fba92bae06..75139ce7ab 100644
--- a/tests/browser/channel-header-menu.spec.mjs
+++ b/tests/browser/channel-header-menu.spec.mjs
@@ -175,7 +175,7 @@ test("header actions use the full-size menu and retain pane/dialog focus owners"
path: testInfo.outputPath("metadata-keyboard-focus.png"),
});
};
- for (const label of ["Edit description", "Edit visibility", "View members"]) {
+ for (const label of ["Edit description", "View members"]) {
const action = settings.getByRole("button", { name: label, exact: true });
const row = action.locator("xpath=../..");
await action.scrollIntoViewIfNeeded();
@@ -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/channel-permissions.spec.mjs b/tests/browser/channel-permissions.spec.mjs
new file mode 100644
index 0000000000..9677ab2280
--- /dev/null
+++ b/tests/browser/channel-permissions.spec.mjs
@@ -0,0 +1,241 @@
+import { test, expect } from "./fixture.mjs";
+import { openPage } from "./navigation.mjs";
+import { hoverMessage } from "./message-actions.mjs";
+
+test.use({
+ productionBroker: true,
+ channelLifecycle: true,
+ lifecycleRole: "guest",
+ channelWriteKinds: [9, 9007, 40100],
+ historyCounts: { alpha: 1, beta: 1 },
+});
+
+const readerChannelId = "11111111-1111-4111-8111-111111111111";
+const permalink = (app, messageId) =>
+ `${app.origin}/#buzz=${encodeURIComponent(
+ JSON.stringify({
+ version: 1,
+ kind: "conversation",
+ channelId: readerChannelId,
+ messageId,
+ scope: { viewer: app.viewer, communityOrigin: "https://primary.example" },
+ }),
+ )}`;
+
+async function readPermalink(page, app, messageId, joined = false) {
+ await page.goto(permalink(app, messageId));
+ const thread = page.getByRole("region", {
+ name: "Thread messages",
+ exact: true,
+ });
+ const row = thread.locator(`[data-message-id="${messageId}"]`);
+ await expect(row).toBeVisible();
+ await expect(
+ thread.locator(`[data-message-id="${app.exact.root.id}"]`),
+ ).toBeVisible();
+ await expect(
+ thread.locator(`[data-message-id="${app.exact.replies[1].id}"]`),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("region", { name: "Read-only channel" }).last(),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Join", exact: true }),
+ ).toHaveCount(0);
+ await expect(page.locator('[contenteditable="true"]')).toHaveCount(0);
+ // Opening the actual message menu settles the deferred message actions.
+ await hoverMessage(row);
+ await row
+ .getByRole("button", { name: "More message actions", exact: true })
+ .click();
+ await expect(page.getByRole("menu")).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "React", exact: true }),
+ ).toHaveCount(0);
+ 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");
+}
+
+function expectNotJoined(app) {
+ expect(
+ app
+ .startupCache()
+ .discovery.filter(
+ (event) =>
+ event.kind === 39002 &&
+ event.tags.some(
+ ([key, value]) => key === "d" && value === readerChannelId,
+ ),
+ ),
+ ).toEqual([]);
+ expect(app.report.lifecyclePublications ?? []).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ kind: 9021 })]),
+ );
+ expect(app.report.publications).toEqual([]);
+ expect(app.report.unexpected).toEqual([]);
+}
+
+// Browser-only: cold-link resolution, surrounding thread history, deferred row
+// actions, and a departed guest cross routing, discovery and publication owners.
+test.describe("Guest-default permalinks", () => {
+ test.use({ guestPermalinks: true, exactMessages: "nested" });
+
+ test.describe("nonmember", () => {
+ test.use({ lifecycleRole: "nonmember" });
+ test("old message and nested reply links read without joining", async ({
+ page,
+ app,
+ }) => {
+ expectNotJoined(app);
+ await readPermalink(page, app, app.exact.root.id);
+ await readPermalink(page, app, app.exact.target.id);
+ expectNotJoined(app);
+ });
+ });
+
+ test("a guest leaves and reopens both links as the same read-only preview", async ({
+ page,
+ app,
+ }) => {
+ await readPermalink(page, app, app.exact.target.id, true);
+ await page
+ .getByRole("button", { name: "Channel actions", exact: true })
+ .click();
+ await page
+ .getByRole("menuitem", { name: "Leave channel", exact: true })
+ .click();
+ const dialog = page.getByRole("dialog", {
+ name: "Leave channel: Lifecycle channel",
+ exact: true,
+ });
+ await dialog
+ .getByRole("button", { name: "Leave channel", exact: true })
+ .click();
+ await expect
+ .poll(() => app.report.lifecyclePublications?.map((event) => event.kind))
+ .toEqual([9022]);
+ await expect(dialog).toHaveCount(0);
+ await expect(
+ page.getByRole("dialog", { name: "Leaving channel…", exact: true }),
+ ).toHaveCount(0);
+ await readPermalink(page, app, app.exact.root.id);
+ await readPermalink(page, app, app.exact.target.id);
+ expectNotJoined(app);
+ });
+});
+
+// The real broker -> signed roster -> sidebar -> conversation wiring and
+// keyboard menu handoff are browser boundaries; role matrices stay in Vitest.
+test("a joined guest can read and leave without receiving a composer or Join prompt", async ({
+ page,
+ app,
+}) => {
+ await page.goto(app.origin);
+ await openPage(page, "Messages");
+ const sidebar = page.getByRole("navigation", { name: "Subscribed channels" });
+ const row = sidebar.getByRole("button", {
+ name: "Lifecycle channel",
+ exact: true,
+ });
+ await row.click();
+ await expect(
+ page.getByRole("region", { name: "Read-only channel" }),
+ ).toHaveText("Read-only access");
+ await expect(
+ page.getByRole("textbox", {
+ name: "Message #Lifecycle channel",
+ exact: true,
+ }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Join", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("region", { name: "Channel history" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Start the conversation below.", { exact: true }),
+ ).toHaveCount(0);
+ await page
+ .getByRole("button", { name: "Channel actions", exact: true })
+ .click();
+ await page
+ .getByRole("menuitem", { name: "Leave channel", exact: true })
+ .click();
+ const dialog = page.getByRole("dialog");
+ await expect(
+ dialog.getByRole("button", { name: "Cancel", exact: true }),
+ ).toBeFocused();
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Channel actions", exact: true }),
+ ).toBeFocused();
+ await page
+ .getByRole("button", { name: "Manage members", exact: true })
+ .click();
+ const members = page.getByRole("dialog", {
+ name: "Channel access",
+ exact: true,
+ });
+ await expect(members.getByRole("searchbox")).toHaveAttribute(
+ "placeholder",
+ "Search members",
+ );
+ 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([]);
+});
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`),
diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs
index 7bd0497f6e..cba613eef1 100644
--- a/tests/browser/fixture.mjs
+++ b/tests/browser/fixture.mjs
@@ -60,6 +60,7 @@ export const test = base.extend({
sortingSidebar: [false, { option: true }],
initialSidebarSort: [{}, { option: true }],
channelLifecycle: [false, { option: true }],
+ guestPermalinks: [false, { option: true }],
lifecycleRole: ["owner", { option: true }],
lifecycleOwnerAgent: [false, { option: true }],
lifecycleVisibility: [{ archived: [], hidden: [] }, { option: true }],
@@ -128,6 +129,7 @@ export const test = base.extend({
sortingSidebar,
initialSidebarSort,
channelLifecycle,
+ guestPermalinks,
lifecycleRole,
lifecycleOwnerAgent,
lifecycleVisibility,
@@ -321,6 +323,7 @@ export const test = base.extend({
},
]
: [];
+ const guestChannelId = guestPermalinks ? lifecycleRows[0].id : undefined;
const archivedIds = new Set(lifecycleVisibility.archived);
const hiddenDmIds = new Set(lifecycleVisibility.hidden);
let lifecycleTime = 1700000001;
@@ -329,7 +332,11 @@ export const test = base.extend({
...channels,
...dmIds,
...sortingIds,
- ...lifecycleRows.map((row) => row.id),
+ ...lifecycleRows
+ .filter(
+ (row) => row.id !== guestChannelId || lifecycleRole !== "nonmember",
+ )
+ .map((row) => row.id),
]),
];
if (savedSidebar) {
@@ -523,6 +530,20 @@ export const test = base.extend({
),
]);
const targetEvents = [];
+ // Enough newer roots to put the permalink outside the initial history page.
+ if (guestChannelId)
+ histories.set(
+ `primary/${guestChannelId}`,
+ Array.from({ length: 103 }, (_, i) =>
+ sign(
+ 9,
+ [["h", guestChannelId]],
+ `Guest room history ${i}`,
+ peerKey,
+ 1700000000 + i,
+ ),
+ ),
+ );
let searchTarget;
if (openSearch) {
const root = sign(
@@ -547,12 +568,13 @@ export const test = base.extend({
}
let exact;
if (exactMessages) {
- const root = histories.get("primary/alpha")[2];
- const replies = Array.from({ length: 80 }, (_, i) =>
+ const exactChannelId = guestChannelId ?? "alpha";
+ const root = histories.get(`primary/${exactChannelId}`)[2];
+ const replies = Array.from({ length: guestChannelId ? 3 : 80 }, (_, i) =>
sign(
9,
[
- ["h", "alpha"],
+ ["h", exactChannelId],
["e", root.id, "", "reply"],
["p", getPublicKey(peerKey)],
],
@@ -566,7 +588,7 @@ export const test = base.extend({
replies[replies.length - 1] = sign(
9,
[
- ["h", "alpha"],
+ ["h", exactChannelId],
["e", root.id, "", "root"],
["e", replies.at(-2).id, "", "reply"],
["p", getPublicKey(peerKey)],
@@ -1088,7 +1110,9 @@ export const test = base.extend({
viewer,
"",
lifecycleRows.some((row) => row.id === id)
- ? lifecycleRole
+ ? lifecycleRole === "nonmember"
+ ? "member"
+ : lifecycleRole
: "member",
],
...(ownSoloChannels.includes(id)
@@ -1131,7 +1155,11 @@ export const test = base.extend({
);
if (filter.kinds?.includes(39000))
return [
- ...new Set([...rosterIds, ...(openSearch ? [OPEN_CHANNEL] : [])]),
+ ...new Set([
+ ...rosterIds,
+ ...(openSearch ? [OPEN_CHANNEL] : []),
+ ...(guestChannelId ? [guestChannelId] : []),
+ ]),
]
.filter((id) => !filter["#d"] || filter["#d"].includes(id))
.map(
@@ -1159,6 +1187,7 @@ export const test = base.extend({
lifecycleRows.find((row) => row.id === id)?.type ??
(dmIds.includes(id) ? "dm" : "stream"),
],
+ ...(id === guestChannelId ? [["join_role", "guest"]] : []),
...(archivedIds.has(id) ? [["archived", "true"]] : []),
// Ordinary channels are explicitly public; do not add a public
// flag to private Solo chats or change the separate DM fixtures.
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
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"],
diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx
index 115d3df55b..6ae9919653 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";
@@ -154,6 +157,37 @@ export function ButtonSpecimen() {
Continue
+
+
+ Add as
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+ Add
+
+
+