diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 5e274e9180..cb902958cd 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -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. diff --git a/docs/solo/sharing.md b/docs/solo/sharing.md index da0f6725e9..795c5e3996 100644 --- a/docs/solo/sharing.md +++ b/docs/solo/sharing.md @@ -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. diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index 805de0cc19..e0ae4b3697 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -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. */ @@ -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) => ({ @@ -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, @@ -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(); + 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) => { diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 5e29c8a32c..e3ebd2603b 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -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} diff --git a/src/bundled/channels/SoloChatMenu.tsx b/src/bundled/channels/SoloChatMenu.tsx index 5274caa566..d001769abc 100644 --- a/src/bundled/channels/SoloChatMenu.tsx +++ b/src/bundled/channels/SoloChatMenu.tsx @@ -169,16 +169,18 @@ export function SoloChatMenuItems({ {conversionLabel(session, channel.id)} )} - {onLifecycle && !channel.readOnly && !channel.cached && ( - - )} + {onLifecycle && + (!channel.readOnly || channel.viewerRole === "guest") && + !channel.cached && ( + + )} ); } diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index 4501240354..0114d0e0e1 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -104,6 +104,8 @@ export function SoloToolbar({ const owner = useSoloOwner(session, channel); const [readError, setReadError] = useState(); const preferences = session.soloPreferences; + const canOpenActions = + !channel.cached && (!channel.readOnly || channel.viewerRole === "guest"); return ( <> {region && ( @@ -132,7 +134,7 @@ export function SoloToolbar({ )} {/* Archived chats keep their lifecycle actions, including Unarchive. */} - {!channel.readOnly && !channel.cached && ( + {canOpenActions && ( { +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( @@ -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(); }, @@ -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((resolve, reject) => { - settle = { resolve, reject }; - }), - ); - const started = vi.fn(); - render( - , - ); - 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((resolve, reject) => { + settle = { resolve, reject }; + }), + ); + const started = vi.fn(); + render( + , + ); + 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 = { diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx index 203e9e3912..45a578a9a6 100644 --- a/src/features/messages/ChannelJoinNotice.tsx +++ b/src/features/messages/ChannelJoinNotice.tsx @@ -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({ @@ -72,13 +72,13 @@ 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 (
{canJoin && (