diff --git a/desktop/src/features/profile/lib/passportMrz.test.mjs b/desktop/src/features/profile/lib/passportMrz.test.mjs new file mode 100644 index 0000000000..3c25d3d358 --- /dev/null +++ b/desktop/src/features/profile/lib/passportMrz.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildPassportMrz, + mrzName, + PASSPORT_MRZ_LINE_LENGTH, + passportDocumentCode, +} from "./passportMrz.ts"; + +const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + +test("document code distinguishes people from agents", () => { + assert.equal(passportDocumentCode(false), "P"); + assert.equal(passportDocumentCode(true), "A"); +}); + +test("mrzName uses surname< { + assert.equal(mrzName("Tyler"), "TYLER"); + assert.equal(mrzName("Johnny Apple Seed"), "JOHNNY< { + const lines = buildPassportMrz({ + displayName: "Johnny Appleseed", + isAgent: false, + npub: NPUB, + }); + + assert.equal(lines.length, 3); + for (const line of lines) { + assert.equal(line.length, PASSPORT_MRZ_LINE_LENGTH); + assert.match(line, /^[A-Z0-9<]+$/); + } + assert.ok(lines[0].startsWith("P { + const [nameLine] = buildPassportMrz({ + displayName: "Nest Keeper", + isAgent: true, + npub: NPUB, + }); + assert.ok(nameLine.startsWith("A { + const [nameLine] = buildPassportMrz({ + displayName: "A".repeat(80), + isAgent: false, + npub: NPUB, + }); + assert.equal(nameLine.length, PASSPORT_MRZ_LINE_LENGTH); +}); diff --git a/desktop/src/features/profile/lib/passportMrz.ts b/desktop/src/features/profile/lib/passportMrz.ts new file mode 100644 index 0000000000..8d02e79ad4 --- /dev/null +++ b/desktop/src/features/profile/lib/passportMrz.ts @@ -0,0 +1,66 @@ +/** + * Machine-readable zone (MRZ) lines for the Buzz Passport card. + * + * The MRZ is the passport's signature visual — but here it is also honest: + * lines 2–3 carry the holder's full npub (bech32 is case-insensitive, so the + * uppercase rendering remains decodable). This makes the passport a valid + * "full key" trust surface per the pubkey-truncation rule: nothing on the + * card abbreviates the identity. + */ + +export const PASSPORT_MRZ_LINE_LENGTH = 44; + +/** Document code by holder kind: P = person, A = agent. */ +export function passportDocumentCode(isAgent: boolean): "P" | "A" { + return isAgent ? "A" : "P"; +} + +/** + * Reduce a display name to the MRZ character set (A–Z, 0–9). Words are + * joined with `<<` after the first word (surname< word.replace(/[^A-Z0-9]/g, "")) + .filter((word) => word.length > 0); + + if (words.length === 0) { + return ""; + } + const [surname, ...given] = words; + return given.length > 0 ? `${surname}<<${given.join("<")}` : surname; +} + +function padMrzLine(line: string): string { + return line + .slice(0, PASSPORT_MRZ_LINE_LENGTH) + .padEnd(PASSPORT_MRZ_LINE_LENGTH, "<"); +} + +/** + * Build the three MRZ lines: a name/document line followed by the holder's + * full npub split across two lines. + */ +export function buildPassportMrz({ + displayName, + isAgent, + npub, +}: { + displayName: string; + isAgent: boolean; + npub: string; +}): [string, string, string] { + const nameLine = `${passportDocumentCode(isAgent)} + {/* Tinted wash + top highlight, both from semantic tokens so the card + adapts to Latte/Macchiato without theme-specific branches. */} +
+
+ + {/* Holographic glint that sweeps across on hover. */} + + ); +} + +function PassportField({ + children, + label, + testId, +}: { + children: React.ReactNode; + label: string; + testId: string; +}) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ); +} + +type GuillocheEllipse = { + cx: number; + cy: number; + rotate: number; + rx: number; + ry: number; +}; + +function guillocheBand({ + count, + start, + step, +}: { + count: number; + start: GuillocheEllipse; + step: GuillocheEllipse; +}): GuillocheEllipse[] { + return Array.from({ length: count }, (_, index) => ({ + cx: start.cx + step.cx * index, + cy: start.cy + step.cy * index, + rotate: start.rotate + step.rotate * index, + rx: start.rx + step.rx * index, + ry: start.ry + step.ry * index, + })); +} + +const GUILLOCHE_BANDS: GuillocheEllipse[][] = [ + guillocheBand({ + count: 9, + start: { cx: 300, cy: 60, rotate: -18, rx: 190, ry: 70 }, + step: { cx: -6, cy: 10, rotate: 4, rx: 18, ry: 9 }, + }), + guillocheBand({ + count: 9, + start: { cx: 40, cy: 400, rotate: 16, rx: 170, ry: 60 }, + step: { cx: 5, cy: -8, rotate: -3, rx: 16, ry: 8 }, + }), +]; + +/** + * Faint guilloché-style security lines, like the engraved curves on a real + * passport data page. Pure decoration (`aria-hidden`), drawn in the primary + * token at very low opacity. + */ +function GuillochePattern() { + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/PassportDialog.tsx b/desktop/src/features/profile/ui/PassportDialog.tsx new file mode 100644 index 0000000000..c56797133f --- /dev/null +++ b/desktop/src/features/profile/ui/PassportDialog.tsx @@ -0,0 +1,90 @@ +import { Copy } from "lucide-react"; +import { toast } from "sonner"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { + PassportCard, + type PassportCardProps, +} from "@/features/profile/ui/PassportCard"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/shared/ui/dialog"; + +type PassportDialogProps = Omit< + PassportCardProps, + "communityName" | "operatorName" +> & { + onOpenChange: (open: boolean) => void; + open: boolean; + /** Declared owner pubkey (NIP-OA) for agent passports. */ + ownerPubkey: string | null; +}; + +/** + * Passport viewer — the card floats alone over the backdrop (no dialog + * chrome), the same "the card is the hero" treatment as the agent trading + * card viewer. The issuing community and operator identity are resolved + * here so the card itself stays purely presentational. + */ +export function PassportDialog({ + onOpenChange, + open, + ownerPubkey, + ...cardProps +}: PassportDialogProps) { + const { activeCommunity } = useCommunities(); + const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); + const operatorName = ownerPubkey + ? ownerProfileQuery.data?.nip05Handle?.trim() || + ownerProfileQuery.data?.displayName?.trim() || + truncatePubkey(ownerPubkey) + : null; + const npub = safeNpub(cardProps.pubkey) ?? cardProps.pubkey; + + const copyKey = async () => { + await writeTextToClipboard(npub); + toast.success("Identity key copied."); + }; + + return ( + + + + {`${cardProps.displayName}'s passport`} + + + Portable identity card carrying the full public key, handle, and + issuing community. + + + + + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 057376a38c..9f3e657eeb 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -36,9 +36,11 @@ import { STATUS_DOT_MASK_CURVE, } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { PassportDialog } from "@/features/profile/ui/PassportDialog"; import { ProfilePersonaPrimaryActions, ProfilePrimaryActions, + ProfileSelfPrimaryActions, } from "@/features/profile/ui/UserProfilePrimaryActions"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -224,6 +226,12 @@ export function ProfileSummaryView({ }: ProfileSummaryViewProps) { const activeTurns = useAgentWorking(isBot ? pubkey : null).channels; + // The passport is available for any profile with a pubkey — self, other + // people, and agents alike. Persona drafts have no keypair yet, so no + // passport has been "issued" for them. + const [passportOpen, setPassportOpen] = React.useState(false); + const onOpenPassport = pubkey ? () => setPassportOpen(true) : undefined; + const showMemoriesTab = isOwner === true && Boolean(pubkey); const showInstructionBlock = isOwner === true && @@ -354,6 +362,7 @@ export function ProfileSummaryView({ canEditAgent={canEditAgent} followMutation={followMutation} onCreateCard={onCreateCard} + onOpenPassport={onOpenPassport} onEditAgent={handleEditAgent} agentActionDisabled={isAgentActionPending} agentActionLabel={ @@ -384,6 +393,8 @@ export function ProfileSummaryView({ pubkey={pubkey} unfollowMutation={unfollowMutation} /> + ) : isSelf && onOpenPassport ? ( + ) : null} {showTabSection ? ( @@ -457,6 +468,19 @@ export function ProfileSummaryView({ ) : null} ) : null} + + {pubkey ? ( + + ) : null}
); } diff --git a/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx index f03635ff18..661f3980e5 100644 --- a/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx +++ b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from "lucide-react"; import { + IdCard, MessageSquare, Pencil, Play, @@ -35,6 +36,7 @@ export function ProfilePrimaryActions({ onCreateCard, onEditAgent, onMessage, + onOpenPassport, pubkey, unfollowMutation, }: { @@ -50,6 +52,7 @@ export function ProfilePrimaryActions({ onCreateCard?: () => void; onEditAgent: () => void; onMessage?: () => void; + onOpenPassport?: () => void; pubkey: string; unfollowMutation: ReturnType; }) { @@ -86,6 +89,14 @@ export function ProfilePrimaryActions({ testId="user-profile-message" /> ) : null} + {onOpenPassport ? ( + + ) : null} {canEditAgent ? ( void; +}) { + return ( +
+ +
+ ); +} + function ProfileQuickAction({ active, disabled,