From dae8e3b70762ddbcc63edcaa5e1ce7021f8ca222 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 4 Aug 2026 14:32:09 +0200 Subject: [PATCH] feat(desktop): add Passport main screen with travel record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /passport route showing the passport card beside the holder's history: community visas and channel stamps, recent notes, follow list, and an operator-only agent record. Depth follows access — viewers see the public record, holders and agent operators see everything available. Reachable via "View full passport" on the passport dialog. Signed-off-by: Thomas Petersen --- desktop/scripts/check-pubkey-truncation.mjs | 2 + desktop/src/app/routes.ts | 1 + desktop/src/app/routes/passport.tsx | 40 ++ .../passport/ui/PassportRecordSections.tsx | 202 ++++++++++ .../features/passport/ui/PassportScreen.tsx | 358 ++++++++++++++++++ .../features/profile/ui/PassportDialog.tsx | 44 ++- desktop/tailwind.config.js | 7 + 7 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 desktop/src/app/routes/passport.tsx create mode 100644 desktop/src/features/passport/ui/PassportRecordSections.tsx create mode 100644 desktop/src/features/passport/ui/PassportScreen.tsx diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index d65db13545..ff9929082e 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -28,6 +28,8 @@ const overrides = new Set([ "src/features/messages/lib/threadPanel.ts:395", "src/features/projects/ui/ProjectsView.tsx:166", "src/features/projects/ui/ProjectsOverviewPanel.tsx:209", + "src/features/passport/ui/PassportRecordSections.tsx:156", + "src/features/passport/ui/PassportScreen.tsx:127", ]); await runPubkeyTruncationCheck({ diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..d2bc72341e 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -3,6 +3,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), + route("/passport", "passport.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), diff --git a/desktop/src/app/routes/passport.tsx b/desktop/src/app/routes/passport.tsx new file mode 100644 index 0000000000..dc8bcb3b81 --- /dev/null +++ b/desktop/src/app/routes/passport.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +type PassportRouteSearch = { + /** Passport holder. Omitted = the current user's own passport. */ + pubkey?: string; +}; + +function validatePassportSearch( + search: Record, +): PassportRouteSearch { + return { + pubkey: + typeof search.pubkey === "string" && search.pubkey.length > 0 + ? search.pubkey + : undefined, + }; +} + +const PassportScreen = React.lazy(async () => { + const module = await import("@/features/passport/ui/PassportScreen"); + return { default: module.PassportScreen }; +}); + +export const Route = createFileRoute("/passport")({ + validateSearch: validatePassportSearch, + component: PassportRouteComponent, +}); + +function PassportRouteComponent() { + const { pubkey } = Route.useSearch(); + + return ( + }> + + + ); +} diff --git a/desktop/src/features/passport/ui/PassportRecordSections.tsx b/desktop/src/features/passport/ui/PassportRecordSections.tsx new file mode 100644 index 0000000000..086c253c59 --- /dev/null +++ b/desktop/src/features/passport/ui/PassportRecordSections.tsx @@ -0,0 +1,202 @@ +import type * as React from "react"; +import { ArrowUpRight } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { UserNote } from "@/shared/api/socialTypes"; +import type { UserProfileSummary } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { cn } from "@/shared/lib/cn"; + +/** Format a date the way a passport stamp would: `12 MAR 2026`. */ +export function formatStampDate(date: Date): string { + return date + .toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + }) + .toUpperCase(); +} + +// ── Section frame ──────────────────────────────────────────────────────────── + +export function RecordSection({ + children, + label, + trailing, +}: { + children: React.ReactNode; + label: string; + trailing?: React.ReactNode; +}) { + return ( +
+
+

+ {label} +

+
+ {trailing ? ( + + {trailing} + + ) : null} +
+ {children} +
+ ); +} + +// ── Stamps ─────────────────────────────────────────────────────────────────── + +// Passport-stamp "inks": rotate through the chart tokens so a page of stamps +// reads as collected over time rather than as a uniform list. +const STAMP_TONES = [ + "border-chart-1 text-chart-1", + "border-chart-2 text-chart-2", + "border-chart-3 text-chart-3", + "border-chart-4 text-chart-4", + "border-chart-5 text-chart-5", +] as const; + +const STAMP_ROTATIONS = [ + "-rotate-2", + "rotate-1", + "rotate-2", + "-rotate-1", + "rotate-0", +] as const; + +export function PassportStamp({ + index, + onClick, + subtitle, + testId, + title, +}: { + /** Position in the stamp page — picks a stable ink color and tilt. */ + index: number; + onClick?: () => void; + subtitle?: string | null; + testId?: string; + title: string; +}) { + const tone = STAMP_TONES[index % STAMP_TONES.length]; + const rotation = STAMP_ROTATIONS[index % STAMP_ROTATIONS.length]; + const Element = onClick ? "button" : "div"; + + return ( + + + {title} + {onClick ? ( + + {subtitle ? ( + + {subtitle} + + ) : null} + + ); +} + +export function StampPage({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +// ── Notes ledger ───────────────────────────────────────────────────────────── + +export function NoteRecordList({ notes }: { notes: UserNote[] }) { + return ( +
    + {notes.map((note) => ( +
  • + + {formatStampDate(new Date(note.createdAt * 1_000))} + +

    + {note.content} +

    +
  • + ))} +
