diff --git a/docs/channels.md b/docs/channels.md index cc62118723..8c551265dd 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -637,14 +637,16 @@ left-to-right fade on hover or keyboard focus (always visible on touch, no anima 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 has a small pencil after its label, -revealed with a short left-to-right fade on whole-row hover or keyboard focus +revealed with a short left-to-right fade on row hover or keyboard focus (always visible on touch, no animation for reduced motion) without shifting layout; -the whole row opens the shared Dialog with one Name/Description/Duration draft. +only the label, a 24px target, opens the shared Dialog with one Name/Description/Duration draft. +Every viewer sees the description rendered as message Markdown: its URLs, Buzz links +and `#channel` references open their targets, so its value is not part of the Edit target. Opening Description focuses its textarea; the title focuses Name. -Only authorized editors get the interactive rows; other viewers retain plain metadata. +Only authorized editors get the edit actions; other viewers see the same linked description. 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 -copies the exact ID. Only a successful copy opens a confirmation tooltip, without +small inline copy icon revealed on whole-row hover or keyboard focus (always visible +on touch, no animation for reduced motion); selecting its row copies the exact ID. Only a successful copy opens a confirmation tooltip, without growing the row; hovering or focusing does not open a hint or replay old feedback. 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 diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx index c154a9adb5..ac19e251d1 100644 --- a/src/bundled/channels/ChannelDetailsEditor.test.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx @@ -18,6 +18,7 @@ import type { DetailsAttempt, } from "../../features/relay/channel-details"; import type { ChannelDetails } from "../../features/relay/channel-details-protocol"; +import { createRelaySession } from "../../features/relay/session"; afterEach(() => { cleanup(); @@ -438,6 +439,79 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a await user.keyboard("{Escape}"); expect(close).toHaveBeenCalledOnce(); }); +it.each([ + ["an editor", true], + ["a member", false], +])( + "links the description for %s without the editor taking its clicks", + async (_, canEdit) => { + const h = harness(); + h.load.mockResolvedValue({ ...base, canEdit }); + const user = userEvent.setup(); + const owned = createRelaySession(null); + const ops = { id: "ops", name: "ops", channelType: "stream" as const }; + const session = { + ...owned.session, + channels: { + ...owned.session.channels, + list: () => ({ status: "ready" as const, channels: [channel, ops] }), + }, + }; + const open = vi.fn(() => true); + const panel = (description: string | undefined) => ( + {}} + > + Diagnostics + + ); + const { rerender } = render( + panel("R2 runbook: https://example.com/r2-runbook · see #ops"), + ); + await waitFor(() => + expect( + screen.getByRole("complementary", { name: "Channel settings" }), + ).toHaveAttribute("aria-busy", "false"), + ); + const edit = screen.queryByRole("button", { name: "Edit description" }); + expect(!!edit).toBe(canEdit); + const description = screen.getAllByRole("definition")[0]; + if (!description) throw new Error("Missing description"); + await user.click( + within(description).getByRole("link", { + name: "https://example.com/r2-runbook", + }), + ); + expect(open).toHaveBeenLastCalledWith("https://example.com/r2-runbook"); + await user.click(within(description).getByRole("link", { name: "#ops" })); + expect(open).toHaveBeenLastCalledWith("buzz://channel/ops"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + if (edit) { + // The rendered value follows; its Markdown source would read twice. + expect(edit).not.toHaveAccessibleDescription(); + await user.click(edit); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Description" }), + ).toHaveFocus(), + ); + await user.keyboard("{Escape}"); + } + for (const [value, shown] of [ + ["", "No description"], + [undefined, "Not available"], + ] as const) { + rerender(panel(value)); + expect(screen.getAllByRole("definition")[0]).toHaveTextContent(shown); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + } + }, +); it("only enables Save for normalized, changed, valid fields with connected errors", async () => { const h = harness(); const user = userEvent.setup(); diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index 8ce971f2dd..a8c7dd9d87 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -1,7 +1,15 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { createPortal } from "react-dom"; import { afterEach, assert, expect, it, vi } from "vitest"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; import type { ChannelSummary } from "../../features/relay/contracts"; @@ -78,6 +86,27 @@ it("closes with Escape or its close button without swallowing other keys", async expect(close).toHaveBeenCalledTimes(3); }); +it("leaves Escape to descendants that handle it and to portalled layers", () => { + const close = vi.fn(); + render( + + + {createPortal(, document.body)} + , + ); + fireEvent.keyDown(screen.getByText("Handled"), { key: "Escape" }); + fireEvent.keyDown(screen.getByText("Portalled"), { key: "Escape" }); + expect(close).not.toHaveBeenCalled(); + fireEvent.keyDown(screen.getByText("Diagnostics"), { key: "Escape" }); + expect(close).toHaveBeenCalledOnce(); +}); + it("does not invent unknown metadata and still exposes diagnostics without a channel", async () => { const user = userEvent.setup(); const { rerender } = render( diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index dc9bb1b048..a0af3f5f9e 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -17,6 +17,9 @@ import { import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; import { managesCommunity } from "../../features/communities/roster"; import type { CommunityMembership } from "../moderation/membership"; +import type { RelaySession } from "../../features/relay/session"; +import { MessageMarkdown } from "../../features/messages/MessageMarkdown"; +import { useReferenceDirectory } from "../../features/messages/ReferenceText"; import { channelIcon } from "../../features/channels/channel-icon"; import { GearIcon, @@ -48,6 +51,9 @@ export function ChannelSettingsPanel({ lifecycle, membership, openMembers, + session, + canOpenLink, + onOpenLink, }: { channel: ChannelSummary | undefined; scope: string; @@ -59,6 +65,10 @@ export function ChannelSettingsPanel({ lifecycle?: ChannelLifecycleCapability | undefined; membership?: CommunityMembership | undefined; openMembers?(trigger: HTMLButtonElement): void; + /** Links the description like a message; without these it stays plain text. */ + session?: RelaySession | undefined; + canOpenLink?(target: string): boolean; + onOpenLink?(url: string): boolean; }) { const tabbed = !!usePanelTabHost(); const header = useRef(null); @@ -86,7 +96,12 @@ export function ChannelSettingsPanel({ aria-label="Channel settings" aria-busy={loading} onKeyDown={(event) => { - if (event.key === "Escape") { + // Portalled menus and cards bubble here through React but own their Escape. + if ( + event.key === "Escape" && + !event.defaultPrevented && + event.currentTarget.contains(event.target as Node) + ) { event.stopPropagation(); close(); } @@ -175,7 +190,22 @@ export function ChannelSettingsPanel({ ? "Not available" : channel.description || "No description" } + content={ + channel.description && + session && + onOpenLink && ( + + ) + } valueClassName={styles.settingsDescription} + labelOnly actionLabel="Edit" loading={edit?.pending} onOpen={ @@ -336,10 +366,53 @@ function AutoJoinSetting({ ); } +/** Only a linked description reads the reference directory, so its snapshots + * re-render this value rather than the whole panel. */ +function ChannelDescription({ + channelId, + content, + session, + scope, + onOpenLink, + canOpenLink, +}: { + channelId: string; + content: string; + session: RelaySession; + scope: string; + onOpenLink(url: string): boolean; + canOpenLink?: ((target: string) => boolean) | undefined; +}) { + const directory = useReferenceDirectory(session); + return ( + + ); +} + /** A native button's hit area covers the row without nesting controls or losing list semantics. */ function SettingsDetail({ label, value, + content, valueClassName, feedback, actionLabel, @@ -349,10 +422,14 @@ function SettingsDetail({ hasPopup = "dialog", disabled = false, loading = false, + labelOnly = false, renderAction = (action) => action, }: { label: string; + /** Plain value; also describes a whole-row action. */ value: string; + /** Rendered in place of `value` when present. */ + content?: ReactNode; valueClassName?: string | undefined; feedback?: ReactNode; actionLabel?: string | undefined; @@ -361,6 +438,8 @@ function SettingsDetail({ hasPopup?: "dialog" | false; disabled?: boolean; loading?: boolean | undefined; + /** Only the label opens the action, so the value's own links stay usable. */ + labelOnly?: boolean; renderAction?(action: ReactElement>): ReactNode; onOpen?: ((trigger: HTMLButtonElement) => void) | undefined; }) { @@ -371,13 +450,17 @@ function SettingsDetail({ ? renderAction(