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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ that agent in Buzz; they do not notify the external app through this connection.
it searches only once that word has at least three characters, even with
`from:` or `in:`. A shorter last word shows how many characters to type, not
"no results".
- **Join** in a View only chat or channel adds you as a viewer, with a sidebar
entry and normal unread tracking. Notifications follow your existing settings
and mention/thread rules. You appear in People as **Viewing only**; you still
cannot post or react. Reading without joining stays available.
Use **Leave chat** or **Leave channel** to remove your membership and sidebar
entry. Joined viewers keep reading after a switch to Private until they leave
or are removed in People.
- **Long text pastes** that exceed the message limit show an error and leave
your draft unchanged. Shorten the text, or save it as a text file and use
**Attach files** where uploads are available.
Expand Down
13 changes: 9 additions & 4 deletions docs/solo/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,17 @@ With View only, visitors can find the chat by name in Search Buzz or open its
ordinary chat link. They read without joining and cannot send messages, react or wake agents.
The relay's ordinary Guest/default-Guest write policy enforces this boundary;
the absent composer is only its client presentation. View-only access grants no
membership and adds no sidebar entry for a visitor. The owner keeps the same
Solo label, conversation ID and private workspace filing.
membership and adds no sidebar entry for a visitor. **Join** adds a visitor as a
Guest, listed in People as **Viewing only**.
The chat then appears under Solo/Chats with normal unread tracking; notifications
follow existing settings and mention/thread rules. It stays read-only. **Leave
chat** removes membership and the sidebar entry; an open chat can still be read
without joining. The owner keeps the same Solo label, conversation ID and private
workspace filing.

