feat(desktop): record visited servers in the passport travel log

Every successful community connection stamps a persistent local travel
log (keyed by relay URL, first/last visit dates). The passport's Visas
section now merges saved communities with that log, so servers you have
departed still appear as faded, dashed expired-visa stamps.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 14:47:37 +02:00
parent dae8e3b707
commit 854f53380b
5 changed files with 225 additions and 7 deletions

View File

@@ -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.

View File

@@ -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");
});

View File

@@ -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<string, unknown>;
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.
}
}

View File

@@ -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",
)}

View File

@@ -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({
</div>
<div className="flex min-w-0 flex-col gap-8">
{isSelf && communities.length > 0 ? (
{visas.length > 0 ? (
<RecordSection
label="Visas · Communities"
trailing={`${communities.length}`}
label="Visas · Servers"
trailing={`${visas.length}`}
>
<StampPage>
{communities.map((community, index) => (
{visas.map((visa, index) => (
<PassportStamp
index={index}
key={community.id}
subtitle={`since ${formatStampDate(new Date(community.addedAt))}`}
key={visa.key}
muted={visa.departed}
subtitle={visa.subtitle}
testId="passport-community-stamp"
title={community.name}
title={visa.title}
/>
))}
</StampPage>