diff --git a/src/components/NewWaypointsBanner.tsx b/src/components/NewWaypointsBanner.tsx
new file mode 100644
index 0000000..cb16ea3
--- /dev/null
+++ b/src/components/NewWaypointsBanner.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import { X } from 'lucide-react';
+import type { Waypoint } from '@/utils/waypoints';
+
+type Props = {
+ waypoints: Waypoint[];
+ /** Add every new waypoint to its default group and mark them seen. */
+ onAdd: () => void;
+ /** Mark the new waypoints seen without adding them. */
+ onDismiss: () => void;
+};
+
+/**
+ * Dismissable "new waypoints" notice. Surfaces built-in waypoints that have
+ * shipped since the user last acknowledged the catalog, with a one-click
+ * "Add" that drops them into their default groups. Rendered on the
+ * universal-link picker and in account settings. Mirrors the extension's
+ * popup banner.
+ */
+export default function NewWaypointsBanner({ waypoints, onAdd, onDismiss }: Props) {
+ if (waypoints.length === 0) return null;
+
+ const names = waypoints.map((w) => w.name);
+ let summary: string;
+ if (names.length === 1) summary = `${names[0]} is a new waypoint`;
+ else if (names.length <= 3) summary = `New waypoints: ${names.join(', ')}`;
+ else summary = `${names.slice(0, 2).join(', ')} and ${names.length - 2} more new waypoints`;
+
+ const addLabel = waypoints.length === 1 ? 'Add it' : 'Add all';
+
+ return (
+
diff --git a/src/components/account/tabs/WaypointsTab.tsx b/src/components/account/tabs/WaypointsTab.tsx
index eb05751..3c06352 100644
--- a/src/components/account/tabs/WaypointsTab.tsx
+++ b/src/components/account/tabs/WaypointsTab.tsx
@@ -1,6 +1,15 @@
'use client';
+import { useMemo } from 'react';
import WaypointGroupsManager from '../WaypointGroupsManager';
+import NewWaypointsBanner from '@/components/NewWaypointsBanner';
+import { usePreferences } from '@/components/PreferencesProvider';
+import {
+ newBuiltinWaypointIds,
+ addWaypointsToDefaultGroups,
+ markWaypointsKnown,
+} from '@/utils/preferences';
+import { WAYPOINT_DESTINATIONS } from '@/utils/waypoints';
/**
* Waypoints tab — user-defined groups of waypoints. Drag groups to
@@ -9,6 +18,14 @@ import WaypointGroupsManager from '../WaypointGroupsManager';
* any waypoint not in any group is hidden from the picker.
*/
export default function WaypointsTab() {
+ const { prefs, update } = usePreferences();
+
+ const newWaypointIds = useMemo(() => newBuiltinWaypointIds(prefs), [prefs]);
+ const newWaypoints = useMemo(
+ () => newWaypointIds.map((id) => WAYPOINT_DESTINATIONS[id]).filter(Boolean),
+ [newWaypointIds],
+ );
+
return (
@@ -20,6 +37,13 @@ export default function WaypointsTab() {
can live in multiple groups; anything not in a group is hidden.
+ {newWaypoints.length > 0 && (
+ update((p) => addWaypointsToDefaultGroups(p, newWaypointIds))}
+ onDismiss={() => update((p) => markWaypointsKnown(p, newWaypointIds))}
+ />
+ )}
);
diff --git a/src/utils/atproto/preferencesPds.ts b/src/utils/atproto/preferencesPds.ts
index 098928d..1514a53 100644
--- a/src/utils/atproto/preferencesPds.ts
+++ b/src/utils/atproto/preferencesPds.ts
@@ -16,6 +16,7 @@
* supportedTypes: string[],
* templates: { post?, profile?, list?, record?: string }
* }>
+ * knownWaypointIds?: string[] // built-ins the user has been notified about
* pinnedLexicons?: string[] // NSIDs pinned in the explorer
* pinnedLexiconsOthers?: string[] // separate list for others' repos in split mode
* pinScope?: 'own' | 'all' | 'split'
@@ -91,6 +92,7 @@ export async function writePreferencesToPds(
$type: PREFERENCES_NSID,
waypointGroups: prefs.waypointGroups,
customWaypoints: prefs.customWaypoints,
+ knownWaypointIds: prefs.knownWaypointIds,
pinnedLexicons: prefs.pinnedLexicons,
pinnedLexiconsOthers: prefs.pinnedLexiconsOthers,
pinScope: prefs.pinScope,
diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts
index 81707e9..7cd140a 100644
--- a/src/utils/preferences.ts
+++ b/src/utils/preferences.ts
@@ -74,6 +74,15 @@ export type Preferences = {
waypointOrder: string[];
/** User-defined waypoints. */
customWaypoints: CustomWaypoint[];
+ /**
+ * Built-in waypoint ids the user has already been notified about. When a
+ * new built-in ships, it won't be in this list, so the picker and settings
+ * surface a "new waypoint" banner offering to add it. Mirrors the
+ * extension's `knownWaypointIds`. Seeded from the user's existing groups on
+ * first read after this field shipped, so an upgrade doesn't flag every
+ * waypoint as new.
+ */
+ knownWaypointIds: string[];
/**
* NSIDs the user has pinned in the explorer's CollectionsTab. Shown at
* the top of the list whenever the current repo has a matching
@@ -146,6 +155,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
hiddenWaypoints: [],
waypointOrder: [],
customWaypoints: [],
+ knownWaypointIds: [...WAYPOINT_ORDER],
pinnedLexicons: [],
pinnedLexiconsOthers: [],
pinScope: 'own',
@@ -229,6 +239,7 @@ export function mergeWithDefaults(input: Partial | null | undefined
storedGroups.length > 0
? storedGroups
: migrateToGroups({ customWaypoints, hiddenWaypoints, waypointOrder });
+ const knownWaypointIds = migrateKnownWaypointIds(input, waypointGroups, hiddenWaypoints);
const pinnedLexicons = Array.isArray(input.pinnedLexicons)
? input.pinnedLexicons.filter((s): s is string => typeof s === 'string')
: [];
@@ -260,6 +271,7 @@ export function mergeWithDefaults(input: Partial | null | undefined
hiddenWaypoints,
waypointOrder,
customWaypoints,
+ knownWaypointIds,
pinnedLexicons,
pinnedLexiconsOthers,
pinScope,
@@ -340,6 +352,7 @@ export function preferencesAreEqual(a: Preferences, b: Preferences): boolean {
a.minimalPostPreview === b.minimalPostPreview &&
JSON.stringify(a.waypointGroups) === JSON.stringify(b.waypointGroups) &&
JSON.stringify(a.customWaypoints) === JSON.stringify(b.customWaypoints) &&
+ JSON.stringify(a.knownWaypointIds) === JSON.stringify(b.knownWaypointIds) &&
JSON.stringify(a.pinnedLexicons) === JSON.stringify(b.pinnedLexicons) &&
JSON.stringify(a.pinnedLexiconsOthers) === JSON.stringify(b.pinnedLexiconsOthers)
);
@@ -586,6 +599,96 @@ function prettyGroupName(id: string): string {
return id.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()).trim();
}
+/**
+ * Seed `knownWaypointIds` for payloads that predate the field. Anything
+ * currently in a group or in the legacy `hiddenWaypoints` list counts as
+ * already "seen"; the diff against the current built-in catalog is what
+ * surfaces as new. Mirrors the extension's `migrateKnownWaypointIds`.
+ *
+ * Trust an explicit array from storage; only seed when the field is absent.
+ */
+function migrateKnownWaypointIds(
+ input: Partial,
+ waypointGroups: WaypointGroup[],
+ hiddenWaypoints: string[],
+): string[] {
+ if (Array.isArray(input.knownWaypointIds)) {
+ return input.knownWaypointIds.filter((id): id is string => typeof id === 'string');
+ }
+
+ const seed = new Set();
+ for (const group of waypointGroups) {
+ for (const id of group.waypointIds) {
+ if (!id.startsWith('custom:')) seed.add(id);
+ }
+ }
+ for (const id of hiddenWaypoints) {
+ if (typeof id === 'string' && !id.startsWith('custom:')) seed.add(id);
+ }
+ if (seed.size === 0) {
+ // No signal in the stored prefs — treat as a fresh user and consider
+ // every built-in already known, so the first post-upgrade load doesn't
+ // flag the entire catalog as new.
+ return [...WAYPOINT_ORDER];
+ }
+ return Array.from(seed);
+}
+
+/**
+ * Built-in waypoint ids that have shipped since the user was last notified
+ * (i.e. not present in `knownWaypointIds`). Order follows `WAYPOINT_ORDER`.
+ * Custom waypoints are never returned.
+ */
+export function newBuiltinWaypointIds(prefs: Preferences): string[] {
+ const known = new Set(prefs.knownWaypointIds ?? []);
+ return WAYPOINT_ORDER.filter((id) => !known.has(id) && WAYPOINT_DESTINATIONS_DATA[id]);
+}
+
+/**
+ * Mark the given built-in waypoint ids as seen so the "new waypoint" banner
+ * stops surfacing them. Custom ids are ignored. Returns a new prefs object;
+ * a no-op (same reference) when nothing changes.
+ */
+export function markWaypointsKnown(prefs: Preferences, ids: string[]): Preferences {
+ const known = new Set(prefs.knownWaypointIds ?? []);
+ let changed = false;
+ for (const id of ids) {
+ if (id.startsWith('custom:')) continue;
+ if (!known.has(id)) {
+ known.add(id);
+ changed = true;
+ }
+ }
+ if (!changed) return prefs;
+ return { ...prefs, knownWaypointIds: Array.from(known) };
+}
+
+/**
+ * Quick-add new built-in waypoints into their default category group,
+ * creating that group if the user has since removed it, and mark them known.
+ * This backs the "Add" action on the new-waypoint banner.
+ */
+export function addWaypointsToDefaultGroups(prefs: Preferences, ids: string[]): Preferences {
+ const groups = prefs.waypointGroups.map((g) => ({ ...g, waypointIds: [...g.waypointIds] }));
+ const byId = new Map(groups.map((g) => [g.id, g]));
+
+ for (const id of ids) {
+ const data = WAYPOINT_DESTINATIONS_DATA[id];
+ if (!data) continue;
+ const catId = data.category;
+ let group = byId.get(catId);
+ if (!group) {
+ const meta = WAYPOINT_CATEGORIES_DATA[catId];
+ group = { id: catId, name: meta?.name ?? prettyGroupName(catId), waypointIds: [] };
+ byId.set(catId, group);
+ groups.push(group);
+ }
+ if (!group.waypointIds.includes(id)) group.waypointIds.push(id);
+ }
+
+ return markWaypointsKnown({ ...prefs, waypointGroups: groups }, ids);
+}
+
function newGroupId(): string {
return `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}