Changing back to Private stops nonmembers from reading; it does not remove
members or change their roles. Guests who joined through the CLI, MCP or another
client while the chat was View only keep read access until removed in People.
members or change their roles. Guests who joined while the chat was View only
keep read access until they leave or are removed in People.
Reading without joining creates no membership to retain. Neither transition
changes invitations, creates a share message or adds notifications. Unconfirmed
publication uses **Check save status**, without automatically resending.
Expand Down
67 changes: 63 additions & 4 deletions src/bundled/channels/ChannelsPage.solo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,16 @@ function fixture({
others = [],
unread,
capability = "frontier-sync",
viewOnly = false,
preview,
}: {
/** Observed unread messages in this chat; fakes the read state when given. */
unread?: number;
/** What the read state can do on this connection. */
capability?: "unsupported" | "read-only" | "frontier-sync";
viewOnly?: boolean;
/** An unjoined reader of an open chat, including legacy admission defaults. */
preview?: { joinRole?: "member" | "guest" };
/** Other Solo chats listed after this one in the sidebar's Chats. */
others?: readonly string[];
/** The viewer's own profile has loaded too. */
Expand Down Expand Up @@ -120,9 +125,15 @@ function fixture({
id,
name: "Planning",
channelType: type,
private: true as const,
...(!preview && { private: true as const }),
creator: ownedBy ?? (placed ? viewer.pubkey : agent),
members: [viewer.pubkey, agent, ...people],
...(viewOnly && { readOnly: true as const, viewerRole: "guest" as const }),
...(preview && {
members: [agent, ...people],
readOnly: true,
...preview,
}),
...(archived && { archived }),
};
const neighbors = others.map((other, index) => ({
Expand Down Expand Up @@ -226,10 +237,10 @@ function fixture({
load: vi.fn(async () => ({
channelId: id,
channelType: "stream" as const,
canArchive: true,
canArchive: !viewOnly,
canUnarchive: false,
canDelete: true,
canLeave: false,
canDelete: !viewOnly,
canLeave: viewOnly,
canHide: false,
})),
run,
Expand Down Expand Up @@ -1015,6 +1026,54 @@ it("marks an archived chat once and keeps only its lifecycle actions", async ()
expect(screen.queryByRole("textbox")).toBeNull();
});

it.each([
{ preview: { joinRole: "guest" as const }, joinable: true },
{ preview: { joinRole: "member" as const }, joinable: false },
{ preview: {}, joinable: false },
])(
"offers Solo preview Join only for Guest admission ($preview)",
async ({ preview, joinable }) => {
fixture({ placed: false, preview }).mount();
expect(
await screen.findByRole("region", {
name:
"joinRole" in preview && preview.joinRole === "guest"
? "Read-only chat"
: "Chat preview",
}),
).toBeVisible();
expect(screen.queryByRole("textbox")).toBeNull();
expect(!!screen.queryByRole("button", { name: "Join" })).toBe(joinable);
},
);

it("lets a joined Solo viewer cancel Leave and restores focus to Chat actions", async () => {
const f = fixture({ placed: false, viewOnly: true });
f.mountWithSidebar();
const row = await screen.findByRole("button", { name: /^Planning/ });
expect(row).toBeVisible();
expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
const user = userEvent.setup();
const actions = screen.getByRole("button", { name: "Chat actions" });
await user.click(actions);
const leave = await screen.findByRole("menuitem", { name: "Leave chat" });
expect(screen.queryByRole("menuitem", { name: "Archive chat" })).toBeNull();
Comment thread
tellaho marked this conversation as resolved.
Resolved
expect(screen.queryByRole("menuitem", { name: "Delete chat" })).toBeNull();
await user.click(leave);
const dialog = await screen.findByRole("dialog", {
name: "Leave chat: Planning",
});
await waitFor(() =>
expect(
within(dialog).getByRole("button", { name: "Cancel" }),
).toHaveFocus(),
);
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(actions).toHaveFocus());
expect(f.run).not.toHaveBeenCalled();
});

it.each(["solo", "stream"] as const)(
"replaces an archived %s composer with a notice and restores its draft on unarchive",
async (type) => {
Expand Down
4 changes: 3 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1886,7 +1886,9 @@ function ChannelWorkspace({
joinable={
!current.archived &&
(current.channelType === "stream" ||
current.channelType === "forum")
current.channelType === "forum" ||
(current.channelType === "solo" &&
current.joinRole === "guest"))
}
onJoin={() => setJoiningChannel(current.id)}
onHeightChange={setJoinNoticeHeight}
Expand Down
22 changes: 12 additions & 10 deletions src/bundled/channels/SoloChatMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,18 @@ export function SoloChatMenuItems({
{conversionLabel(session, channel.id)}
</MenuItem>
)}
{onLifecycle && !channel.readOnly && !channel.cached && (
<ChannelLifecycleMenu
channelId={channel.id}
lifecycle={session.channelLifecycle}
disabled={!!handoff?.lifecycleDialog}
separator
noun="chat"
choose={onLifecycle}
/>
)}
{onLifecycle &&
(!channel.readOnly || channel.viewerRole === "guest") &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P3 (optional, UX): Guests see two disabled items next to Leave. Now that joined Guests can open Chat actions, they see "Chat settings" (always disabled for readOnly, line 113) and "Share access" (disabled) next to Leave. It's safe, since both stay guarded, but two permanently disabled items make Leave harder to find. Consider hiding them when viewerRole === "guest".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Deferring this optional menu cleanup from the copy follow-up. The existing read-only guards keep these actions disabled; hiding the entries would be a separate menu presentation change. Leaving this thread open rather than claiming the cleanup is done.

!channel.cached && (
<ChannelLifecycleMenu
channelId={channel.id}
lifecycle={session.channelLifecycle}
disabled={!!handoff?.lifecycleDialog}
separator
noun="chat"
choose={onLifecycle}
/>
)}
</>
);
}
Expand Down
4 changes: 3 additions & 1 deletion src/bundled/channels/SoloHeading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export function SoloToolbar({
const owner = useSoloOwner(session, channel);
const [readError, setReadError] = useState<string>();
const preferences = session.soloPreferences;
const canOpenActions =
!channel.cached && (!channel.readOnly || channel.viewerRole === "guest");
return (
<>
{region && (
Expand Down Expand Up @@ -132,7 +134,7 @@ export function SoloToolbar({
</>
)}
{/* Archived chats keep their lifecycle actions, including Unarchive. */}
{!channel.readOnly && !channel.cached && (
{canOpenActions && (
<MenuRoot key={regions.binding?.incarnation ?? "recovery"}>
<MenuTrigger
render={
Expand Down
106 changes: 59 additions & 47 deletions src/features/messages/ChannelJoinNotice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ import { ChannelJoinNotice } from "./ChannelJoinNotice";

afterEach(cleanup);

it.each([false, true])(
"keeps Guest-default previews read-only with stored Guest membership %s",
(guest) => {
it.each([
{ guest: false, place: "channel" as const },
{ guest: true, place: "channel" as const },
{ guest: false, place: "chat" as const },
{ guest: true, place: "chat" as const },
])(
"offers a $place preview Join only before Guest membership ($guest)",
({ guest, place }) => {
const run = vi.fn();
const started = vi.fn();
render(
Expand All @@ -27,14 +32,17 @@ it.each([false, true])(
lifecycle={lifecycle(run)}
joinable
guest={guest}
place={place}
joinRole="guest"
onJoin={started}
/>,
);
expect(
screen.getByRole("region", { name: "Read-only channel" }),
).toHaveTextContent(/^This channel is read-only$/);
expect(screen.queryByRole("button", { name: "Join" })).toBeNull();
screen.getByRole("region", { name: `Read-only ${place}` }),
).toHaveTextContent(
`This ${place} is read-only${guest ? "" : ". Join to keep it in your sidebar and appear as a viewer."}`,
);
expect(!!screen.queryByRole("button", { name: "Join" })).toBe(!guest);
expect(run).not.toHaveBeenCalled();
expect(started).not.toHaveBeenCalled();
},
Expand Down Expand Up @@ -100,47 +108,51 @@ it("offers Join only for a joinable channel on a connection that can send it", (
expect(run).not.toHaveBeenCalled();
});

it("joins once, reports its start, and keeps a failed join retryable", async () => {
let settle: { resolve(): void; reject(error: unknown): void } | undefined;
const run = vi.fn(
() =>
new Promise<void>((resolve, reject) => {
settle = { resolve, reject };
}),
);
const started = vi.fn();
render(
<ChannelJoinNotice
channelId="open"
lifecycle={lifecycle(run)}
joinable
onJoin={started}
/>,
);
const button = screen.getByRole("button", { name: "Join" });
fireEvent.click(button);
fireEvent.click(button);
expect(run).toHaveBeenCalledOnce();
// The page owns focus handoff; membership may arrive before the run settles.
expect(started).toHaveBeenCalledOnce();
expect(run).toHaveBeenCalledWith("join", "open", expect.any(AbortSignal));
expect(button).toHaveAttribute("aria-disabled", "true");
await act(async () =>
settle?.reject(new ChannelLifecycleUnconfirmed(new Error("timeout"))),
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Join may have taken effect, but it is not confirmed yet. Try again to check.",
);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
expect(run).toHaveBeenCalledTimes(2);
expect(started).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).toBeNull();
await act(async () => settle?.resolve());
expect(screen.getByRole("button", { name: "Join" })).not.toHaveAttribute(
"aria-disabled",
"true",
);
});
it.each(["member", "guest"] as const)(
"joins once with %s admission and keeps a failed join retryable",
async (joinRole) => {
let settle: { resolve(): void; reject(error: unknown): void } | undefined;
const run = vi.fn(
() =>
new Promise<void>((resolve, reject) => {
settle = { resolve, reject };
}),
);
const started = vi.fn();
render(
<ChannelJoinNotice
channelId="open"
lifecycle={lifecycle(run)}
joinable
joinRole={joinRole}
onJoin={started}
/>,
);
const button = screen.getByRole("button", { name: "Join" });
fireEvent.click(button);
fireEvent.click(button);
expect(run).toHaveBeenCalledOnce();
// The page owns focus handoff; membership may arrive before the run settles.
expect(started).toHaveBeenCalledOnce();
expect(run).toHaveBeenCalledWith("join", "open", expect.any(AbortSignal));
expect(button).toHaveAttribute("aria-disabled", "true");
await act(async () =>
settle?.reject(new ChannelLifecycleUnconfirmed(new Error("timeout"))),
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Join may have taken effect, but it is not confirmed yet. Try again to check.",
);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
expect(run).toHaveBeenCalledTimes(2);
expect(started).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).toBeNull();
await act(async () => settle?.resolve());
expect(screen.getByRole("button", { name: "Join" })).not.toHaveAttribute(
"aria-disabled",
"true",
);
},
);

it("waits for content once per channel and stays visible during refresh", () => {
const props = {
Expand Down
18 changes: 10 additions & 8 deletions src/features/messages/ChannelJoinNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Messages.module.css";

/** Archived and read-only channels share the public preview's lower notice.
* Member-default public previews offer Join; confirmed membership enables composition.
* Public previews offer Join; the admission role determines whether joining enables composition.
* 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({
Expand Down Expand Up @@ -72,22 +72,24 @@ export function ChannelJoinNotice({
? "Join may have taken effect, but it is not confirmed yet. Try again to check."
: reason instanceof Error
? reason.message
: "Couldn’t join this channel.",
: `Couldn’t join this ${place}.`,
});
},
);
};
const readOnly = guest || joinRole === "guest";
const canJoin = !archived && !readOnly && joinable && !!lifecycle?.available;
const canJoin = !archived && !guest && joinable && !!lifecycle?.available;
return (
<section
ref={notice}
aria-label={
archived
? `Archived ${place}`
: readOnly
? "Read-only channel"
: "Channel preview"
? `Read-only ${place}`
: place === "chat"
? "Chat preview"
: "Channel preview"
}
aria-hidden={!visible}
inert={!visible}
Expand All @@ -103,11 +105,11 @@ export function ChannelJoinNotice({
? `This ${place} is archived`
: readOnly
? joinRole === "guest"
? `This ${place} is read-only`
? `This ${place} is read-only${canJoin ? ". Join to keep it in your sidebar and appear as a viewer." : ""}`
: "Read-only access"
: canJoin
Comment thread
tellaho marked this conversation as resolved.
Resolved
? "Join channel to send messages"
: "You’re previewing this channel"}
? `Join ${place} to send messages`
: `You’re previewing this ${place}`}
</p>
{canJoin && (
<Button
Expand Down
Loading
Loading