diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 5c9f7d4a84..fc5298f322 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom import { StrictMode } from "react"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; import { npubEncode } from "nostr-tools/nip19"; import "@testing-library/jest-dom/vitest"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; @@ -23,11 +25,18 @@ import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { ConversationPresentation } from "../conversation/ConversationPresentation"; import { MessageRow } from "./MessageRow"; import { MessageManagement } from "./MessageManagement"; -import type { ChannelMessage } from "../relay/contracts"; +import type { ChannelMessage, Profile } from "../relay/contracts"; import type { UnreadCapability, UnreadSnapshot } from "../relay/unread"; import type { RelaySession } from "../relay/session"; import type { TypingEntry } from "../relay/typing"; import { LinkLabel } from "../../bundled/links/InlineLink"; +import { attestedOwner } from "../agents/owner-attestation"; + +// Real verification, observed so tests can wait for its verdict. +vi.mock("../agents/owner-attestation", async (original) => { + const actual = await original(); + return { ...actual, attestedOwner: vi.fn(actual.attestedOwner) }; +}); const row: ChannelMessage = { id: "root", @@ -3617,3 +3626,130 @@ it("keeps a failed card's attachments listed under StrictMode", () => { error.mockRestore(); } }); + +const listed = { channels: [], status: "ready" }; + +it.each([ + { + case: "signed owner", + forged: false, + viewer: undefined, + label: "Logan’s agent", + }, + // The viewer's own agent shows only the mark. + { case: "viewer's agent", forged: false, viewer: "owner", label: undefined }, + { case: "forged owner", forged: true, viewer: undefined, label: undefined }, +])( + "names an agent's owner from its signed profile ($case)", + async ({ forged, viewer, label }) => { + const agent = keypair(); + const owner = keypair(); + const signer = forged ? keypair() : owner; + const conditions = ""; + const sig = bytesToHex( + schnorr.sign( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode( + `nostr:agent-auth:${agent.pubkey}:${conditions}`, + ), + ), + ), + signer.secret, + ), + ); + const head = signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Quinn" }), + tags: [["auth", owner.pubkey, conditions, sig]], + }); + const quinn: Profile = { name: "Quinn", isAgent: true }; + let profiles = new Map([[agent.pubkey, quinn]]); + const listeners = new Set<() => void>(); + // The owner's name arrives after verification, as a background load. + const ensure = vi.fn(async () => { + // Later than the render that requested it, so only a subscription shows it. + await new Promise((resolve) => setTimeout(resolve, 0)); + profiles = new Map(profiles).set(owner.pubkey, { name: "Logan" }); + for (const listener of listeners) listener(); + }); + const subscribe = () => () => {}; + const verifications = vi.mocked(attestedOwner); + verifications.mockClear(); + const session = { + channels: { list: () => listed, subscribeList: subscribe }, + messages: {}, + profiles: { + snapshot: () => profiles, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure, + event: (key: string) => (key === agent.pubkey ? head : undefined), + }, + } as unknown as RelaySession; + const view = renderMessage({ + row: { ...row, authorId: agent.pubkey }, + profile: quinn, + // Timelines pass row-selected profiles, which never include the owner. + participantProfiles: new Map([[agent.pubkey, quinn]]), + session, + viewer: viewer === "owner" ? owner.pubkey : row.authorId, + }); + try { + const byline = view.container.querySelector("strong")?.parentElement; + // The mention-chip agent mark sits beside the name either way. + expect(byline?.querySelector("svg")).not.toBeNull(); + expect(verifications).toHaveBeenCalledWith(head); + // Barrier: the real signature check has returned its verdict. + await act(async () => { + await verifications.mock.results[0]?.value; + }); + if (label) { + await waitFor(() => + expect(view.container).toHaveTextContent(`Quinn· ${label}`), + ); + expect(ensure).toHaveBeenCalledWith([owner.pubkey], "background"); + } else if (viewer === "owner") { + await expect(verifications.mock.results[0]?.value).resolves.toBe( + owner.pubkey, + ); + // Barrier: the owner's name has loaded, so a label would show now. + await waitFor(() => expect(profiles.has(owner.pubkey)).toBe(true)); + expect(view.container).not.toHaveTextContent("agent"); + } else { + await expect(verifications.mock.results[0]?.value).resolves.toBe( + undefined, + ); + expect(view.container).not.toHaveTextContent("agent"); + expect(ensure).not.toHaveBeenCalled(); + } + } finally { + view.unmount(); + } + }, +); + +it("shows no agent mark or owner for a person", () => { + const event = vi.fn(); + const subscribe = () => () => {}; + const none = new Map(); + const session = { + channels: { list: () => listed, subscribeList: subscribe }, + messages: {}, + profiles: { snapshot: () => none, subscribe, event, ensure: vi.fn() }, + } as unknown as RelaySession; + const view = renderMessage({ session, profile: { name: "Ana" } }); + try { + expect( + view.container + .querySelector("strong") + ?.parentElement?.querySelector("svg"), + ).toBeNull(); + expect(event).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } +}); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 2cc9d83bd3..a7791ae2d2 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -11,7 +11,9 @@ import { Avatar } from "../../shared/design-system/ui/Avatar"; import { ArrowRightIcon, LightningIcon, + RobotIcon, } from "../../shared/design-system/icons"; +import { useProfileOwner } from "../profiles/useProfileOwner"; import { messagePreviewText } from "../conversation/message-preview-text"; import { profileMentionParts } from "./profile-mentions"; import { addressedMessage } from "./audience-address"; @@ -310,6 +312,19 @@ function MessageRowContent({ : undefined, row.authorId, ); + const agentAuthor = !row.workflowOwnerId && avatarShape === "squircle"; + const owner = useProfileOwner( + session, + agentAuthor && !stackPrevious ? row.authorId : undefined, + ); + // The viewer's own agents carry only the agent mark. + const ownerLabel = + owner && + owner.id !== viewer && + owner.profile && + !owner.profile.nameIsFallback + ? `${resolveName(owner.id, owner.profile.name)}’s agent` + : undefined; const presenceId = useId(); const shown = shownRow ?? row; const timeReply = shown.diff ? undefined : parseMediaTimeReply(shown.content); @@ -884,7 +899,16 @@ function MessageRowContent({ /> ) : ( - {name} + + {/* Inside the name, so the byline keeps the text baseline. */} + {agentAuthor && !stackPrevious && ( + {session && !stackPrevious && ( )} + {ownerLabel && ( + + + {ownerLabel} + + )} {!stackPrevious && ( )} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index d9fa80d6f2..a99847e4b4 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -205,6 +205,18 @@ font-weight: var(--type-weight-medium); min-width: 0; } +.authorAgentIcon { + display: inline-block; + width: 1em; + height: 1em; + margin-inline-end: var(--space-1); + vertical-align: -0.125em; +} +.authorOwner { + color: var(--text-subtle); + min-width: 0; + overflow-wrap: anywhere; +} .text { white-space: normal; } diff --git a/src/features/profiles/useProfileOwner.ts b/src/features/profiles/useProfileOwner.ts new file mode 100644 index 0000000000..2f417d6c2e --- /dev/null +++ b/src/features/profiles/useProfileOwner.ts @@ -0,0 +1,60 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../agents/owner-attestation"; +import type { Profile } from "../relay/contracts"; +import type { RelayEvent } from "../relay/events"; +import type { RelaySession } from "../relay/session"; + +const noSubscribe = () => () => {}; +const none = () => undefined; +// One verification per retained signed head, shared by every row that shows it. +const checks = new WeakMap>(); +const results = new WeakMap(); + +function verify(head: RelayEvent) { + let check = checks.get(head); + if (!check) { + check = attestedOwner(head).then((owner) => { + results.set(head, { owner }); + return owner; + }); + checks.set(head, check); + } + return check; +} + +/** Verified NIP-OA owner of the profile head the session already retains, for + * display only, never authorization. Reads no agent profile: the profile pane + * owns live evidence (`useAgentOwnerEvidence`). Reads the owner's profile from + * the session directory, since row-selected profiles do not include owners. */ +export function useProfileOwner( + session: RelaySession | undefined, + pubkey: string | undefined, +): { id: string; profile: Profile | undefined } | undefined { + const profiles = pubkey ? session?.profiles : undefined; + const head = useSyncExternalStore( + profiles?.subscribe ?? noSubscribe, + profiles?.event && pubkey ? () => profiles.event?.(pubkey) : none, + profiles?.event && pubkey ? () => profiles.event?.(pubkey) : none, + ); + const [, settled] = useState(); + useEffect(() => { + if (!head || results.has(head)) return; + let active = true; + void verify(head).then(() => { + if (active) settled(head); + }); + return () => { + active = false; + }; + }, [head]); + const owner = head ? results.get(head)?.owner : undefined; + useEffect(() => { + if (owner) void profiles?.ensure([owner], "background").catch(() => {}); + }, [profiles, owner]); + const profile = useSyncExternalStore( + owner && profiles ? profiles.subscribe : noSubscribe, + owner && profiles ? () => profiles.snapshot().get(owner) : none, + owner && profiles ? () => profiles.snapshot().get(owner) : none, + ); + return owner ? { id: owner, profile } : undefined; +}