mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
feat(desktop): add Passport identity card for people and agents
Every profile with a pubkey gets a Passport action that opens a passport-style card: photo, HUMAN/AGENT type chip, operator (for agents), issuing community, a scannable npub QR, and a machine-readable zone carrying the full npub. Makes the "your keypair is your portable identity" model tangible, building on the trading card foundation. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
60
desktop/src/features/profile/lib/passportMrz.test.mjs
Normal file
60
desktop/src/features/profile/lib/passportMrz.test.mjs
Normal file
@@ -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<<given convention and MRZ charset", () => {
|
||||
assert.equal(mrzName("Tyler"), "TYLER");
|
||||
assert.equal(mrzName("Johnny Apple Seed"), "JOHNNY<<APPLE<SEED");
|
||||
assert.equal(mrzName("Zoë O'Brien"), "ZOE<<OBRIEN");
|
||||
assert.equal(mrzName(" "), "");
|
||||
});
|
||||
|
||||
test("buildPassportMrz emits three fixed-width lines carrying the full npub", () => {
|
||||
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<BUZJOHNNY<<APPLESEED"));
|
||||
|
||||
// The npub survives round-trip: strip filler, lowercase, rejoin.
|
||||
const key = (lines[1] + lines[2]).replaceAll("<", "").toLowerCase();
|
||||
assert.equal(key, NPUB);
|
||||
});
|
||||
|
||||
test("agent passports lead with the A document code", () => {
|
||||
const [nameLine] = buildPassportMrz({
|
||||
displayName: "Nest Keeper",
|
||||
isAgent: true,
|
||||
npub: NPUB,
|
||||
});
|
||||
assert.ok(nameLine.startsWith("A<BUZNEST<<KEEPER"));
|
||||
});
|
||||
|
||||
test("overlong names truncate to the line width instead of overflowing", () => {
|
||||
const [nameLine] = buildPassportMrz({
|
||||
displayName: "A".repeat(80),
|
||||
isAgent: false,
|
||||
npub: NPUB,
|
||||
});
|
||||
assert.equal(nameLine.length, PASSPORT_MRZ_LINE_LENGTH);
|
||||
});
|
||||
66
desktop/src/features/profile/lib/passportMrz.ts
Normal file
66
desktop/src/features/profile/lib/passportMrz.ts
Normal file
@@ -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<<given convention) and `<`
|
||||
* between the remaining words. Diacritics are transliterated by stripping
|
||||
* combining marks.
|
||||
*/
|
||||
export function mrzName(displayName: string): string {
|
||||
const words = displayName
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.split(/\s+/)
|
||||
.map((word) => 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)}<BUZ${mrzName(displayName)}`;
|
||||
const key = npub.toUpperCase();
|
||||
return [
|
||||
padMrzLine(nameLine),
|
||||
padMrzLine(key.slice(0, PASSPORT_MRZ_LINE_LENGTH)),
|
||||
padMrzLine(key.slice(PASSPORT_MRZ_LINE_LENGTH)),
|
||||
];
|
||||
}
|
||||
239
desktop/src/features/profile/ui/PassportCard.tsx
Normal file
239
desktop/src/features/profile/ui/PassportCard.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { buildPassportMrz } from "@/features/profile/lib/passportMrz";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
|
||||
|
||||
export type PassportCardProps = {
|
||||
avatarUrl: string | null;
|
||||
className?: string;
|
||||
/** Issuing community name — the server this identity is currently visiting. */
|
||||
communityName: string | null;
|
||||
displayName: string;
|
||||
/** NIP-05 handle, when the profile has one. */
|
||||
handle: string | null;
|
||||
isAgent: boolean;
|
||||
/** Owner handle for agent passports (the human the agent acts for). */
|
||||
operatorName: string | null;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Buzz Passport — a portable identity card for a person or agent.
|
||||
*
|
||||
* One mental model: your keypair IS your passport. It travels with you to
|
||||
* other communities, and your agents carry passports of their own that name
|
||||
* you as operator. The card renders that idea literally: photo, holder
|
||||
* fields, an issuing community, a scannable key, and a machine-readable zone
|
||||
* carrying the full npub (never truncated — this is a trust surface).
|
||||
*
|
||||
* Visual language stays on Buzz basics: Catppuccin semantic tokens, stock
|
||||
* rem type ramp, rounded-2xl card geometry shared with the identity cards.
|
||||
*/
|
||||
export function PassportCard({
|
||||
avatarUrl,
|
||||
className,
|
||||
communityName,
|
||||
displayName,
|
||||
handle,
|
||||
isAgent,
|
||||
operatorName,
|
||||
pubkey,
|
||||
}: PassportCardProps) {
|
||||
const npub = safeNpub(pubkey) ?? pubkey;
|
||||
const mrzLines = buildPassportMrz({ displayName, isAgent, npub });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-[22rem] max-w-full overflow-hidden rounded-3xl border border-border/70 bg-card text-card-foreground shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
data-testid="passport-card"
|
||||
>
|
||||
{/* Tinted wash + top highlight, both from semantic tokens so the card
|
||||
adapts to Latte/Macchiato without theme-specific branches. */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-primary/10" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-background/50 to-transparent" />
|
||||
<GuillochePattern />
|
||||
{/* Holographic glint that sweeps across on hover. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 -left-full w-1/2 -skew-x-12 bg-gradient-to-r from-transparent via-primary/20 to-transparent transition-transform duration-700 ease-out group-hover:translate-x-[300%] motion-reduce:hidden"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-5 p-6">
|
||||
<header className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-2xs font-semibold tracking-[0.35em] text-muted-foreground">
|
||||
BUZZ · PASSPORT
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full border border-primary/40 px-2 py-0.5 text-3xs font-semibold tracking-[0.2em] text-primary"
|
||||
data-testid="passport-holder-kind"
|
||||
>
|
||||
{isAgent ? "AGENT" : "HUMAN"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-5">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-28 w-28 rounded-2xl border border-border/60 text-2xl shadow-none"
|
||||
iconClassName="h-10 w-10"
|
||||
imageClassName="rounded-2xl"
|
||||
label={displayName}
|
||||
testId="passport-photo"
|
||||
/>
|
||||
|
||||
<dl className="flex min-w-0 flex-1 flex-col justify-between gap-2 py-0.5">
|
||||
<PassportField label="Name" testId="passport-name">
|
||||
<span className="text-base font-semibold tracking-tight text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
</PassportField>
|
||||
{handle ? (
|
||||
<PassportField label="Handle" testId="passport-handle">
|
||||
{handle}
|
||||
</PassportField>
|
||||
) : null}
|
||||
{isAgent && operatorName ? (
|
||||
<PassportField label="Operator" testId="passport-operator">
|
||||
{operatorName}
|
||||
</PassportField>
|
||||
) : null}
|
||||
<PassportField label="Community" testId="passport-community">
|
||||
{communityName ?? "—"}
|
||||
</PassportField>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
|
||||
Identity key
|
||||
</span>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
This key is {isAgent ? "this agent's" : "your"} passport — it
|
||||
travels to every community and conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-xl border border-border/60 bg-background/60 p-1.5">
|
||||
<StyledQrCode
|
||||
backgroundColor="transparent"
|
||||
foregroundColor="hsl(var(--foreground))"
|
||||
size={84}
|
||||
title={`${displayName} identity key QR code`}
|
||||
value={npub}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine-readable zone — carries the FULL npub (uppercased bech32
|
||||
stays decodable), so the passport never truncates the identity. */}
|
||||
<div
|
||||
className="relative border-t border-border/60 bg-background/70 px-6 py-4 font-mono text-2xs leading-relaxed tracking-normal text-foreground/80"
|
||||
data-testid="passport-mrz"
|
||||
>
|
||||
{mrzLines.map((line) => (
|
||||
<div className="truncate whitespace-pre" key={line}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassportField({
|
||||
children,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<dt className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className="truncate text-sm font-medium text-foreground/90"
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full text-primary opacity-[0.07]"
|
||||
fill="none"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 352 440"
|
||||
>
|
||||
{GUILLOCHE_BANDS.flat().map((ellipse) => (
|
||||
<ellipse
|
||||
cx={ellipse.cx}
|
||||
cy={ellipse.cy}
|
||||
key={`${ellipse.cx}-${ellipse.cy}-${ellipse.rx}`}
|
||||
rx={ellipse.rx}
|
||||
ry={ellipse.ry}
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.75"
|
||||
transform={`rotate(${ellipse.rotate} ${ellipse.cx} ${ellipse.cy})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
90
desktop/src/features/profile/ui/PassportDialog.tsx
Normal file
90
desktop/src/features/profile/ui/PassportDialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-fit justify-items-center"
|
||||
data-testid="passport-dialog"
|
||||
showCloseButton={false}
|
||||
surface="none"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{`${cardProps.displayName}'s passport`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Portable identity card carrying the full public key, handle, and
|
||||
issuing community.
|
||||
</DialogDescription>
|
||||
<PassportCard
|
||||
{...cardProps}
|
||||
communityName={activeCommunity?.name ?? null}
|
||||
operatorName={operatorName}
|
||||
/>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
data-testid="passport-copy-key"
|
||||
onClick={() => void copyKey()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Copy identity key
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<ProfileSelfPrimaryActions onOpenPassport={onOpenPassport} />
|
||||
) : null}
|
||||
|
||||
{showTabSection ? (
|
||||
@@ -457,6 +468,19 @@ export function ProfileSummaryView({
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{pubkey ? (
|
||||
<PassportDialog
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={displayName}
|
||||
handle={profile?.nip05Handle ?? null}
|
||||
isAgent={isBot}
|
||||
onOpenChange={setPassportOpen}
|
||||
open={passportOpen}
|
||||
ownerPubkey={isBot ? (profile?.ownerPubkey ?? null) : null}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof useUnfollowMutation>;
|
||||
}) {
|
||||
@@ -86,6 +89,14 @@ export function ProfilePrimaryActions({
|
||||
testId="user-profile-message"
|
||||
/>
|
||||
) : null}
|
||||
{onOpenPassport ? (
|
||||
<ProfileQuickAction
|
||||
icon={IdCard}
|
||||
label="Passport"
|
||||
onClick={onOpenPassport}
|
||||
testId="user-profile-passport"
|
||||
/>
|
||||
) : null}
|
||||
{canEditAgent ? (
|
||||
<ProfileQuickAction
|
||||
icon={Pencil}
|
||||
@@ -169,6 +180,28 @@ export function ProfilePersonaPrimaryActions({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions when viewing your own profile — today just the Passport. A
|
||||
* dedicated row so self profiles gain identity actions without the
|
||||
* follow/message set meant for other people.
|
||||
*/
|
||||
export function ProfileSelfPrimaryActions({
|
||||
onOpenPassport,
|
||||
}: {
|
||||
onOpenPassport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<ProfileQuickAction
|
||||
icon={IdCard}
|
||||
label="Passport"
|
||||
onClick={onOpenPassport}
|
||||
testId="user-profile-passport"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileQuickAction({
|
||||
active,
|
||||
disabled,
|
||||
|
||||
Reference in New Issue
Block a user