Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion src/features/messages/MessageRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof import("../agents/owner-attestation")>();
return { ...actual, attestedOwner: vi.fn(actual.attestedOwner) };
});

const row: ChannelMessage = {
id: "root",
Expand Down Expand Up @@ -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<string, Profile>([[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();
}
});
32 changes: 31 additions & 1 deletion src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -884,7 +899,16 @@ function MessageRowContent({
/>
) : (
<span className={styles.authorWithStatus}>
<strong className={styles.author}>{name}</strong>
<strong className={styles.author}>
{/* Inside the name, so the byline keeps the text baseline. */}
{agentAuthor && !stackPrevious && (
<RobotIcon
aria-hidden="true"
className={styles.authorAgentIcon}
/>
)}
{name}
</strong>
{session && !stackPrevious && (
<UserStatusDisplay
session={session}
Expand All @@ -895,6 +919,12 @@ function MessageRowContent({
)}
</span>
)}
{ownerLabel && (
<span className={styles.authorOwner}>
<span aria-hidden="true">· </span>
{ownerLabel}
</span>
)}
{!stackPrevious && (
<MessageTimestamp createdAt={row.createdAt} />
)}
Expand Down
12 changes: 12 additions & 0 deletions src/features/messages/Messages.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions src/features/profiles/useProfileOwner.ts
Original file line number Diff line number Diff line change
@@ -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<RelayEvent, Promise<string | undefined>>();
const results = new WeakMap<RelayEvent, { owner: string | undefined }>();

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<RelayEvent>();
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;
}
Loading