+ ); +} + +// ── Following strip ────────────────────────────────────────────────────────── + +export function FollowingStrip({ + contactPubkeys, + profiles, +}: { + contactPubkeys: string[]; + profiles: Record; +}) { + const shown = contactPubkeys.slice(0, 12); + + return ( +
+
+ {shown.map((contactPubkey) => { + const profile = profiles[contactPubkey.toLowerCase()]; + const label = + profile?.displayName ?? + profile?.name ?? + truncatePubkey(contactPubkey); + return ( + + ); + })} +
+ {contactPubkeys.length > shown.length ? ( + + +{contactPubkeys.length - shown.length} more + + ) : null} +
+ ); +} + +// ── Empty record ───────────────────────────────────────────────────────────── + +export function EmptyRecord({ displayName }: { displayName: string }) { + return ( +
+

No record on this community yet

+

+ Stamps appear here as {displayName} joins channels, posts notes, and + travels between communities. +

+
+ ); +} diff --git a/desktop/src/features/passport/ui/PassportScreen.tsx b/desktop/src/features/passport/ui/PassportScreen.tsx new file mode 100644 index 0000000000..fad01954f3 --- /dev/null +++ b/desktop/src/features/passport/ui/PassportScreen.tsx @@ -0,0 +1,358 @@ +import * as React from "react"; +import { Activity, Copy, UserRound } from "lucide-react"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + useAgentMemoryQuery, + useIsManagedAgent, +} from "@/features/agent-memory/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + EmptyRecord, + FollowingStrip, + formatStampDate, + NoteRecordList, + PassportStamp, + RecordSection, + StampPage, +} from "@/features/passport/ui/PassportRecordSections"; +import { + useContactListQuery, + useUserProfileQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { PassportCard } from "@/features/profile/ui/PassportCard"; +import { useMyNotesQuery } from "@/features/pulse/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +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 { Skeleton } from "@/shared/ui/skeleton"; + +const RECENT_NOTES_SHOWN = 6; + +type PassportScreenProps = { + /** Holder pubkey from the route search; omitted = the viewer's own passport. */ + pubkey?: string; +}; + +/** + * The Passport main screen: the identity card as the hero, next to the + * holder's record of travel — communities and channels rendered as collected + * stamps, plus notes and the follow list. Depth is honest to access: the + * viewer sees more for themselves and for agents they operate (memories, + * activity), and only the public record for everyone else. + */ +export function PassportScreen({ + pubkey: pubkeyFromSearch, +}: PassportScreenProps) { + const identityQuery = useIdentityQuery(); + const selfPubkey = identityQuery.data?.pubkey ?? null; + const pubkey = pubkeyFromSearch ?? selfPubkey; + const pubkeyLower = pubkey?.toLowerCase() ?? null; + const isSelf = + pubkeyLower !== null && pubkeyLower === selfPubkey?.toLowerCase(); + + const profileQuery = useUserProfileQuery(pubkey ?? undefined); + const profile = profileQuery.data; + const summaryQuery = useUsersBatchQuery(pubkey ? [pubkey] : []); + const summary = pubkeyLower + ? summaryQuery.data?.profiles[pubkeyLower] + : undefined; + + const relayAgentsQuery = useRelayAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgent = relayAgentsQuery.data?.find( + (agent) => agent.pubkey.toLowerCase() === pubkeyLower, + ); + const managedAgent = managedAgentsQuery.data?.find( + (agent) => agent.pubkey.toLowerCase() === pubkeyLower, + ); + const isAgent = Boolean( + relayAgent ?? + managedAgent ?? + (summary?.isAgent || profile?.ownerPubkey != null), + ); + const isLocallyManaged = useIsManagedAgent(isAgent ? pubkey : null); + const viewerIsOwner = + isAgent && + (isLocallyManaged === true || + (profile?.ownerPubkey != null && + profile.ownerPubkey.toLowerCase() === selfPubkey?.toLowerCase())); + + // Record sources. Channel membership is derived from the viewer's channel + // list, so private rooms only ever appear to viewers already in them. + const { activeCommunity, communities } = useCommunities(); + const channelsQuery = useChannelsQuery(); + const notesQuery = useMyNotesQuery(pubkey ?? undefined); + const contactsQuery = useContactListQuery(pubkey ?? undefined); + const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: viewerIsOwner }); + const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); + const { goChannel } = useAppNavigation(); + + const memberChannels = React.useMemo(() => { + const links = new Map(); + relayAgent?.channels.forEach((name, index) => { + const id = relayAgent.channelIds[index] ?? name; + links.set(id, { id, name }); + }); + for (const channel of channelsQuery.data ?? []) { + if (channel.channelType === "dm") { + continue; + } + const isMember = channel.memberPubkeys.some( + (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, + ); + if (isMember) { + links.set(channel.id, { id: channel.id, name: channel.name }); + } + } + return [...links.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); + }, [channelsQuery.data, pubkeyLower, relayAgent]); + + const contactPubkeys = React.useMemo( + () => contactsQuery.data?.contacts.map((contact) => contact.pubkey) ?? [], + [contactsQuery.data], + ); + const contactProfilesQuery = useUsersBatchQuery(contactPubkeys.slice(0, 12)); + + // Card fields, resolved the same way the passport dialog resolves them. + const displayName = + profile?.displayName?.trim() || + (pubkey ? truncatePubkey(pubkey) : "Unknown"); + const ownerProfileQuery = useUserProfileQuery( + profile?.ownerPubkey ?? undefined, + ); + const operatorName = profile?.ownerPubkey + ? ownerProfileQuery.data?.nip05Handle?.trim() || + ownerProfileQuery.data?.displayName?.trim() || + truncatePubkey(profile.ownerPubkey) + : null; + const npub = pubkey ? (safeNpub(pubkey) ?? pubkey) : null; + + const notes = notesQuery.data?.notes.slice(0, RECENT_NOTES_SHOWN) ?? []; + const memoryCount = memoryQuery.data + ? memoryQuery.data.memories.length + (memoryQuery.data.core ? 1 : 0) + : null; + const recordLoading = + channelsQuery.isLoading || notesQuery.isLoading || contactsQuery.isLoading; + const hasAgentRecord = + viewerIsOwner && (managedAgent !== undefined || memoryCount !== null); + const recordIsEmpty = + !recordLoading && + memberChannels.length === 0 && + notes.length === 0 && + contactPubkeys.length === 0 && + !isSelf && + !hasAgentRecord; + + const copyKey = async () => { + if (!npub) { + return; + } + await writeTextToClipboard(npub); + toast.success("Identity key copied."); + }; + + if (!pubkey) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Passport

+

+ {isSelf + ? "Your portable identity and its record of travel across communities." + : `${displayName}'s portable identity — the record shows what is visible to you.`} +

+
+ +
+ {/* The card column stays pinned while the record scrolls. */} +
+ +
+ +
+
+ +
+ {isSelf && communities.length > 0 ? ( + + + {communities.map((community, index) => ( + + ))} + + + ) : null} + + {memberChannels.length > 0 ? ( + + + {memberChannels.map((channel, index) => ( + void goChannel(channel.id)} + testId="passport-channel-stamp" + title={`#${channel.name}`} + /> + ))} + + + ) : null} + + {hasAgentRecord ? ( + +
+
+ {managedAgent ? ( + <> + + {managedAgent.status} + + + {managedAgent.runtime ?? managedAgent.agentCommand} + + {managedAgent.model ? ( + + {managedAgent.model} + + ) : null} + + ) : null} + {memoryCount !== null ? ( + + {memoryCount} + + ) : null} +
+ {canOpenAgentActivity(pubkey) ? ( +
+ +
+ ) : null} +
+
+ ) : null} + + {notes.length > 0 ? ( + + + + ) : null} + + {contactPubkeys.length > 0 ? ( + + + + ) : null} + + {recordLoading && memberChannels.length === 0 ? ( +
+ + + +
+ ) : null} + {recordIsEmpty ? : null} + + {!isSelf && !viewerIsOwner ? ( +

+

+ ) : null} +
+
+
+
+ ); +} + +function AgentRecordField({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ); +} diff --git a/desktop/src/features/profile/ui/PassportDialog.tsx b/desktop/src/features/profile/ui/PassportDialog.tsx index c56797133f..791ffc35c7 100644 --- a/desktop/src/features/profile/ui/PassportDialog.tsx +++ b/desktop/src/features/profile/ui/PassportDialog.tsx @@ -1,4 +1,5 @@ -import { Copy } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { BookOpenText, Copy } from "lucide-react"; import { toast } from "sonner"; import { useCommunities } from "@/features/communities/useCommunities"; @@ -41,6 +42,7 @@ export function PassportDialog({ ...cardProps }: PassportDialogProps) { const { activeCommunity } = useCommunities(); + const navigate = useNavigate(); const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); const operatorName = ownerPubkey ? ownerProfileQuery.data?.nip05Handle?.trim() || @@ -54,6 +56,14 @@ export function PassportDialog({ toast.success("Identity key copied."); }; + const openFullPassport = () => { + onOpenChange(false); + void navigate({ + search: { pubkey: cardProps.pubkey }, + to: "/passport", + }); + }; + return ( - +
+ + +
); diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 8905fc16ce..a4263fd297 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -77,6 +77,13 @@ export default { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))",