diff --git a/docs/profiles.md b/docs/profiles.md index eb93d43a4..693f5c400 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -89,3 +89,28 @@ channels; `sidebar-unread.spec.mjs` holds unread evidence through real EOSE and checks its badges without retries. Access-loss/disconnect cancellation is unchanged. Broad scan and native build/package acceptance remain deferred to an agreed integration batch. No sending/signing behavior changed. + +## Info, channels and linked instances + +The profile's Channels tab uses the current viewer's `session.channels` +list and exact `ChannelSummary.members` from relay-authored rosters. It excludes +archived, hidden, DM and session conversations. Only positively identified stream +and forum rows render; when a matching roster lacks a recognized type, that +membership is unclassified and omitted from the channel rows. Ready empty copy +qualifies this uncertainty for the viewed identity, including partial lists. +Verified classified rows remain visible +alongside a transient list error; loading/error/partial discovery is not an +empty membership claim; retry uses `refreshList`. Rows navigate using the existing +scoped conversation destination when a valid community scope and navigation +capability exist, otherwise remain plain text. + +On native hosts, linked instances use `agentControl`'s exact managed `pubkey` and +normalized `relayUrl` for the active community. The subsection is absent without +native control or a valid community scope. Agent identity hints gate loading and +visibility, never ownership; exact native matches alone supply instance rows. The +section stays hidden for a non-agent without a match. It never derives ownership +from the old Buzz library, self-declared profile markers, or names. The Agents +page route opens management, not a per-instance page. + +The Info tab keeps the public key and linked instances; the Channels tab is read-only. +Neither list is a cross-community/global directory. diff --git a/src/bundled/profiles/ProfileChannels.test.tsx b/src/bundled/profiles/ProfileChannels.test.tsx new file mode 100644 index 000000000..d938f1098 --- /dev/null +++ b/src/bundled/profiles/ProfileChannels.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Navigation } from "../../features/navigation/controller"; +import type { + ChannelList, + ChannelQueries, +} from "../../features/relay/contracts"; +import type { RelaySession } from "../../features/relay/session"; +import { ProfileChannels } from "./ProfileChannels"; + +const person = "a".repeat(64); +const viewer = "b".repeat(64); +function fixture() { + let list: ChannelList = { status: "loading", channels: [] }; + const listeners = new Set<() => void>(); + const refreshList = vi.fn(); + const ensureList = vi.fn(); + const channels = { + list: () => list, + subscribeList(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensureList, + refreshList, + } as unknown as ChannelQueries; + const open = vi.fn(async () => ({ status: "opened" as const })); + const navigation = { open } as unknown as Navigation; + const session = { channels } as RelaySession; + const update = (next: ChannelList) => + act(() => { + list = next; + for (const listener of listeners) listener(); + }); + return { session, navigation, open, update, refreshList, ensureList }; +} +afterEach(cleanup); + +it("shows only exact verified visible memberships, handles partial lists and opens the scoped conversation", () => { + const f = fixture(); + render( + , + ); + expect(screen.getByRole("status").textContent).toContain("Loading channels"); + expect(f.ensureList).toHaveBeenCalled(); + f.update({ + status: "ready", + coverage: "partial", + channels: [ + { + id: "exact", + name: "Visible", + channelType: "stream", + members: [person, viewer], + }, + { id: "forum", name: "Forum", channelType: "forum", members: [person] }, + { id: "other", name: "Unrelated", members: [viewer] }, + { id: "unknown", name: "Unknown" }, + { + id: "hidden", + name: "Hidden", + channelType: "stream", + hidden: true, + members: [person], + }, + { id: "untyped-member", name: "Unknown type", members: [person] }, + { id: "untyped-unrelated", name: "Unrelated type", members: [viewer] }, + { id: "dm", name: "Direct", channelType: "dm", members: [person] }, + { + id: "session", + name: "Child", + channelType: "session", + members: [person], + }, + { id: "archived", name: "Archived", archived: true, members: [person] }, + ], + }); + expect(screen.getByRole("button", { name: "#Visible" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "#Forum" })).toBeTruthy(); + expect( + screen.getByText(/Some memberships for this identity are unclassified/), + ).toBeTruthy(); + expect( + screen.queryByText(/Unrelated|Unknown type|Hidden|Archived|Direct|Child/), + ).toBeNull(); + expect(screen.getByText(/More channels may exist/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "#Visible" })); + expect(f.open).toHaveBeenCalledWith({ + version: 1, + kind: "conversation", + channelId: "exact", + scope: { viewer, communityOrigin: "https://relay.example.test" }, + }); + f.update({ status: "ready", channels: [] }); + expect( + screen.getByText(/No visible channels with verified membership/), + ).toBeTruthy(); +}); + +it("does not invent a route, and retries failed discovery without claiming a complete empty result", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "error", + channels: [ + { id: "known", name: "Known", channelType: "stream", members: [person] }, + ], + }); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("Known")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Known/ })).toBeNull(); + expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry channels" })); + expect(f.refreshList).toHaveBeenCalledOnce(); + expect(f.open).not.toHaveBeenCalled(); +}); + +it("renders a verified row without a destination when navigation is unavailable", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "ready", + channels: [ + { id: "known", name: "Known", channelType: "stream", members: [person] }, + ], + }); + expect(screen.getByText("Known")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Known/ })).toBeNull(); + expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy(); +}); + +it("keeps classified roster rows but omits unclassified conversations after metadata failure", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "error", + channels: [ + { id: "known", name: "Known", channelType: "forum", members: [person] }, + { id: "untyped", name: "untyped", members: [person] }, + { + id: "hidden", + name: "Hidden", + channelType: "stream", + hidden: true, + members: [person], + }, + { id: "dm", name: "Direct", channelType: "dm", members: [person] }, + { + id: "session", + name: "Child", + channelType: "session", + members: [person], + }, + ], + }); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByRole("button", { name: "#Known" })).toBeTruthy(); + expect( + screen.getByText(/Some memberships for this identity are unclassified/), + ).toBeTruthy(); + expect(screen.queryByText(/untyped|Hidden|Direct|Child/)).toBeNull(); +}); + +it("qualifies ready empty results when only matching unclassified memberships exist", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "ready", + channels: [{ id: "unknown", name: "Unknown", members: [person] }], + }); + expect(screen.queryByRole("button", { name: /Unknown/ })).toBeNull(); + expect( + screen.getByText(/No matching channels with a known visible type/), + ).toBeTruthy(); + expect( + screen.queryByText(/No visible channels with verified membership/), + ).toBeNull(); + f.update({ + status: "ready", + coverage: "partial", + channels: [{ id: "unknown", name: "Unknown", members: [person] }], + }); + expect( + screen.getByText(/More channels may exist outside the loaded list/), + ).toBeTruthy(); + expect( + screen.queryByText(/No visible channels with verified membership/), + ).toBeNull(); +}); + +it("does not classify hidden, archived or DM memberships in a ready empty list", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "ready", + channels: [ + { id: "dm", name: "Direct", channelType: "dm", members: [person] }, + { id: "hidden", name: "Hidden", hidden: true, members: [person] }, + { id: "archived", name: "Archived", archived: true, members: [person] }, + ], + }); + expect(screen.queryByText(/unclassified and omitted/)).toBeNull(); + expect( + screen.getByText(/No visible channels with verified membership/), + ).toBeTruthy(); + expect( + screen.queryByText(/No matching channels with a known visible type/), + ).toBeNull(); + expect(f.open).not.toHaveBeenCalled(); +}); + +it("does not show a metadata caveat for another identity's unclassified roster", () => { + const f = fixture(); + render( + , + ); + f.update({ + status: "ready", + channels: [{ id: "unknown", name: "Unknown", members: [viewer] }], + }); + expect(screen.queryByText(/unclassified and omitted/)).toBeNull(); + expect( + screen.getByText(/No visible channels with verified membership/), + ).toBeTruthy(); +}); diff --git a/src/bundled/profiles/ProfileChannels.tsx b/src/bundled/profiles/ProfileChannels.tsx new file mode 100644 index 000000000..05feab920 --- /dev/null +++ b/src/bundled/profiles/ProfileChannels.tsx @@ -0,0 +1,121 @@ +import type { Navigation } from "../../features/navigation/controller"; +import { useChannelList } from "../../features/relay/react"; +import type { RelaySession } from "../../features/relay/session"; +import { Button } from "../../shared/design-system/ui/Button"; +import { ArrowUpRightIcon } from "../../shared/design-system/icons/index"; +import styles from "./Profiles.module.css"; + +/** Only the viewer's listed channels with an exact roster match are evidence. */ +export function ProfileChannels({ + session, + pubkey, + communityOrigin, + viewer, + navigation, +}: { + session: RelaySession; + pubkey: string; + communityOrigin: string | undefined; + viewer: string | undefined; + navigation: Navigation | undefined; +}) { + const list = useChannelList(session.channels); + const channels = ( + list.status === "ready" || list.status === "error" ? list.channels : [] + ).filter( + (channel) => + !channel.archived && + !channel.hidden && + (channel.channelType === "stream" || channel.channelType === "forum") && + channel.members?.includes(pubkey), + ); + const unclassifiedMembership = + (list.status === "ready" || list.status === "error") && + list.channels.some( + (channel) => + !channel.archived && + !channel.hidden && + !channel.channelType && + channel.members?.includes(pubkey), + ); + return ( +
+
+ {(list.status === "idle" || list.status === "loading") && ( +

+ Loading channels… +

+ )} + {list.status === "unavailable" && ( +

+ Channel membership is unavailable. +

+ )} + {list.status === "error" && ( +
+

Could not finish loading channels.

+ +
+ )} + {unclassifiedMembership && ( +

+ Some memberships for this identity are unclassified and omitted. +

+ )} + {list.status === "ready" && !channels.length && ( +

+ {unclassifiedMembership + ? `No matching channels with a known visible type; other memberships could not be classified.${list.coverage === "partial" ? " More channels may exist outside the loaded list." : ""}` + : list.coverage === "partial" + ? "No matching channels in the loaded list; more channels may exist." + : "No visible channels with verified membership for this identity."} +

+ )} + {!!channels.length && ( +
    + {channels.map((channel) => ( +
  • + {communityOrigin && navigation && viewer ? ( + + ) : ( + {channel.name} + )} +
  • + ))} +
+ )} +
+ {list.coverage === "partial" && !!channels.length && ( +

+ More channels may exist outside the loaded list. +

+ )} + {channels.length > 0 && (!communityOrigin || !navigation) && ( +

+ Channel navigation is unavailable here. +

+ )} +
+ ); +} diff --git a/src/bundled/profiles/ProfileInstances.test.tsx b/src/bundled/profiles/ProfileInstances.test.tsx new file mode 100644 index 000000000..3d2d8fe3f --- /dev/null +++ b/src/bundled/profiles/ProfileInstances.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { + AgentControl, + AgentControlState, +} from "../../features/agents/control"; +import type { Navigation } from "../../features/navigation/controller"; +import { ProfileInstances } from "./ProfileInstances"; + +const person = "a".repeat(64); +const viewer = "b".repeat(64); +const instance = (id: string, pubkey: string, relayUrl: string) => ({ + id, + pubkey, + relayUrl, + name: id, + status: "stopped" as const, +}); +function fixture() { + let state: AgentControlState = { + status: "idle", + data: null, + busy: false, + error: null, + }; + const listeners = new Set<() => void>(); + const refresh = vi.fn(async () => {}); + const control = { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + refresh, + } as unknown as AgentControl; + const open = vi.fn(async () => ({ status: "opened" as const })); + const navigation = { open } as unknown as Navigation; + const update = (next: AgentControlState) => + act(() => { + state = next; + for (const listener of listeners) listener(); + }); + return { control, navigation, open, update, refresh }; +} +afterEach(cleanup); + +it("uses native exact identity and community, never library display links", () => { + const f = fixture(); + render( + , + ); + expect(f.refresh).toHaveBeenCalledOnce(); + expect(screen.getByRole("status").textContent).toContain( + "Loading managed agents", + ); + f.update({ + status: "ready", + data: { + agents: [ + instance("matched", person, "wss://relay.example.test"), + instance("wrong-key", viewer, "wss://relay.example.test"), + instance("wrong-relay", person, "wss://other.example.test"), + ] as NonNullable["agents"], + runtimeAvailable: true, + }, + busy: false, + error: null, + }); + expect(screen.getByText("matched")).toBeTruthy(); + expect(screen.queryByText(/wrong-key|wrong-relay|stopped/)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "View in Agents" })); + expect(f.open).toHaveBeenCalledWith({ + version: 1, + kind: "page", + pluginId: "buzz.agents", + pageId: "agents", + scope: { viewer, communityOrigin: "https://relay.example.test" }, + }); + f.update({ status: "unavailable", data: null, busy: false, error: null }); + expect( + screen.queryByRole("region", { name: "Linked agent instances" }), + ).toBeNull(); +}); + +it("omits instances without a valid community", () => { + const f = fixture(); + f.update({ status: "error", data: null, busy: false, error: "failed" }); + render( + , + ); + expect( + screen.queryByRole("region", { name: "Linked agent instances" }), + ).toBeNull(); + expect(f.refresh).not.toHaveBeenCalled(); + expect(f.open).not.toHaveBeenCalled(); +}); + +it("retries failed native discovery in a valid community without presenting stale instances", () => { + const f = fixture(); + f.update({ status: "error", data: null, busy: false, error: "failed" }); + render( + , + ); + expect(screen.getByRole("alert")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry agents" })); + expect(f.refresh).toHaveBeenCalledOnce(); + expect(f.open).not.toHaveBeenCalled(); +}); + +it("does not refresh or display instances for a human without a native match", () => { + const f = fixture(); + const { rerender } = render( + , + ); + expect(f.refresh).not.toHaveBeenCalled(); + f.update({ + status: "ready", + data: { + agents: [ + instance("someone-else", viewer, "wss://relay.example.test"), + ] as NonNullable["agents"], + runtimeAvailable: true, + }, + busy: false, + error: null, + }); + rerender( + , + ); + expect( + screen.queryByRole("region", { name: "Linked agent instances" }), + ).toBeNull(); +}); diff --git a/src/bundled/profiles/ProfileInstances.tsx b/src/bundled/profiles/ProfileInstances.tsx new file mode 100644 index 000000000..e478e3328 --- /dev/null +++ b/src/bundled/profiles/ProfileInstances.tsx @@ -0,0 +1,85 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { sameCommunityAgents } from "../../features/agents/choices"; +import type { AgentControl } from "../../features/agents/control"; +import type { Navigation } from "../../features/navigation/controller"; +import { Button } from "../../shared/design-system/ui/Button"; + +/** Native-managed identities only. The old library's definition links are not authority. */ +export function ProfileInstances({ + control, + navigation, + pubkey, + scope, + communityOrigin, + viewer, + knownAgent, +}: { + control: AgentControl; + navigation: Navigation | undefined; + pubkey: string; + scope: string | undefined; + communityOrigin: string | undefined; + viewer: string | undefined; + knownAgent: boolean; +}) { + const state = useSyncExternalStore( + control.subscribe, + control.snapshot, + control.snapshot, + ); + useEffect(() => { + if (communityOrigin && knownAgent && state.status === "idle") + void control.refresh(); + }, [control, communityOrigin, knownAgent, state.status]); + if (!communityOrigin || state.status === "unavailable") return null; + const instances = + scope && state.status === "ready" + ? sameCommunityAgents(state.data?.agents ?? [], scope).filter( + (agent) => agent.pubkey === pubkey, + ) + : []; + if (!knownAgent && !instances.length) return null; + return ( +
+

Linked agent instances

+ {state.status === "loading" || state.status === "idle" ? ( +

Loading managed agents…

+ ) : state.status === "error" ? ( +
+

Could not refresh managed agents.

+ +
+ ) : !instances.length ? ( +

No managed instance for this identity in this community.

+ ) : ( +
    + {instances.map((agent) => ( +
  • {agent.name}
  • + ))} +
+ )} + {!!instances.length && navigation && viewer && ( + + )} +
+ ); +} diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index 5220c5dab..85070d2a3 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -88,7 +88,10 @@ it("recovers provider-owned names in the same live session and releases demand w viewer: person.pubkey, relayAuthor: relayKey.pubkey, scope: "wss://relay.example.test", - query: async () => [publicProfile], + query: async (filters) => + filters.some((filter) => filter.kinds?.includes(0)) + ? [publicProfile] + : [], media: () => undefined, readAgentLibrary: read, subscribe(callbacks) { @@ -129,6 +132,10 @@ it("recovers provider-owned names in the same live session and releases demand w live.established(); }; try { + owner.session.channels.ensureList(); + await waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); render( Unsupported profile.

; @@ -36,6 +48,10 @@ export function ProfilePanel({ session={connection.session} pubkey={pubkey} context={context} + navigation={navigation} + control={control} + scope={connection.scope} + viewer={connection.viewer} /> ); } @@ -43,10 +59,18 @@ function ProfileDetails({ session, pubkey, context, + navigation, + control, + scope, + viewer, }: { session: RelaySession; pubkey: string; context: PanelProps["context"]; + navigation: Navigation | undefined; + control: AgentControl | undefined; + scope: string | undefined; + viewer: string | undefined; }) { const selection = useMemo( () => selectProfiles(session.profiles, [pubkey]), @@ -63,6 +87,7 @@ function ProfileDetails({ ); const [attempt, retry] = useState(0); const [copyStatus, setCopyStatus] = useState(""); + const [tab, setTab] = useState<"info" | "channels">("info"); const region = useRef(null); useEffect(() => { region.current?.focus(); @@ -85,6 +110,14 @@ function ProfileDetails({ }; }, [session, pubkey, attempt]); const agentPubkeys = useKnownAgentPubkeys(session, profiles); + let communityOrigin: string | undefined; + if (scope && viewer && scope.endsWith(`:${viewer}`)) { + try { + communityOrigin = relayOrigin(scope.slice(0, -(viewer.length + 1))); + } catch { + // This session has no usable navigation scope. + } + } const npub = profileTarget(pubkey)?.slice(6) ?? pubkey; const identityName = useIdentityNames(session.names); const name = identityName(pubkey, profile?.name ?? "Unknown profile"); @@ -114,62 +147,111 @@ function ProfileDetails({

{name}

- - {profile?.about &&

{profile.about}

} - {context?.canOpen(activity) && ( -
- -

- Owner-only agent telemetry in this channel, if published. -

-
- )} -
-
-

Public key

- -
- {npub} - - {copyStatus} - -
- {!profile && - (status === "loading" ? ( -

Loading profile…

- ) : ( - <> -

- {status === "error" - ? "Could not load this profile." - : "No profile metadata is available in this community."} -

- - - ))} + ( +
+ {selected === "info" ? ( + <> + + {profile?.about && ( +

{profile.about}

+ )} + {context?.canOpen(activity) && ( +
+ +

+ Owner-only agent telemetry in this channel, if published. +

+
+ )} + {control && ( + + )} +
+
+

Public key

+ +
+ {npub} + + {copyStatus} + +
+ {!profile && + (status === "loading" ? ( +

Loading profile…

+ ) : ( + <> +

+ {status === "error" + ? "Could not load this profile." + : "No profile metadata is available in this community."} +

+ + + ))} + + ) : ( + + )} +
+ )} + /> ); } diff --git a/src/bundled/profiles/Profiles.module.css b/src/bundled/profiles/Profiles.module.css index ee3bd1155..8b489b775 100644 --- a/src/bundled/profiles/Profiles.module.css +++ b/src/bundled/profiles/Profiles.module.css @@ -66,3 +66,45 @@ .feedback:empty { display: none; } + +.tabContent { + display: flex; + flex-direction: column; + gap: var(--space-6); + padding-top: var(--space-4); + min-width: 0; +} + +.channelList { + overflow: hidden; + border: 1px solid var(--border-standard); + border-radius: var(--radius-card); +} +.channelNote { + padding: var(--space-4); +} +.channelRow { + border-top: 1px solid var(--border-standard); +} +[data-profile-channel-link] { + display: flex; + width: 100%; + min-height: 3.5rem; + align-items: center; + justify-content: space-between; + text-align: left; +} +/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector targets the shared button label. */ +[data-profile-channel-link] :global(.buzz-button-label) { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; +} +/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector targets the shared button label. */ +[data-profile-channel-link] :global(.buzz-button-label) span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/bundled/profiles/index.tsx b/src/bundled/profiles/index.tsx index 284746147..fead4e290 100644 --- a/src/bundled/profiles/index.tsx +++ b/src/bundled/profiles/index.tsx @@ -2,12 +2,19 @@ import type { PluginModule } from "../../plugins/api"; import { profileKey } from "../../features/profiles/target"; import { ProfilePanel } from "./ProfilePanel"; -export const inject = ["panels", "relay"]; +export const inject = ["panels", "relay", "navigation", "agentControl"]; export const apply: PluginModule["apply"] = (ctx) => { ctx.panels.register({ id: "profile", title: "Profile", matches: (target) => !!profileKey(target), - component: (props) => , + component: (props) => ( + + ), }); }; diff --git a/tests/browser/profile-channel-focus.spec.mjs b/tests/browser/profile-channel-focus.spec.mjs new file mode 100644 index 000000000..ac9c37620 --- /dev/null +++ b/tests/browser/profile-channel-focus.spec.mjs @@ -0,0 +1,100 @@ +import { test, expect } from "./source-fixture.mjs"; + +// Read actual painted pixels at the shared 2px outline (2px outward offset). +// CSS declarations alone cannot tell us whether the list clips the ring. +async function expectPaintedRing(page, list, row) { + await expect(row).toBeFocused(); + await expect(row).toHaveCSS("outline-width", "2px"); + expect( + await row.evaluate((element) => element.matches(":focus-visible")), + ).toBe(true); + const bounds = await list.boundingBox(); + const image = await page.screenshot({ + clip: { + x: Math.floor(bounds.x), + y: Math.floor(bounds.y), + width: Math.ceil(bounds.x + bounds.width) - Math.floor(bounds.x), + height: Math.ceil(bounds.y + bounds.height) - Math.floor(bounds.y), + }, + scale: "css", + }); + const paint = await row.evaluate( + async (element, { png, clip }) => { + const image = new Image(); + image.src = `data:image/png;base64,${png}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const rect = element.getBoundingClientRect(); + const color = getComputedStyle(element) + .outlineColor.match(/\d+/g) + .map(Number); + const sample = (x, y) => { + const pixel = context.getImageData( + Math.floor(x - clip.x), + Math.floor(y - clip.y), + 1, + 1, + ).data; + return ( + pixel[3] === 255 && + color.every((value, index) => Math.abs(value - pixel[index]) < 30) + ); + }; + return { + top: sample(rect.left + rect.width / 2, rect.top - 3), + bottom: sample(rect.left + rect.width / 2, rect.bottom + 2), + left: sample(rect.left - 3, rect.top + rect.height / 2), + right: sample(rect.right + 2, rect.top + rect.height / 2), + }; + }, + { + png: image.toString("base64"), + clip: { x: Math.floor(bounds.x), y: Math.floor(bounds.y) }, + }, + ); + expect(paint).toEqual({ top: true, bottom: true, left: true, right: true }); +} + +test("profile channel focus ring paints on one row and both list boundaries", async ({ + page, +}) => { + await page.goto("/tests/fixtures/profiles.html"); + const panel = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + const channels = panel.getByRole("region", { name: "Channels" }); + await page + .getByRole("button", { name: "View thread: 1 reply", exact: true }) + .click(); + await page.getByRole("button", { name: "View Pinky profile" }).click(); + await panel.getByRole("tab", { name: "Channels" }).click(); + const single = channels.locator("ul"); + await expect(single.locator("li")).toHaveCount(1); + // The source fixture has no host keyboard-modality listener; emulate its + // state after a real keyboard input while retaining browser :focus-visible. + await page.keyboard.press("Tab"); + await page + .locator("html") + .evaluate((html) => html.setAttribute("data-keyboard-navigation", "")); + await single.getByRole("button", { name: "#One" }).focus(); + await expectPaintedRing(page, single, single.getByRole("button")); + + await panel.getByRole("button", { name: "Close channel panel" }).click(); + await page.getByRole("button", { name: "View Viewer profile" }).click(); + await panel.getByRole("tab", { name: "Channels" }).click(); + const multiple = channels.locator("ul"); + await expect(multiple.locator("li")).toHaveCount(2); + for (const row of [ + multiple.getByRole("button").first(), + multiple.getByRole("button").last(), + ]) { + await page.keyboard.press("Tab"); + await row.focus(); + await expectPaintedRing(page, multiple, row); + } +}); diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index d3ac0883d..55fdd941a 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -139,6 +139,14 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc panel.getByRole("img", { name: "Pinky avatar" }), ).toHaveAttribute("data-size", "fill"); await expect(panel.getByText("Agent profile", { exact: true })).toBeVisible(); + await panel.getByRole("tab", { name: "Channels" }).click(); + await expect(panel.getByRole("region", { name: "Channels" })).toContainText( + "#One", + ); + await panel.getByRole("tab", { name: "Info" }).click(); + await expect( + panel.getByRole("region", { name: "Linked agent instances" }), + ).toHaveCount(0); await panel.getByRole("button", { name: "Close channel panel" }).click(); await expect( page.getByRole("button", { name: "View thread: 1 reply", exact: true }), diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index cfd0cb51c..494b99c56 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -14,6 +14,9 @@ import { PagesService } from "../../src/features/pages/service"; import { TemplateProvidersService } from "../../src/features/channel-templates/provider"; import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage"; import { createRelaySession } from "../../src/features/relay/session"; +import { createAgentControl } from "../../src/features/agents/control"; +import { createNavigationController } from "../../src/features/navigation/controller"; +import { createMemoryHistory } from "../../src/features/navigation/history"; import type { RelayData, RelaySnapshot, @@ -22,9 +25,9 @@ import { bounds, keypair, message, - metadata, profile, roster, + signed, summary, } from "../../src/features/relay/testing"; import { profileTarget } from "../../src/features/profiles/target"; @@ -43,7 +46,9 @@ const reply = message(viewer, "one", "Thread @Pinky", 12, [ ["p", pinky.pubkey], ]); const picture = "https://images.test/avatar.png"; +const pinkyPicture = "https://images.test/pinky-animated.gif"; const pictureFixture = "/tests/fixtures/design-system/assets/avatar.png"; +const pinkyPictureFixture = "/tests/fixtures/design-system/assets/avatar.png"; const report = { profileReads: [] as string[][], media: [] as [string, "small" | undefined][], @@ -57,7 +62,7 @@ const data = [ name: "Pinky", about: "Agent profile", is_agent: true, - picture, + picture: pinkyPicture, }), ]; function session() { @@ -66,7 +71,11 @@ function session() { relayAuthor: authority.pubkey, media: (url, size) => { report.media.push([url, size]); - return url === picture ? pictureFixture : url; + return url === picture + ? pictureFixture + : url === pinkyPicture + ? pinkyPictureFixture + : url; }, async query(filters) { return filters.flatMap((filter) => { @@ -77,8 +86,24 @@ function session() { ]; if (filter.kinds?.includes(39000)) return [ - metadata(authority, "one", "One"), - metadata(authority, "two", "Two"), + signed(authority, { + kind: 39000, + content: JSON.stringify({ name: "One" }), + tags: [ + ["d", "one"], + ["name", "One"], + ["t", "stream"], + ], + }), + signed(authority, { + kind: 39000, + content: JSON.stringify({ name: "Two" }), + tags: [ + ["d", "two"], + ["name", "Two"], + ["t", "stream"], + ], + }), ]; if (filter.kinds?.includes(0)) { report.profileReads.push([...(filter.authors ?? [])]); @@ -124,7 +149,7 @@ let owner = session(); let snapshot: RelaySnapshot = { status: "ready", generation: 1, - scope: "fixture:viewer", + scope: `https://relay.example.test:${viewer.pubkey}`, viewer: viewer.pubkey, session: owner.session, }; @@ -143,6 +168,12 @@ const relay: RelayData = { }; const context = new Context(); context.provide("relay", relay); +const navigationHost = createNavigationController(createMemoryHistory()); +context.provide("navigation", navigationHost.navigation); +context.effect(() => () => navigationHost.dispose()); +const agentControl = createAgentControl(null); +context.provide("agentControl", agentControl); +context.effect(() => () => agentControl.dispose()); const contexts: PanelContext[] = []; function ContextProbe({ context }: PanelProps) { useLayoutEffect(() => {