diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 444087eca7..2c2d1f4d74 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -258,7 +258,8 @@ revokes all of them, and a pairing unused for 30 days expires. conversation entries, even across long gaps or midnight. Large or mixed groups keep one name per action, such as “Pinky and 182 others were added.” Hover or activate “182 others” to see - the full, scrollable list of names. Messages and huddle entries split groups. + the full, scrollable list of names; choose a name to open that person's + profile. Messages and huddle entries split groups. - **Solo** organizes your chats with agents. Choose **New chat**, select or tag an agent, and send a message. Solo conversations continue at the chat's top diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index b9aa9626f3..aa6e2ecef7 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -433,11 +433,14 @@ export function ChannelMembersButton({ owner, label = "Access", conversationName, + trigger: hostTrigger, }: { session: RelaySession; channelId: string; label?: string; conversationName?: string; + /** The button's ref, for a host that returns focus to it. */ + trigger?: RefObject | undefined; /** Someone else's Solo chat names its owner on the Access control. */ owner?: { name: string; picture?: string | undefined } | undefined; agents?: Agents | undefined; @@ -451,7 +454,8 @@ export function ChannelMembersButton({ const [localOpen, setLocalOpen] = useState(false); const open = presentation ? presentation.open : localOpen; const setOpen = presentation ? presentation.onOpenChange : setLocalOpen; - const trigger = useRef(null); + const ownTrigger = useRef(null); + const trigger = hostTrigger ?? ownTrigger; const access = useHeaderChannel(session.channels, channelId, memberAccess); return ( <> diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index cdab5674a5..c32dd9555d 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -39,6 +39,7 @@ import { import { SoloWorkspaces } from "./SoloWorkspaces"; import type { RelaySession } from "../../features/relay/session"; import type { ReadTarget } from "../../features/relay/read-state-model"; +import type { RegisteredPanel } from "../../features/panels/service"; vi.mock( "../../features/messages/solo-model-picks-storage", @@ -393,6 +394,17 @@ function fixture({ } as unknown as Navigation; const archive: ArchiveShortcut = {}; const read: ReadShortcut = {}; + const profilePanel = { + id: "profile", + key: "fixture:profile", + pluginId: "fixture", + revision: "1", + title: "Profile", + matches: (target: string) => target.startsWith("nostr:npub"), + component: () =>

Profile content

, + } as RegisteredPanel; + // A stable snapshot, as useSyncExternalStore requires. + const available = [profilePanel]; const page = (sidebar = false) => ( {sidebar && ( @@ -410,9 +422,11 @@ function fixture({ available, + subscribe: () => () => {}, register() {}, - resolve: () => undefined, + resolve: (target) => + profilePanel.matches(target) ? profilePanel : undefined, requestOpen: () => false, handleOpenRequests: () => () => {}, }} @@ -1371,6 +1385,92 @@ it.each([ expect(screen.queryByText(/’s chat/)).toBeNull(); }); +// This fixture's visit never changes, so the header never remounts here as it +// does for each visit in the app. solo-workspace.spec.mjs covers focus +// returning to the opener when the profile closes. +it("opens a profile from the header's Share access", async () => { + const person = "e".repeat(64); + const f = fixture({ people: [person] }); + // The dialog lists members once their names settle. + Object.assign(f.session.profiles, { ensure: async () => {} }); + f.mount(); + const user = userEvent.setup(); + await user.click( + await screen.findByRole("button", { name: "Share access to Planning" }), + ); + const dialog = await screen.findByRole("dialog", { name: /Share access/ }); + await user.click( + await within(dialog).findByRole("button", { + name: /^Open profile for Teammate/, + }), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(await screen.findByText("Profile content")).toBeVisible(); +}); + +it("offers the team's member actions from Chat actions → Share access", async () => { + const person = "e".repeat(64); + const dm = "22222222-2222-4222-8222-222222222222"; + const f = fixture({ people: [person], viewerNamed: true }); + const openDm = vi.fn(async () => dm); + Object.assign(f.session.profiles, { ensure: async () => {} }); + Object.assign(f.session, { + directMessages: { + ...f.session.directMessages, + available: true, + open: openDm, + }, + }); + f.mount(); + const user = userEvent.setup(); + const trigger = await screen.findByRole("button", { name: "Chat actions" }); + const share = async () => { + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Share access" }), + ); + return within(await screen.findByRole("dialog", { name: /Share access/ })); + }; + const actions = async (dialog: ReturnType, name: string) => { + await user.click( + await dialog.findByRole("button", { name: `Actions for ${name}` }), + ); + return within(await screen.findByRole("menu")); + }; + let dialog = await share(); + // An agent and the viewer get no Send message, as in team channels. + for (const name of ["Scout", "Viewer"]) { + const menu = await actions(dialog, name); + expect( + menu.getByRole("menuitem", { name: "View profile" }), + ).not.toHaveAttribute("aria-disabled", "true"); + expect(menu.queryByRole("menuitem", { name: "Send message" })).toBeNull(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + } + await user.click( + (await actions(dialog, "Teammate")).getByRole("menuitem", { + name: "Send message", + }), + ); + expect(openDm).toHaveBeenCalledExactlyOnceWith( + [person], + expect.any(AbortSignal), + ); + await waitFor(() => + expect(f.open).toHaveBeenLastCalledWith(conversationTarget(f.scope, dm)), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + dialog = await share(); + await user.click( + (await actions(dialog, "Teammate")).getByRole("menuitem", { + name: "View profile", + }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(await screen.findByText("Profile content")).toBeVisible(); +}); + it("marks an archived chat once and keeps only its lifecycle actions", async () => { fixture({ archived: true }).mount(); await screen.findByRole("button", { name: "Chat actions" }); diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 7cc490691b..b042059745 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -186,7 +186,16 @@ async function page( matches: (target) => target === "https://app.example/", component: appComponent ?? (() =>

App content

), }; - const available = [panel, app]; + const profile: RegisteredPanel = { + id: "profile", + key: "fixture:profile", + pluginId: "fixture", + revision: "1", + title: "Profile", + matches: (target) => target.startsWith("nostr:npub"), + component: () =>

Profile content

, + }; + const available = [panel, app, profile]; let handleOpen: ((request: PanelOpenRequest) => boolean) | undefined; const panels: Panels = { snapshot: () => available, @@ -937,6 +946,39 @@ it("gives a Solo chat's side tab its own header actions, opening it in the main expect(screen.queryByRole("button", { name: "Chat actions" })).toBeNull(); }); +// This fixture's profile isn't a history panel and its visit never changes, so +// closing it has no dismissal's return. solo-workspace.spec.mjs covers focus +// returning to the side tab's opener when the profile closes. +it.each(["Share access", "Chat actions"])( + "opens a profile from a Solo side tab's %s", + async (opener) => { + const snapshot = { entry: { id: "visit" }, attempt: {} }; + const h = await page("stream", { + open: vi.fn(async () => ({ status: "opened" as const })), + snapshot: () => snapshot, + } as unknown as Navigation); + const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); + act(() => + tabs.result.current.setTabs((items) => [ + ...items, + { id: "conversation:gamma", kind: "conversation", channelId: "gamma" }, + ]), + ); + await h.user.click(await screen.findByRole("tab", { name: "Gamma" })); + await h.user.click(await screen.findByRole("button", { name: opener })); + if (opener === "Chat actions") + await h.user.click( + await screen.findByRole("menuitem", { name: "Share access" }), + ); + const dialog = await screen.findByRole("dialog", { name: /Share access/ }); + await h.user.click( + await within(dialog).findByRole("button", { name: /^Open profile for/ }), + ); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(await screen.findByText("Profile content")).toBeVisible(); + }, +); + it("keeps a side tab's failed move recoverable after switching tabs", async () => { const base = { status: "ready", diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e8b83ebf5f..f89facf30c 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -41,7 +41,12 @@ import { WorkspaceControl } from "../../features/solo/WorkspaceControl"; import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import type { CommunityMembership } from "../moderation/membership"; import { useFirstChannel } from "./first-channel"; -import { SoloHeading, SoloToolbar, useSoloMoveRecovery } from "./SoloHeading"; +import { + SoloHeading, + SoloToolbar, + useSoloMoveRecovery, + type SoloOpeners, +} from "./SoloHeading"; import { NewMessage } from "../../features/direct-messages/NewMessage"; import { ForwardMessageProvider } from "../../features/messages/ForwardMessage"; import { Panel } from "../../shared/design-system/ui/Panel"; @@ -326,6 +331,27 @@ function LiveNewMessage( return ; } +/** The Solo header control that opened a panel, with the side tab whose + * toolbar held it, or none for the main header. */ +type SoloOpenerControl = { + control: keyof SoloOpeners; + tab: string | undefined; +}; + +/** Solo headers' registered openers, by pane. */ +type SoloHeaders = { + main?: SoloOpeners | undefined; + side?: SoloOpeners | undefined; +}; + +/** The registered Solo header control that is `trigger`, if any. */ +function soloControl(headers: SoloHeaders, trigger: HTMLElement) { + for (const pane of ["main", "side"] as const) + for (const control of ["share", "actions"] as const) + if (headers[pane]?.[control].current === trigger) + return { pane, control }; +} + function ChannelWorkspace({ extensions, chatMenus, @@ -1090,6 +1116,7 @@ function ChannelWorkspace({ target.detail.rootId === entry.rootId ) return; + const from = navigator.snapshot().entry.id; tabState.leaveVisit(); void navigator.open({ version: 1, @@ -1101,6 +1128,11 @@ function ChannelWorkspace({ ...(entry.rootId ? { rootId: entry.rootId } : {}), }, }); + // A Solo opener's record adopts any visit pushed from one of its visits + // in the same chat, such as an agent's owner opened from its profile. + const opener = soloOpener.current; + if (target.channelId === entry.channelId && opener?.visits.includes(from)) + opener.visits.push(navigator.snapshot().entry.id); }, [navigator, tabState.leaveVisit], ); @@ -1146,66 +1178,110 @@ function ChannelWorkspace({ : {}), }; }; - const keptTabReturn = useRef<{ + const dismissalReturn = useRef<{ from: string; target: string; - id: string; - pane: "main" | "side"; - /** The side pane's visibility to restore: a kept tab shows, and a - * launcher's side panel keeps the visibility it had before the close. */ - paneOpen: boolean; + kept: + | { + id: string; + pane: "main" | "side"; + /** The side pane's visibility to restore: a kept tab shows, and a + * launcher's side panel keeps the visibility it had before the + * close. */ + paneOpen: boolean; + } + | undefined; + opener: SoloOpenerControl | undefined; }>(undefined); const dismissContent = ( visits: readonly string[], fallback: OpenTarget, pane: "main" | "side", entries = tabState.entries, + opener?: SoloOpenerControl, ) => { - keptTabReturn.current = undefined; + dismissalReturn.current = undefined; if (!navigator) return; const keep = pane === "main" ? tabState.getMainSelected() : tabState.getSelected(); const keptTab = tabState.tabs.find((tab) => tab.id === keep); const keptEntry = entries.find((entry) => panelTabId(entry) === keep); const current = navigator.snapshot().entry; - if ( + // A later close can retire more visits before the return lands, moving + // where it lands. It takes that return even when it retires no visit of + // its own, as for a profile queued behind it. + const pending = + pendingReturn.current?.from === current.id + ? pendingReturn.current + : undefined; + const returns = !!pending || visits.includes(current.id); + const kept = keep !== undefined && (keptTab || keptEntry || keep === "settings" || (pane === "side" && tabState.sidePanel)) && - visits.includes(current.id) && keptTab?.kind !== "thread" && !(keptEntry && historyPanel(keptEntry)) - ) { - // A local survivor has no routed address. Preserve it only for this - // dismissal's matching return, never for unrelated later navigation. - keptTabReturn.current = { + ? { + id: keep, + pane, + paneOpen: + !!(keptTab || keptEntry || keep === "settings") || + tabState.paneOpen, + } + : undefined; + // A local survivor has no routed address, and a Solo opener's header + // remounts. Restore them only on this dismissal's matching return, never + // for unrelated later navigation. + if ((kept || opener) && returns) + dismissalReturn.current = { from: current.id, target: targetKey(fallback), - id: keep, - pane, - paneOpen: - !!(keptTab || keptEntry || keep === "settings") || tabState.paneOpen, + kept, + opener, }; - } navigator.dismiss(visits, fallback); - // A later close can retire more visits before the return lands, moving - // where it lands. - const pending = - pendingReturn.current?.from === current.id - ? pendingReturn.current - : undefined; - if ( - (pending || visits.includes(current.id)) && - navigator.snapshot().entry.id === current.id - ) + if (returns && navigator.snapshot().entry.id === current.id) pendingReturn.current = { ...pending, from: current.id, target: targetKey(fallback), }; }; + // The main Solo header remounts for each visit, ending that visit's dialogs + // and menus, and a side tab's toolbar unmounts while a panel covers its + // chat. Either way the opener is gone by the dismissal's return, so the same + // control in that chat's header takes focus back then. Only the record's + // visits do: Back can restore the same panel from another opener. + const soloOpener = useRef< + SoloOpenerControl & { trigger: HTMLElement; visits: string[] } + >(undefined); + // Solo headers register their openers by pane. The dock shows only its + // selected tab's toolbar. + const soloHeaders = useRef({}); + const registerSoloOpeners = useMemo(() => { + const register = (pane: "main" | "side") => (openers: SoloOpeners) => { + soloHeaders.current[pane] = openers; + return () => { + if (soloHeaders.current[pane] === openers) + soloHeaders.current[pane] = undefined; + }; + }; + return { main: register("main"), side: register("side") }; + }, []); + // Member lists open profiles and DMs through this page. Side-tab Solo + // toolbars render inside useConversationTabs, before canOpenLink and + // openLink are declared, so these read them only when called. + const memberLinks = { + canOpenLink: (target: string) => canOpenLink(target), + onOpenLink: (url: string, returnFocus?: HTMLElement) => + openLink(url, { returnFocus }), + onOpenConversation: + navigator && viewer + ? (id: string) => openLink(`buzz://channel/${encodeURIComponent(id)}`) + : undefined, + }; const secondary = useConversationTabs({ state: tabState, relay, @@ -1227,6 +1303,8 @@ function ChannelWorkspace({ channel={channel} navigator={navigator} signal={navigation?.signal} + {...memberLinks} + registerOpeners={registerSoloOpeners.side} onOpen={open} onLifecycle={(action, trigger) => handoff?.openLifecycle(channel, action, trigger, navigation?.signal) @@ -1282,13 +1360,25 @@ function ChannelWorkspace({ if (!historyPanel(entry)) return; const pane = entryPane(tabState.mainEntries, entry); const fallback = closeTarget(pane, remaining); - if (fallback) - dismissContent( - tabState.detailVisits(entry.channelId, entry.target), - fallback, - pane, - remaining, - ); + if (!fallback || !navigator) return; + const visits = tabState.detailVisits(entry.channelId, entry.target); + // The recorded opener takes focus back only from one of its visits, and + // only while it is still that panel's trigger. A main header opener + // that reopened the panel already showing is still mounted, but the + // return remounts its header. The dismissal retires this panel's + // visits; once none of its own remain, it is forgotten. + const opener = soloOpener.current; + const returning = + opener?.visits.includes(navigator.snapshot().entry.id) && + opener.trigger === panelTrigger.current && + (opener.tab === undefined || !opener.trigger.isConnected) + ? { control: opener.control, tab: opener.tab } + : undefined; + if (opener) { + opener.visits = opener.visits.filter((id) => !visits.includes(id)); + if (!opener.visits.length) soloOpener.current = undefined; + } + dismissContent(visits, fallback, pane, remaining, returning); }, onCloseThread: (tab, active, last) => { // X is explicit dismissal; Back/Forward never comes through this path. @@ -1448,7 +1538,8 @@ function ChannelWorkspace({ navigation.target.kind === "conversation" ? navigation.target.detail : undefined; - const kept = keptTabReturn.current; + const pending = dismissalReturn.current; + const kept = pending?.kept; const retained = kept && (kept.pane === "main" @@ -1489,16 +1580,47 @@ function ChannelWorkspace({ else navigation.complete({ status: "failed", reason: "unavailable" }); } // Reselect it over the restored selection and give it its own visit. - if (reopen) open(reopen); - if (kept && navigation.entryId !== kept.from) { - keptTabReturn.current = undefined; - if (retained && targetKey(navigation.target) === kept.target) { - if (kept.pane === "main") tabState.selectMain(kept.id); - else { - tabState.select(kept.id); - tabState.setPaneOpen(kept.paneOpen); + // A Solo opener that queued it recorded the retiring visit, so it takes + // the fresh one instead. + if (reopen && returned) { + open(reopen); + const opener = soloOpener.current; + if (opener?.visits.includes(returned.from)) { + const fresh = navigator.snapshot().entry.id; + opener.visits = opener.visits.map((visit) => + visit === returned.from ? fresh : visit, + ); + } + } + if (pending && navigation.entryId !== pending.from) { + dismissalReturn.current = undefined; + if (targetKey(navigation.target) === pending.target) { + if (kept && retained) { + if (kept.pane === "main") tabState.selectMain(kept.id); + else { + tabState.select(kept.id); + tabState.setPaneOpen(kept.paneOpen); + } } - focusKept.current = kept.id; + // The opener's control in its remounted header takes focus, but + // only while focus waits where the close left it: on the chat once + // the pane emptied, or on the chat's tab that its pane selected + // instead. A main header opener that was still mounted took focus + // itself, and this commit's header remount removed it. + const { opener } = pending; + const focused = document.activeElement; + const waiting = opener?.tab + ? focused instanceof HTMLElement && + focused.dataset.tabValue === opener.tab + : focused === historyContainer.current || focused === document.body; + const control = + opener && + waiting && + soloHeaders.current[opener.tab ? "side" : "main"]?.[opener.control] + .current; + if (control) control.focus({ preventScroll: true }); + // Otherwise the kept tab, where the close was, takes focus back. + else if (kept && retained) focusKept.current = kept.id; } } if (navigation.focus === "preserve") setPreservedVisit(navigation); @@ -1561,6 +1683,7 @@ function ChannelWorkspace({ currentId, panels, panelTrigger, + navigator, ]); const mounted = useRef(false); const channel = useRef(current?.id); @@ -1800,15 +1923,33 @@ function ChannelWorkspace({ const next = channelId && panelOpening(panels, channelId, url, rootId); if (next) { setSettings(undefined); - panelTrigger.current = + const trigger = returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); + panelTrigger.current = trigger; + // Find a Solo header's opener while its header has it registered. + const header = trigger && soloControl(soloHeaders.current, trigger); + const tab = + header?.pane === "side" ? tabState.getSelected() : undefined; // Thread-origin details cover the live thread instead of retiring it. if (!fromThread && routedThread && !historyPanel(next)) select(channelId); open(next, fromThread); + // Record the visit now showing the panel. Reopening the panel already + // shown pushes none, so the opener takes over the current visit. A + // reopen queued behind a close's return holds the retiring visit until + // the return gives the panel its own. + soloOpener.current = + trigger && header && navigator && historyPanel(next) + ? { + trigger, + control: header.control, + tab, + visits: [navigator.snapshot().entry.id], + } + : undefined; return true; } return false; @@ -1827,6 +1968,7 @@ function ChannelWorkspace({ // recreate openLink and re-render each memoized message row. tabState.setTabs, tabState.select, + tabState.getSelected, tabState.leaveVisit, panelTrigger, ], @@ -2159,14 +2301,8 @@ function ChannelWorkspace({ ? membersTrigger : membersHeaderTrigger } - canOpenLink={canOpenLink} {...(agents ? { agents } : {})} - onOpenLink={(url, returnFocus) => openLink(url, { returnFocus })} - onOpenConversation={ - navigator && viewer - ? (id) => openLink(`buzz://channel/${encodeURIComponent(id)}`) - : undefined - } + {...memberLinks} /> )} setConversion({ channelId: current.id, diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index 06f2b447d7..06571a2316 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -5,9 +5,11 @@ import { soloNavigationFor } from "../../features/channel-navigation/solo-naviga import { useCallback, useEffect, + useLayoutEffect, useRef, useState, useSyncExternalStore, + type ComponentProps, type ReactNode, type RefObject, } from "react"; @@ -43,6 +45,12 @@ import { useSoloOwner } from "./ChatSharedMark"; const noSubscription = () => () => {}; +/** A Solo header's dialog openers, which its page returns focus to. */ +export type SoloOpeners = { + share: RefObject; + actions: RefObject; +}; + type SoloToolbarProps = { session: RelaySession; chatMenus?: Conversation["chatMenus"] | undefined; @@ -61,11 +69,17 @@ type SoloToolbarProps = { onLifecycle(action: ChannelLifecycleAction, trigger?: HTMLElement): void; /** Reports a move started here; see `useSoloMoveRecovery`. */ onMove(move: Promise): void; -}; + /** Registers this header's openers while it is mounted. */ + registerOpeners?: ((openers: SoloOpeners) => () => void) | undefined; +} & Pick< + ComponentProps, + "canOpenLink" | "onOpenLink" | "onOpenConversation" +>; /** The header of a conversation shown with the Solo treatment. */ export function SoloHeading(props: SoloToolbarProps) { const owner = useSoloOwner(props.session, props.channel); + const shareTrigger = useRef(null); return props.channel.archived ? ( @@ -81,9 +95,13 @@ export function SoloHeading(props: SoloToolbarProps) { owner={owner} navigator={props.navigator} signal={props.signal} + trigger={shareTrigger} + canOpenLink={props.canOpenLink} + onOpenLink={props.onOpenLink} + onOpenConversation={props.onOpenConversation} /> } - actions={} + actions={} /> ); @@ -103,8 +121,15 @@ export function SoloToolbar({ onConvert, onLifecycle, onMove, - membershipInHeader = false, -}: SoloToolbarProps & { membershipInHeader?: boolean }) { + canOpenLink, + onOpenLink, + onOpenConversation, + registerOpeners, + membershipInHeader, +}: SoloToolbarProps & { + /** The header's own Share access control, which replaces the toolbar's. */ + membershipInHeader?: RefObject; +}) { const menus = useSyncExternalStore( chatMenus?.subscribe ?? noSubscription, () => chatMenus?.snapshot(), @@ -113,7 +138,15 @@ export function SoloToolbar({ const region = regions.regions.find((region) => region.channels.some((item) => item.id === channel.id), ); + const ownShareTrigger = useRef(null); + const shareTrigger = membershipInHeader ?? ownShareTrigger; const actionsTrigger = useRef(null); + // A layout effect, so a remounted header registers before its page's + // layout effects run in the same commit. + useLayoutEffect( + () => registerOpeners?.({ share: shareTrigger, actions: actionsTrigger }), + [registerOpeners, shareTrigger], + ); // Each dialog returns focus to the control that opened it. const [settingsOpen, setSettingsOpen] = useState<{ visit: AbortSignal | undefined; @@ -150,6 +183,10 @@ export function SoloToolbar({ owner={owner} navigator={navigator} signal={signal} + trigger={shareTrigger} + canOpenLink={canOpenLink} + onOpenLink={onOpenLink} + onOpenConversation={onOpenConversation} /> )} {children} @@ -227,6 +264,9 @@ export function SoloToolbar({ signal={sharing.visit} trigger={actionsTrigger} close={() => setSharing(undefined)} + canOpenLink={canOpenLink} + onOpenLink={onOpenLink} + onOpenConversation={onOpenConversation} /> )} {creatingWorkspace && ( diff --git a/src/features/messages/MembershipRow.tsx b/src/features/messages/MembershipRow.tsx index 8d1cce696b..089ae32341 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -1,6 +1,7 @@ import type { IdentityNameView } from "../identity-names/service"; import { useIdentityNames } from "../identity-names/react"; import { + PopoverClose, PopoverRoot, PopoverTrigger, PopoverPopup, @@ -8,7 +9,7 @@ import { } from "../../shared/design-system/ui/Popover"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import referenceStyles from "../../shared/InlineReference.module.css"; -import { Fragment, memo } from "react"; +import { Fragment, memo, useRef, type RefObject } from "react"; import { profileTarget } from "../profiles/target"; import type { Profile } from "../relay/contracts"; import { membershipDescription, type TimelineRow } from "./membership-rows"; @@ -16,6 +17,114 @@ import { DayDivider } from "./MessageTimestamp"; import styles from "./Messages.module.css"; import { avatarMedia } from "../../shared/avatar-source"; +type ProfileLinks = { + viewer?: string | undefined; + agentPubkeys?: ReadonlySet | undefined; + onOpenLink?: ((url: string) => boolean) | undefined; + canOpenLink?: ((target: string) => boolean) | undefined; +}; + +/** A member's name as a button that opens their profile, or undefined where + * the host cannot open it. A system notice names people as quiet text, not + * mention chips. No profile target, so copies stay plain prose. */ +function profileName( + { viewer, agentPubkeys, onOpenLink, canOpenLink }: ProfileLinks, + id: string | undefined, + text: string, + /** Where the profile returns focus; the name itself unless given. */ + origin?: RefObject, +) { + const agent = !!id && !!agentPubkeys?.has(id); + const target = id && profileTarget(id, { agent }); + if (!target || !canOpenLink?.(target)) return undefined; + return ( + + ); +} + +/** "N others": every named member, each opening their profile. */ +function MembershipPreview({ + text, + label, + members, + links, +}: { + text: string; + label: string; + members: readonly { id: string; name: string }[]; + links: ProfileLinks; +}) { + // A chosen name closes with the list, so its profile returns focus here. + const trigger = useRef(null); + return ( + + + {text} + + } + /> + + + {label} ({members.length}) + +
+ +
+
+
+ ); +} + +/** The listed names. The portal mounts this only while the list is open, so a + * large burst resolves each member's profile target only when someone looks. */ +function MembershipPeople({ + members, + links, + trigger, +}: { + members: readonly { id: string; name: string }[]; + links: ProfileLinks; + trigger: RefObject; +}) { + return ( +
    + {members.map((member) => { + const name = profileName(links, member.id, member.name, trigger); + return ( +
  • + {name ? : member.name} +
  • + ); + })} +
+ ); +} + /** Quiet, non-conversational activity. Avatars are decorative; prose owns the * label, and each named member opens their profile like a prose mention. */ export const MembershipRow = memo(function MembershipRow({ @@ -33,14 +142,10 @@ export const MembershipRow = memo(function MembershipRow({ row: TimelineRow; names?: IdentityNameView | undefined; profiles: ReadonlyMap; - viewer?: string | undefined; media(url: string, size?: "small"): string | undefined; - agentPubkeys?: ReadonlySet | undefined; day: boolean; resolveName?: (pubkey: string, fallback: string) => string; - onOpenLink?: ((url: string) => boolean) | undefined; - canOpenLink?: ((target: string) => boolean) | undefined; -}) { +} & ProfileLinks) { const globalName = useIdentityNames(names); const resolveName = scopedName ?? globalName; const { targets, parts, title } = membershipDescription( @@ -49,6 +154,7 @@ export const MembershipRow = memo(function MembershipRow({ viewer, resolveName, ); + const links = { viewer, agentPubkeys, onOpenLink, canOpenLink }; return (
{day && } @@ -85,67 +191,19 @@ export const MembershipRow = memo(function MembershipRow({

{parts.map((part, index) => { const key = `${index}:${part.id ?? ""}:${part.text}`; - if (part.preview) { - const { label, members } = part.preview; + if (part.preview) return ( - - - {part.text} - - } - /> - - - {label} ({members.length}) - -

-
    - {members.map((member) => ( -
  • {member.name}
  • - ))} -
-
- - + ); - } - const agent = !!part.id && !!agentPubkeys?.has(part.id); - const target = part.id && profileTarget(part.id, { agent }); - if (!target || !canOpenLink?.(target)) - return {part.text}; - // A system notice names people as quiet text, not mention chips. - // No profile target, so copies stay plain prose. return ( - + + {profileName(links, part.id, part.text) ?? part.text} + ); })}

diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 69dd8eed33..143e452d35 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1999,17 +1999,21 @@ html[data-keyboard-navigation] .text pre code:focus-visible { min-width: 0; overflow-wrap: anywhere; } -/* A quiet log line: member names keep the caption color at rest, carry - medium weight as their only resting cue, and take the inline-reference - color while hovered or keyboard-focused. The shared chip fill and ring stay. */ -.membership .membershipName { - --link-text: var(--text-subtle); +/* A quiet log line: member names rest in their surrounding text color (the + row's caption, the list's body), keep the link underline, add medium weight, + and take the inline-reference color while hovered or keyboard-focused. The + shared chip fill and ring stay. The "N others" list renders in a portal, + outside the row, so it is named too. */ +:is(.membership, .membershipPeople) .membershipName { + --link-text: currentColor; /* Restated over the channel host's button reset. */ color: var(--link-text); font-weight: var(--type-weight-medium); } -.membership .membershipName:hover, -html[data-keyboard-navigation] .membership .membershipName:focus-visible { +:is(.membership, .membershipPeople) .membershipName:hover, +html[data-keyboard-navigation] + :is(.membership, .membershipPeople) + .membershipName:focus-visible { --link-text: var(--text-link); } .membershipPeople { @@ -2017,8 +2021,15 @@ html[data-keyboard-navigation] .membership .membershipName:focus-visible { overflow-y: auto; overscroll-behavior: contain; margin-top: var(--space-2); + /* Inline room in the scrollport for each name's hover overhang. */ + padding-inline: var(--space-half); + margin-inline: calc(-1 * var(--space-half)); @apply text-body-sm text-standard; } +/* The scrollport would clip an offset ring, so the names' ring is inset. */ +html[data-keyboard-navigation] .membershipPeople .membershipName:focus-visible { + outline-offset: -2px; +} .membershipPeople ul { list-style: none; margin: 0; diff --git a/src/features/messages/membership-rows.test.tsx b/src/features/messages/membership-rows.test.tsx index b74661c82f..231efe24d0 100644 --- a/src/features/messages/membership-rows.test.tsx +++ b/src/features/messages/membership-rows.test.tsx @@ -2,7 +2,15 @@ import { assert, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { profileTarget } from "../profiles/target"; import type { ChannelMessage, MembershipChange } from "../relay/contracts"; import { @@ -374,6 +382,66 @@ it("member names open the subject's and the actor's profiles when the host can", expect(document.querySelector("[data-profile-target]")).toBeNull(); }); +it("listed members open profiles and return focus to the overflow control", async () => { + const unnamed = "d".repeat(64); + const group = membershipRows( + [pinky, brain, wes, unnamed].map((id, i) => + row(String(i), joined("adder", id)), + ), + )[0]; + assert.exists(group); + // The host returns focus to whatever held it when the profile opened. + const origins: (Element | null)[] = []; + const open = vi.fn((_target: string) => { + origins.push(document.activeElement); + return true; + }); + const canOpenLink = vi.fn( + (target: string) => target !== profileTarget(unnamed), + ); + render( + undefined} + viewer={wes} + day={false} + onOpenLink={open} + canOpenLink={canOpenLink} + />, + ); + // A closed list asks the host about its lead name only, not every member. + expect(canOpenLink.mock.calls).toEqual([[profileTarget(pinky)]]); + const user = userEvent.setup(); + const others = screen.getByRole("button", { name: "3 others" }); + const list = async () => { + await user.click(others); + return within( + await screen.findByRole("region", { name: "People who were added" }), + ); + }; + let people = await list(); + expect( + people + .getAllByRole("button") + .map((entry) => entry.getAttribute("aria-label")), + ).toEqual(["View Pinky profile", "View Brain profile", "View your profile"]); + // A member the host cannot open stays plain text. + expect(people.getByText("dddddddddd").closest("button")).toBeNull(); + await user.click(people.getByRole("button", { name: "View Brain profile" })); + expect(open).toHaveBeenLastCalledWith(profileTarget(brain, { agent: true })); + expect(origins.at(-1)).toBe(others); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + people = await list(); + people.getByRole("button", { name: "View Pinky profile" }).focus(); + await user.keyboard("{Enter}"); + expect(open).toHaveBeenLastCalledWith(profileTarget(pinky)); + expect(origins.at(-1)).toBe(others); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(open).toHaveBeenCalledTimes(2); +}); + it("omits only the owner's starting agents once history reaches the start", () => { const added = (id: string, actor: string, target: string) => row(id, { type: "member_joined", actor, target }); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 1ec0cbbc53..f24b10bedb 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2744,8 +2744,24 @@ export const test = base.extend({ return { event, plaintext, agent }; }, // Answer later kind-0 reads for this key; no live delivery is modeled. - serveProfile(key, body) { - const event = sign(0, [], JSON.stringify(body), key); + // An owner key adds its signed NIP-OA attestation. + serveProfile(key, body, owner) { + const digest = + owner && + createHash("sha256") + .update(`nostr:agent-auth:${getPublicKey(key)}:`) + .digest(); + const tags = digest + ? [ + [ + "auth", + getPublicKey(owner), + "", + bytesToHex(schnorr.sign(new Uint8Array(digest), owner)), + ], + ] + : []; + const event = sign(0, tags, JSON.stringify(body), key); servedProfiles.set(event.pubkey, event); return event; }, diff --git a/tests/browser/membership.spec.mjs b/tests/browser/membership.spec.mjs index 5cca30ae25..c7a0e889f8 100644 --- a/tests/browser/membership.spec.mjs +++ b/tests/browser/membership.spec.mjs @@ -339,8 +339,14 @@ burstTest( exact: true, }); await expect(people.getByRole("listitem")).toHaveCount(602); - await expect(people.getByRole("listitem").first()).toHaveText("Pinky"); - await expect(people.getByRole("listitem").nth(1)).toHaveText("Brain"); + // Each listed member opens their profile, like the row's lead name. + await expect(people.getByRole("button")).toHaveCount(602); + await expect(people.getByRole("button").first()).toHaveAccessibleName( + "View Pinky profile", + ); + await expect(people.getByRole("button").nth(1)).toHaveAccessibleName( + "View Brain profile", + ); await people.hover(); await page.screenshot({ path: testInfo.outputPath("membership-people.png"), @@ -354,6 +360,33 @@ burstTest( await others.press("Enter"); await expect(popup).toBeVisible(); await expect(people).toBeFocused(); + // Names rest in the list's own color; Tab reaches the first one, whose + // ring and fill fit inside the scrollport instead of being clipped. + const first = people.getByRole("button").first(); + await expect(first).toHaveCSS( + "color", + await people.evaluate((list) => getComputedStyle(list).color), + ); + await page.keyboard.press("Tab"); + await expect(first).toBeFocused(); + await expect(first).toHaveCSS("outline-style", "solid"); + const clipped = await first.evaluate((entry) => { + const list = entry.closest("section"); + const port = list.getBoundingClientRect(); + const box = entry.getBoundingClientRect(); + const style = getComputedStyle(entry); + const ring = + parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + return Math.max( + port.left - (box.left - ring), + port.top - (box.top - ring), + box.right + ring - (port.left + list.clientWidth), + ); + }); + expect(clipped).toBeLessThanOrEqual(0); + await popup.screenshot({ + path: testInfo.outputPath("membership-people-focus.png"), + }); await people.press("End"); await expect(people.getByRole("listitem").last()).toBeInViewport(); await page.keyboard.press("Escape"); @@ -384,6 +417,22 @@ burstTest( }); await page.keyboard.press("Escape"); await expect(popup).toHaveCount(0); + // Choosing a member closes the list; their profile returns focus to it. + await page.mouse.move(0, 0); + await others.hover(); + await expect(popup).toBeVisible(); + await people.getByRole("button", { name: "View Pinky profile" }).click(); + await expect(popup).toHaveCount(0); + const profile = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + await expect(profile).toContainText("Pinky"); + await page + .getByRole("button", { name: "Close Pinky tab", exact: true }) + .click(); + await expect(profile).toHaveCount(0); + await expect(others).toBeFocused(); await page.screenshot({ path: testInfo.outputPath("join-burst.png") }); await testInfo.attach("join-burst", { diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index b45017e64a..a13a767349 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -1,6 +1,11 @@ import { pauseClock } from "./clock.mjs"; import { pluginAction } from "./plugin-settings.mjs"; -import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + verifyEvent, +} from "nostr-tools"; import { test, expect } from "./fixture.mjs"; import { closeSettings, @@ -233,6 +238,567 @@ test("Solo chat Activity opens the agent's profile locally, restores per convers await closeSettings(page); await expect(trigger).toHaveCount(0); }); +// Browser-only: opening and closing the profile are real navigation visits. +// The main Solo header remounts for each one, and focus returns to the opener +// on the close's return visit. A static jsdom visit has neither. +test.describe("Share access profiles", () => { + const teammate = generateSecretKey(); + // An agent Sam manages, so its profile links to Sam's. + const scout = generateSecretKey(); + test.use({ + sessionMembers: { + [id]: [getPublicKey(teammate), getPublicKey(scout)], + [other]: [getPublicKey(teammate)], + }, + }); + test("closing a profile opened from Share access returns focus to its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry.id); + // The header button, a name chosen by keyboard and Escape on the tab. + await share.click(); + const visit = await entry(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).not.toBe(visit); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); + // Chat actions, a name chosen by pointer and the tab's Close button. + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + await name.click(); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await closeProfile(page).click(); + await expect(tab).toHaveCount(0); + await expect(actions).toBeFocused(); + // Choosing the profile already showing pushes no visit, so the opener + // stays mounted until the close's return remounts its header. + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + for (const [openDialog, opener] of [ + [() => share.click(), share], + [shareFromActions, actions], + ]) { + await openDialog(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const shown = await entry(); + await openDialog(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).toBe(shown); + const header = await opener.elementHandle(); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(opener).toBeFocused(); + } + // An agent's profile opens its owner's in its place. Closing the owner's + // still returns to Share access. + const agent = page.getByRole("tab", { name: "Scout", exact: true }); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(agent).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(tab).toBeFocused(); + await expect(agent).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); + // The agent's visit stays in history. Back restores it, and closing it + // also returns to Share access. + await page.goBack(); + await expect(agent).toHaveAttribute("aria-selected", "true"); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + await expect(share).toBeFocused(); + // So does Back to it from the owner's visit, which stays in history. + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(agent).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(tab).toBeFocused(); + await page.goBack(); + await expect(agent).toHaveAttribute("aria-selected", "true"); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + await expect(share).toBeFocused(); + }); + test("a profile reopened from Share access before its close returns keeps its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry); + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + for (const [openDialog, opener] of [ + [() => share.click(), share], + [shareFromActions, actions], + ]) { + await openDialog(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const closed = await entry(); + // The close's native return lands a task or more later. Hold it so the + // reopen is queued while the navigator still shows the closed visit. + await page.evaluate(() => { + const go = history.go.bind(history); + const held = []; + history.go = (delta) => held.push(delta); + window.releaseReturn = () => { + history.go = go; + for (const delta of held) go(delta); + return held.length; + }; + }); + let held; + try { + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await openDialog(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect((await entry()).id).toBe(closed.id); + } finally { + held = await page.evaluate(() => window.releaseReturn()); + } + expect(held).toBe(1); + // The reopened profile takes a fresh visit and keeps its selection and + // focus, rather than the closed one's opener taking focus. + await expect + .poll(async () => { + const shown = await entry(); + return shown.id !== closed.id && shown.target.detail?.target; + }) + .toBe(closed.target.detail.target); + await expect(tab).toHaveAttribute("aria-selected", "true"); + await expect(tab).toBeFocused(); + // Closing it returns focus to the opener that reopened it. + const header = await opener.elementHandle(); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(opener).toBeFocused(); + } + }); + test("another profile opened from Share access and closed before an earlier close returns keeps its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const sam = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const agent = page.getByRole("tab", { name: "Scout", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry); + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + // The second profile's opener differs from the first's, so only its own + // control can take focus back. + for (const [openFirst, openSecond, opener] of [ + [() => share.click(), shareFromActions, actions], + [shareFromActions, () => share.click(), share], + ]) { + await openFirst(); + await dialog + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + const closed = await entry(); + const header = await opener.elementHandle(); + // Hold the close's native return so Scout's profile is queued, with no + // visit of its own, and closes while the navigator still shows Sam's. + await page.evaluate(() => { + const go = history.go.bind(history); + const held = []; + history.go = (delta) => held.push(delta); + window.releaseReturn = () => { + history.go = go; + for (const delta of held) go(delta); + return held.length; + }; + }); + let held; + try { + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await openSecond(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(agent).toBeFocused(); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + expect((await entry()).id).toBe(closed.id); + } finally { + held = await page.evaluate(() => window.releaseReturn()); + } + expect(held).toBe(1); + // The return lands on the chat and remounts its header. Neither profile + // comes back, and the second one's opener takes focus. + await expect + .poll(async () => { + const shown = await entry(); + return shown.id !== closed.id && !shown.target.detail; + }) + .toBe(true); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(sam).toHaveCount(0); + await expect(agent).toHaveCount(0); + await expect(opener).toBeFocused(); + } + }); + test("a profile restored by Back or Forward returns focus only to its own opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); + await page.goto(app.origin); + await newChat(page); + const sidebar = page.getByRole("navigation", { + name: "Solo conversations", + }); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const sam = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const profile = page.getByRole("tab", { + name: "Fixture Reader", + exact: true, + }); + // Beta: the viewer's profile, opened from a message's author. + await sidebar.locator(`[data-solo-channel-id="${other}"]`).click(); + await conversation + .getByRole("button", { name: "View Fixture Reader profile", exact: true }) + .first() + .click(); + await expect(profile).toBeFocused(); + // Alpha: Sam's profile, opened from Share access. + await sidebar.locator(`[data-solo-channel-id="${id}"]`).click(); + await share.click(); + await page + .getByRole("dialog", { name: "Share access", exact: true }) + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + // Back then Forward restores Sam; closing it still returns to Share access. + await page.goBack(); + await expect(sam).toHaveCount(0); + await page.goForward(); + await expect(sam).toBeVisible(); + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await expect(share).toBeFocused(); + // Back restores Beta's profile. Alpha's Share access never opened it, so + // Beta's does not take focus when it closes. + await page.goBack(); + await expect( + page.getByRole("region", { name: "Chat Beta", exact: true }), + ).toBeVisible(); + await expect(profile).toHaveAttribute("aria-selected", "true"); + const header = await share.elementHandle(); + await profile.press("Escape"); + await expect(profile).toHaveCount(0); + // The dismissal's return remounts the header, in the commit where a stale + // opener would take focus. + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect( + page.getByRole("region", { name: "Chat Beta", exact: true }), + ).toBeFocused(); + // Alpha: Sam's profile, opened from his message's author. Share access + // later opens Sam's profile again, directly or through Scout's owner, but + // never the visit Back restores. + await sidebar.locator(`[data-solo-channel-id="${id}"]`).click(); + const chat = page.getByRole("region", { name: "Chat Alpha", exact: true }); + await expect(chat).toBeVisible(); + const message = finalizeEvent( + { + kind: 9, + created_at: Math.floor(Date.now() / 1000), + content: "Sam's message", + tags: [["h", id]], + }, + teammate, + ); + await expect(() => app.relay.publish("primary", message)).toPass(); + const author = conversation.getByRole("button", { + name: "View Sam Rivera profile", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const closeRestored = async () => { + await expect(sam).toHaveAttribute("aria-selected", "true"); + const header = await share.elementHandle(); + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(chat).toBeFocused(); + }; + await author.click(); + await expect(sam).toBeFocused(); + await conversation + .getByRole("button", { name: "View Fixture Reader profile", exact: true }) + .first() + .click(); + await expect(profile).toBeFocused(); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + await page.goBack(); + await expect(profile).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await closeRestored(); + // Scout's owner link opens Sam in Scout's place, on Share access's behalf. + const scoutTab = page.getByRole("tab", { name: "Scout", exact: true }); + await author.click(); + await expect(sam).toBeFocused(); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(scoutTab).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(sam).toBeFocused(); + await page.goBack(); + await expect(scoutTab).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await closeRestored(); + }); + test("closing a profile opened from a Solo side tab returns focus to its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await openPage(page, "Messages"); + await page.locator('[data-channel-id="gamma"]').click(); + await expect + .poll(() => + app.report.liveRequests.some((request) => + request.filters.some((filter) => filter["#h"]?.includes("gamma")), + ), + ) + .toBe(true); + // A team channel's link opens the Solo chat Beta in a side tab. + const link = app.append( + "primary", + "gamma", + `[Open Beta](buzz://channel/${other})`, + ); + await page + .locator(`[data-message-id="${link.id}"]`) + .getByRole("link", { name: "Open Chat: Beta", exact: true }) + .click(); + const beta = page.getByRole("tab", { name: "Beta", exact: true }); + await expect(beta).toHaveAttribute("aria-selected", "true"); + // The side tab's toolbar renders in its dock, outside the tab panel. + const dock = page.locator("[data-panel-workspace]"); + const share = dock.getByRole("button", { + name: "Share access", + exact: true, + }); + const actions = dock.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + // The toolbar button, a name chosen by keyboard and Escape on the tab. + await share.click(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await expect(share).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(beta).toHaveAttribute("aria-selected", "true"); + await expect(share).toBeFocused(); + // Chat actions, a name chosen by pointer and the tab's Close button. + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + await name.click(); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await page + .getByRole("button", { name: "Close Sam Rivera tab", exact: true }) + .click(); + await expect(tab).toHaveCount(0); + await expect(actions).toBeFocused(); + // Gamma's Canvas closes beside the profile and leaves Share access to the + // profile's own close. + await page + .getByRole("button", { name: "Channel actions", exact: true }) + .click(); + await page + .getByRole("menuitem", { name: "View canvas", exact: true }) + .click(); + const canvas = page.getByRole("tab", { name: "Canvas", exact: true }); + await expect(canvas).toHaveAttribute("aria-selected", "true"); + await beta.click(); + await share.click(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + await page + .getByRole("button", { name: "Close Canvas tab", exact: true }) + .click(); + await expect(canvas).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); + // Choosing the profile already open beside the chat pushes no visit, and + // selecting its tab still unmounts the toolbar. The close focuses the Beta + // tab, and only the dismissal's return moves focus to Share access. + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry.id); + await share.click(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const shown = await entry(); + await beta.click(); + await share.click(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).toBe(shown); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(beta).toHaveAttribute("aria-selected", "true"); + await expect(share).toBeFocused(); + }); +}); // Real hover, menu hit targets and local-panel handoff cannot be proved in jsdom. test("Solo sidebar shares working activity, keyboard preview and local panel navigation", async ({ page,