diff --git a/desktop/src/features/passport/lib/badges.test.mjs b/desktop/src/features/passport/lib/badges.test.mjs new file mode 100644 index 0000000000..f27b3b1618 --- /dev/null +++ b/desktop/src/features/passport/lib/badges.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeAgentBadges } from "./badges.ts"; + +const NOW = 1_800_000_000; +const DAY = 86_400; + +function baseInputs(overrides = {}) { + return { + activeTurnCount: 0, + channelCount: 0, + firstSeenAt: null, + hasAbout: false, + hasAvatar: false, + hasHandle: false, + hasName: false, + hasOwner: false, + memoryCount: null, + noteCount: 0, + noteHours: [], + reactionCount: 0, + now: NOW, + ...overrides, + }; +} + +function badgeIds(badges) { + return badges.map((badge) => badge.id); +} + +test("a blank record earns no badges", () => { + assert.deepEqual(computeAgentBadges(baseInputs()), []); +}); + +test("trust badges reflect provenance facts", () => { + const badges = computeAgentBadges( + baseInputs({ + hasAbout: true, + hasAvatar: true, + hasHandle: true, + hasName: true, + hasOwner: true, + }), + ); + assert.deepEqual(badgeIds(badges), [ + "registered-operator", + "verified-handle", + "full-papers", + ]); +}); + +test("full papers requires every profile field", () => { + const badges = computeAgentBadges( + baseInputs({ hasAbout: true, hasAvatar: true, hasName: true }), + ); + assert.ok(!badgeIds(badges).includes("full-papers")); +}); + +test("tenure tiers escalate with days on record", () => { + const veteran = computeAgentBadges( + baseInputs({ firstSeenAt: NOW - 200 * DAY }), + ); + assert.equal(veteran[0].name, "Veteran"); + assert.equal(veteran[0].tier, 3); + + const settled = computeAgentBadges( + baseInputs({ firstSeenAt: NOW - 45 * DAY }), + ); + assert.equal(settled[0].name, "Settled"); + assert.equal(settled[0].tier, 1); + + const fresh = computeAgentBadges(baseInputs({ firstSeenAt: NOW - 5 * DAY })); + assert.deepEqual(fresh, []); +}); + +test("the like ladder honors its BadgeNation heritage", () => { + const godlike = computeAgentBadges(baseInputs({ reactionCount: 150 })); + assert.equal(godlike[0].name, "Godlike"); + assert.equal(godlike[0].tier, 3); + + const double = computeAgentBadges(baseInputs({ reactionCount: 5 })); + assert.equal(double[0].name, "Double Like"); +}); + +test("memories only grant badges when visible to the viewer", () => { + assert.deepEqual(computeAgentBadges(baseInputs({ memoryCount: null })), []); + const badges = computeAgentBadges(baseInputs({ memoryCount: 60 })); + assert.equal(badges[0].name, "Elephant Memory"); +}); + +test("time-of-day flair needs a repeated pattern", () => { + const night = computeAgentBadges( + baseInputs({ noteHours: [23, 0, 1, 2, 3, 4] }), + ); + assert.deepEqual(badgeIds(night), ["night-hawk"]); + + const sparse = computeAgentBadges(baseInputs({ noteHours: [23, 0] })); + assert.deepEqual(sparse, []); +}); + +test("on duty appears while the agent is actively working", () => { + const badges = computeAgentBadges(baseInputs({ activeTurnCount: 2 })); + assert.equal(badges[0].id, "on-duty"); + assert.match(badges[0].description, /2 channels/); +}); diff --git a/desktop/src/features/passport/lib/badges.ts b/desktop/src/features/passport/lib/badges.ts new file mode 100644 index 0000000000..4cd77beff9 --- /dev/null +++ b/desktop/src/features/passport/lib/badges.ts @@ -0,0 +1,204 @@ +/** + * Passport badges — commendations earned by an agent, computed from signals + * the client can verify (profile provenance, authored notes, reactions + * received, channel memberships, owner-visible memories). + * + * The point is trustability: trust badges surface verifiable identity facts + * (declared operator, verified handle, complete papers), tenure and activity + * badges show a track record, and flair adds personality. Badges are computed + * client-side from relay data — they are a lens on the record, not a + * server-granted award, so every description states the fact it reflects. + */ + +export type BadgeKind = "trust" | "tenure" | "activity" | "flair"; + +export type PassportBadge = { + /** Stable id; the UI maps this to an icon. Tiered badges share one id. */ + id: string; + name: string; + /** The verifiable fact this badge reflects, shown alongside the name. */ + description: string; + kind: BadgeKind; + /** 1–3 within a tiered family (bronze/silver/gold). Untiered = undefined. */ + tier?: 1 | 2 | 3; +}; + +export type AgentBadgeInputs = { + /** Channels the agent is currently working in (live signal). */ + activeTurnCount: number; + channelCount: number; + /** Unix seconds of the oldest fetched note; null when nothing authored. */ + firstSeenAt: number | null; + hasAbout: boolean; + hasAvatar: boolean; + hasHandle: boolean; + hasName: boolean; + hasOwner: boolean; + /** Engram count; null when the viewer cannot see memories (non-owner). */ + memoryCount: number | null; + /** Number of recent authored notes (relay window, up to 50). */ + noteCount: number; + /** Local hour (0–23) of each recent note, for time-of-day flair. */ + noteHours: number[]; + /** Total reactions received across recent notes. */ + reactionCount: number; + /** Injectable clock for tests; unix seconds. */ + now?: number; +}; + +const DAY_SECONDS = 86_400; + +function tierOf( + value: number, + thresholds: [number, number, number], +): 1 | 2 | 3 | null { + if (value >= thresholds[2]) return 3; + if (value >= thresholds[1]) return 2; + if (value >= thresholds[0]) return 1; + return null; +} + +/** Compute the badges an agent has earned, ordered trust-first. */ +export function computeAgentBadges(inputs: AgentBadgeInputs): PassportBadge[] { + const badges: PassportBadge[] = []; + const now = inputs.now ?? Math.floor(Date.now() / 1000); + + // ── Trust: verifiable identity facts ───────────────────────────────────── + if (inputs.hasOwner) { + badges.push({ + description: "Publicly declares a human operator", + id: "registered-operator", + kind: "trust", + name: "Registered Operator", + }); + } + if (inputs.hasHandle) { + badges.push({ + description: "Carries a verified community handle", + id: "verified-handle", + kind: "trust", + name: "Verified Handle", + }); + } + if ( + inputs.hasName && + inputs.hasAvatar && + inputs.hasAbout && + inputs.hasHandle + ) { + badges.push({ + description: "Complete papers: name, photo, bio, and handle", + id: "full-papers", + kind: "trust", + name: "Full Papers", + }); + } + + // ── Tenure: how long a track record exists ─────────────────────────────── + if (inputs.firstSeenAt !== null) { + const days = Math.floor((now - inputs.firstSeenAt) / DAY_SECONDS); + const tenureTier = tierOf(days, [30, 90, 180]); + if (tenureTier !== null) { + const names = ["Settled", "Resident", "Veteran"] as const; + badges.push({ + description: `On the record for ${days} days`, + id: "tenure", + kind: "tenure", + name: names[tenureTier - 1], + tier: tenureTier, + }); + } + } + + // ── Activity: contribution volume ──────────────────────────────────────── + const scribeTier = tierOf(inputs.noteCount, [10, 25, 50]); + if (scribeTier !== null) { + const names = ["Scribe", "Chronicler", "Historian"] as const; + badges.push({ + description: `Published ${inputs.noteCount} recent notes`, + id: "scribe", + kind: "activity", + name: names[scribeTier - 1], + tier: scribeTier, + }); + } + + const connectedTier = tierOf(inputs.channelCount, [3, 6, 10]); + if (connectedTier !== null) { + const names = ["Connected", "Well Connected", "Ambassador"] as const; + badges.push({ + description: `Member of ${inputs.channelCount} channels`, + id: "well-connected", + kind: "activity", + name: names[connectedTier - 1], + tier: connectedTier, + }); + } + + // Homage to the BadgeNation like ladder. + const likedTier = tierOf(inputs.reactionCount, [5, 20, 100]); + if (likedTier !== null) { + const names = ["Double Like", "Mega Like", "Godlike"] as const; + badges.push({ + description: `${inputs.reactionCount} reactions on recent notes`, + id: "liked", + kind: "activity", + name: names[likedTier - 1], + tier: likedTier, + }); + } + + if (inputs.memoryCount !== null) { + const memoryTier = tierOf(inputs.memoryCount, [10, 50, 200]); + if (memoryTier !== null) { + const names = [ + "Keeps Notes", + "Elephant Memory", + "Living Archive", + ] as const; + badges.push({ + description: `Holds ${inputs.memoryCount} memories`, + id: "elephant-memory", + kind: "activity", + name: names[memoryTier - 1], + tier: memoryTier, + }); + } + } + + // ── Flair: personality from the record ─────────────────────────────────── + const lateNotes = inputs.noteHours.filter( + (hour) => hour >= 23 || hour < 5, + ).length; + if (lateNotes >= 5) { + badges.push({ + description: `${lateNotes} recent notes posted late at night`, + id: "night-hawk", + kind: "flair", + name: "Night Hawk", + }); + } + const earlyNotes = inputs.noteHours.filter( + (hour) => hour >= 5 && hour < 7, + ).length; + if (earlyNotes >= 5) { + badges.push({ + description: `${earlyNotes} recent notes posted before 7am`, + id: "early-bird", + kind: "flair", + name: "Early Bird", + }); + } + if (inputs.activeTurnCount > 0) { + badges.push({ + description: `Working in ${inputs.activeTurnCount} ${ + inputs.activeTurnCount === 1 ? "channel" : "channels" + } right now`, + id: "on-duty", + kind: "flair", + name: "On Duty", + }); + } + + return badges; +} diff --git a/desktop/src/features/passport/ui/PassportBadges.tsx b/desktop/src/features/passport/ui/PassportBadges.tsx new file mode 100644 index 0000000000..fd5abf569b --- /dev/null +++ b/desktop/src/features/passport/ui/PassportBadges.tsx @@ -0,0 +1,101 @@ +import { + Activity, + BadgeCheck, + Brain, + Feather, + Heart, + type LucideIcon, + Medal, + Moon, + Network, + ScrollText, + ShieldCheck, + Sunrise, +} from "lucide-react"; + +import type { BadgeKind, PassportBadge } from "@/features/passport/lib/badges"; +import { cn } from "@/shared/lib/cn"; + +const BADGE_ICONS: Record = { + "early-bird": Sunrise, + "elephant-memory": Brain, + "full-papers": ScrollText, + liked: Heart, + "night-hawk": Moon, + "on-duty": Activity, + "registered-operator": ShieldCheck, + scribe: Feather, + tenure: Medal, + "verified-handle": BadgeCheck, + "well-connected": Network, +}; + +const KIND_TONES: Record = { + activity: "border-chart-3/60 text-chart-3", + flair: "border-chart-4/60 text-chart-4", + tenure: "border-chart-5/60 text-chart-5", + trust: "border-primary/60 text-primary", +}; + +/** + * Commendations pinned under the passport: each medallion is an icon in a + * double ring, with the badge name and the verifiable fact it reflects. Tier + * pips show progression within a badge family. + */ +export function PassportBadgeList({ badges }: { badges: PassportBadge[] }) { + return ( + + ); +} + +function TierPips({ tier }: { tier: 1 | 2 | 3 }) { + return ( + + {[1, 2, 3].map((pip) => ( + + ))} + + ); +} diff --git a/desktop/src/features/passport/ui/PassportScreen.tsx b/desktop/src/features/passport/ui/PassportScreen.tsx index fa8abb5d45..495dfb54c0 100644 --- a/desktop/src/features/passport/ui/PassportScreen.tsx +++ b/desktop/src/features/passport/ui/PassportScreen.tsx @@ -8,6 +8,7 @@ import { useAgentMemoryQuery, useIsManagedAgent, } from "@/features/agent-memory/hooks"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { useManagedAgentsQuery, useRelayAgentsQuery, @@ -15,10 +16,12 @@ import { import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; +import { computeAgentBadges } from "@/features/passport/lib/badges"; import { loadTravelLog, normalizeRelayUrl, } from "@/features/passport/lib/travelLog"; +import { PassportBadgeList } from "@/features/passport/ui/PassportBadges"; import { AgentCrewList, type CrewAgent, @@ -36,7 +39,10 @@ import { useUsersBatchQuery, } from "@/features/profile/hooks"; import { PassportCard } from "@/features/profile/ui/PassportCard"; -import { useMyNotesQuery } from "@/features/pulse/hooks"; +import { + useMyNotesQuery, + usePulseReactionsQuery, +} from "@/features/pulse/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { safeNpub } from "@/shared/lib/nostrUtils"; @@ -235,6 +241,55 @@ export function PassportScreen({ const memoryCount = memoryQuery.data ? memoryQuery.data.memories.length + (memoryQuery.data.core ? 1 : 0) : null; + + // Badge inputs: computed from the same record the rest of the page shows. + const allNotes = React.useMemo( + () => notesQuery.data?.notes ?? [], + [notesQuery.data], + ); + const noteIds = React.useMemo( + () => allNotes.map((note) => note.id), + [allNotes], + ); + const reactionsQuery = usePulseReactionsQuery(isAgent ? noteIds : []); + const activeTurnCount = useAgentWorking(isAgent ? pubkey : null).channels + .length; + const badges = React.useMemo(() => { + if (!isAgent) { + return []; + } + let reactionCount = 0; + for (const state of reactionsQuery.data?.values() ?? []) { + reactionCount += state.count; + } + return computeAgentBadges({ + activeTurnCount, + channelCount: memberChannels.length, + firstSeenAt: + allNotes.length > 0 + ? Math.min(...allNotes.map((note) => note.createdAt)) + : null, + hasAbout: Boolean(profile?.about?.trim()), + hasAvatar: Boolean(profile?.avatarUrl), + hasHandle: Boolean(profile?.nip05Handle?.trim()), + hasName: Boolean(profile?.displayName?.trim()), + hasOwner: profile?.ownerPubkey != null, + memoryCount, + noteCount: allNotes.length, + noteHours: allNotes.map((note) => + new Date(note.createdAt * 1_000).getHours(), + ), + reactionCount, + }); + }, [ + activeTurnCount, + allNotes, + isAgent, + memberChannels.length, + memoryCount, + profile, + reactionsQuery.data, + ]); const recordLoading = channelsQuery.isLoading || notesQuery.isLoading || contactsQuery.isLoading; const hasAgentRecord = @@ -306,6 +361,15 @@ export function PassportScreen({
+ {badges.length > 0 ? ( + + + + ) : null} + {visas.length > 0 ? (