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 ( +
+ + {waypoints.slice(0, 3).map((w) => ( + + {w.icon} + + ))} + + + + {summary} + + + + + +
+ ); +} diff --git a/src/components/WaypointPicker.tsx b/src/components/WaypointPicker.tsx index 64a024f..4128566 100644 --- a/src/components/WaypointPicker.tsx +++ b/src/components/WaypointPicker.tsx @@ -6,6 +6,7 @@ import { getCategorizedWaypoints, getRecommendedWaypoints, getWaypointsForType, + WAYPOINT_DESTINATIONS, type WaypointType } from '@/utils/waypoints'; import { @@ -13,9 +14,15 @@ import { personalizeRecommended, customToWaypoint, } from '@/utils/personalizeWaypoints'; +import { + newBuiltinWaypointIds, + addWaypointsToDefaultGroups, + markWaypointsKnown, +} from '@/utils/preferences'; import { usePreferences } from './PreferencesProvider'; import ShareButton from './ShareButton'; import CategoryCard from './CategoryCard'; +import NewWaypointsBanner from './NewWaypointsBanner'; type WaypointPickerProps = { type: WaypointType; @@ -36,7 +43,15 @@ export default function WaypointPicker({ }: WaypointPickerProps) { const display = displayName || `@${handle}`; const [copiedId, setCopiedId] = useState(null); - const { prefs } = usePreferences(); + const { prefs, update } = usePreferences(); + + // Built-in waypoints that have shipped since the user last acknowledged the + // catalog. Surfaced as a dismissable banner with one-click add. + const newWaypointIds = useMemo(() => newBuiltinWaypointIds(prefs), [prefs]); + const newWaypoints = useMemo( + () => newWaypointIds.map((id) => WAYPOINT_DESTINATIONS[id]).filter(Boolean), + [newWaypointIds], + ); // Get categorized waypoints and featured waypoint, with user prefs applied // (hidden built-ins removed, custom waypoints added as their own group, @@ -274,6 +289,14 @@ export default function WaypointPicker({ return (
+ {newWaypoints.length > 0 && ( + update((p) => addWaypointsToDefaultGroups(p, newWaypointIds))} + onDismiss={() => update((p) => markWaypointsKnown(p, newWaypointIds))} + /> + )} + {/* Header */}

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)}`; }