diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..73c34ad3dd 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -26,6 +26,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { recordCommunityVisit } from "@/features/passport/lib/travelLog"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -261,6 +262,13 @@ export function useCommunityInit( err, ); } + // Stamp the passport travel log now that the connection applied. The + // log intentionally survives community switches and removals — it is + // the passport's record of every server this identity has been on. + recordCommunityVisit({ + name: activeCommunity.name, + relayUrl: activeCommunity.relayUrl, + }); // Restore any turn state saved for this community (a prior A→B round- // trip). This runs after applyCommunity succeeds and before the app // renders so components see the restored timers on first render. diff --git a/desktop/src/features/passport/lib/travelLog.test.mjs b/desktop/src/features/passport/lib/travelLog.test.mjs new file mode 100644 index 0000000000..f909123174 --- /dev/null +++ b/desktop/src/features/passport/lib/travelLog.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeRelayUrl, upsertTravelLogEntry } from "./travelLog.ts"; + +test("first visit creates an entry with matching first/last timestamps", () => { + const entries = upsertTravelLogEntry( + [], + { name: "Buzz HQ", relayUrl: "wss://relay.example.com" }, + "2026-08-04T12:00:00.000Z", + ); + assert.equal(entries.length, 1); + assert.equal(entries[0].name, "Buzz HQ"); + assert.equal(entries[0].firstVisitedAt, "2026-08-04T12:00:00.000Z"); + assert.equal(entries[0].lastVisitedAt, "2026-08-04T12:00:00.000Z"); +}); + +test("revisits refresh lastVisitedAt and name but keep firstVisitedAt", () => { + const first = upsertTravelLogEntry( + [], + { name: "Buzz HQ", relayUrl: "wss://relay.example.com" }, + "2026-01-01T00:00:00.000Z", + ); + const second = upsertTravelLogEntry( + first, + { name: "Buzz HQ (renamed)", relayUrl: "wss://relay.example.com/" }, + "2026-08-04T12:00:00.000Z", + ); + assert.equal(second.length, 1); + assert.equal(second[0].name, "Buzz HQ (renamed)"); + assert.equal(second[0].firstVisitedAt, "2026-01-01T00:00:00.000Z"); + assert.equal(second[0].lastVisitedAt, "2026-08-04T12:00:00.000Z"); +}); + +test("relay URLs are keyed case-insensitively and without trailing slashes", () => { + assert.equal( + normalizeRelayUrl("WSS://Relay.Example.com/"), + normalizeRelayUrl("wss://relay.example.com"), + ); +}); + +test("distinct servers accumulate, most recently visited first", () => { + let entries = upsertTravelLogEntry( + [], + { name: "Alpha", relayUrl: "wss://alpha.example" }, + "2026-01-01T00:00:00.000Z", + ); + entries = upsertTravelLogEntry( + entries, + { name: "Beta", relayUrl: "wss://beta.example" }, + "2026-02-01T00:00:00.000Z", + ); + entries = upsertTravelLogEntry( + entries, + { name: "Alpha", relayUrl: "wss://alpha.example" }, + "2026-03-01T00:00:00.000Z", + ); + assert.deepEqual( + entries.map((entry) => entry.name), + ["Alpha", "Beta"], + ); + assert.equal(entries[0].firstVisitedAt, "2026-01-01T00:00:00.000Z"); +}); diff --git a/desktop/src/features/passport/lib/travelLog.ts b/desktop/src/features/passport/lib/travelLog.ts new file mode 100644 index 0000000000..bfb12e87bc --- /dev/null +++ b/desktop/src/features/passport/lib/travelLog.ts @@ -0,0 +1,111 @@ +/** + * Passport travel log — a persistent record of every community (server) this + * identity has connected to, keyed by relay URL. + * + * Like the stamps in a physical passport, entries deliberately outlive the + * visit: removing a community from the sidebar does not remove it from the + * log. This is user-scoped cross-community history, so unlike the + * community-scoped singletons in `resetCommunityState()`, it must NOT be + * reset on community switches — recording the switch is its whole job. + */ + +export type TravelLogEntry = { + /** Community display name as of the most recent visit. */ + name: string; + relayUrl: string; + /** ISO timestamp of the first recorded connection. */ + firstVisitedAt: string; + /** ISO timestamp of the most recent connection. */ + lastVisitedAt: string; +}; + +const STORAGE_KEY = "buzz-passport-travel-log"; +/** Safety cap; evicts the least recently visited servers beyond it. */ +const MAX_ENTRIES = 200; + +/** Key relay URLs case-insensitively and ignore trailing slashes. */ +export function normalizeRelayUrl(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +} + +/** + * Pure upsert: revisits refresh `lastVisitedAt` and the display name but keep + * `firstVisitedAt`. Returns entries sorted by most recent visit first. + */ +export function upsertTravelLogEntry( + entries: TravelLogEntry[], + visit: { name: string; relayUrl: string }, + visitedAt: string, +): TravelLogEntry[] { + const key = normalizeRelayUrl(visit.relayUrl); + const existing = entries.find( + (entry) => normalizeRelayUrl(entry.relayUrl) === key, + ); + const updated: TravelLogEntry = existing + ? { ...existing, lastVisitedAt: visitedAt, name: visit.name } + : { + firstVisitedAt: visitedAt, + lastVisitedAt: visitedAt, + name: visit.name, + relayUrl: visit.relayUrl, + }; + + return [ + updated, + ...entries.filter((entry) => normalizeRelayUrl(entry.relayUrl) !== key), + ] + .sort((left, right) => + right.lastVisitedAt.localeCompare(left.lastVisitedAt), + ) + .slice(0, MAX_ENTRIES); +} + +function isTravelLogEntry(value: unknown): value is TravelLogEntry { + if (typeof value !== "object" || value === null) { + return false; + } + const entry = value as Record; + return ( + typeof entry.name === "string" && + typeof entry.relayUrl === "string" && + typeof entry.firstVisitedAt === "string" && + typeof entry.lastVisitedAt === "string" + ); +} + +/** Read the log from localStorage; malformed payloads yield an empty log. */ +export function loadTravelLog(): TravelLogEntry[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isTravelLogEntry) : []; + } catch { + return []; + } +} + +/** Record a successful connection to a community's relay. */ +export function recordCommunityVisit(visit: { + name: string; + relayUrl: string; +}): void { + if (typeof window === "undefined") { + return; + } + try { + const next = upsertTravelLogEntry( + loadTravelLog(), + visit, + new Date().toISOString(), + ); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Best-effort history — never let bookkeeping break community init. + } +} diff --git a/desktop/src/features/passport/ui/PassportRecordSections.tsx b/desktop/src/features/passport/ui/PassportRecordSections.tsx index 086c253c59..54521340ac 100644 --- a/desktop/src/features/passport/ui/PassportRecordSections.tsx +++ b/desktop/src/features/passport/ui/PassportRecordSections.tsx @@ -69,6 +69,7 @@ const STAMP_ROTATIONS = [ export function PassportStamp({ index, + muted = false, onClick, subtitle, testId, @@ -76,6 +77,8 @@ export function PassportStamp({ }: { /** Position in the stamp page — picks a stable ink color and tilt. */ index: number; + /** Faded, dashed rendering for expired visas (servers since departed). */ + muted?: boolean; onClick?: () => void; subtitle?: string | null; testId?: string; @@ -91,6 +94,7 @@ export function PassportStamp({ "group inline-flex max-w-56 min-w-0 flex-col items-start rounded-xl border-2 px-3.5 py-2 text-left opacity-90 saturate-[0.85] transition-transform", tone, rotation, + muted && "border-dashed opacity-45 saturate-50", onClick && "cursor-pointer hover:rotate-0 hover:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", )} diff --git a/desktop/src/features/passport/ui/PassportScreen.tsx b/desktop/src/features/passport/ui/PassportScreen.tsx index fad01954f3..f15ef452fa 100644 --- a/desktop/src/features/passport/ui/PassportScreen.tsx +++ b/desktop/src/features/passport/ui/PassportScreen.tsx @@ -14,6 +14,10 @@ import { import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; +import { + loadTravelLog, + normalizeRelayUrl, +} from "@/features/passport/lib/travelLog"; import { EmptyRecord, FollowingStrip, @@ -120,6 +124,33 @@ export function PassportScreen({ ); }, [channelsQuery.data, pubkeyLower, relayAgent]); + // Visas: servers you are on now (saved communities) plus servers you have + // been on before (persistent travel log), shown as expired stamps. Both are + // local-device records, so this page only exists on your own passport. + const visas = React.useMemo(() => { + if (!isSelf) { + return []; + } + const savedRelays = new Set( + communities.map((community) => normalizeRelayUrl(community.relayUrl)), + ); + const current = communities.map((community) => ({ + departed: false, + key: community.id, + subtitle: `since ${formatStampDate(new Date(community.addedAt))}`, + title: community.name, + })); + const departed = loadTravelLog() + .filter((entry) => !savedRelays.has(normalizeRelayUrl(entry.relayUrl))) + .map((entry) => ({ + departed: true, + key: `departed-${normalizeRelayUrl(entry.relayUrl)}`, + subtitle: `departed ${formatStampDate(new Date(entry.lastVisitedAt))}`, + title: entry.name, + })); + return [...current, ...departed]; + }, [communities, isSelf]); + const contactPubkeys = React.useMemo( () => contactsQuery.data?.contacts.map((contact) => contact.pubkey) ?? [], [contactsQuery.data], @@ -214,19 +245,20 @@ export function PassportScreen({
- {isSelf && communities.length > 0 ? ( + {visas.length > 0 ? ( - {communities.map((community, index) => ( + {visas.map((visa, index) => ( ))}