'use client'; import { useState, useMemo, useEffect, useCallback } from 'react'; import { ExternalLink, Copy, Check } from 'lucide-react'; import { getCategorizedWaypoints, getRecommendedWaypoints, getWaypointsForType, WAYPOINT_DESTINATIONS, type WaypointType } from '@/utils/waypoints'; import { waypointActivity } from '@/utils/waypoints.data'; import { personalizeCategorized, 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; handle: string; collection?: string; rkey?: string; displayName?: string; did?: string; /** * Collection NSIDs found in the target repo (from describeRepo). When * provided, waypoints whose `expectedCollections` match none of these are * hidden — e.g. the Tangled waypoint won't show for an account with no * `sh.tangled.*` records. `null`/`undefined` (scan failed or not run, as on * record pages) leaves every waypoint visible. Generic tools that declare no * `expectedCollections` (PDSls, atp.tools, Aturi Explore…) are never hidden. */ repoCollections?: string[] | null; }; export default function WaypointPicker({ type, handle, collection, rkey, displayName, did, repoCollections, }: WaypointPickerProps) { const display = displayName || `@${handle}`; const [copiedId, setCopiedId] = useState(null); const { prefs, update } = usePreferences(); // Set of collection NSIDs the target repo holds. `null` means "no opinion" // (scan failed/not run) — `waypointActivity` returns 'unknown' for everything // and nothing gets hidden. An empty array is a real answer (repo with no // collections), so only a nullish prop short-circuits to null. const repoCollectionSet = useMemo( () => (repoCollections ? new Set(repoCollections) : null), [repoCollections], ); // Keep a waypoint unless we've positively confirmed the repo has no records // for it. 'present' and 'unknown' both pass; only 'absent' is filtered out. const isActiveForRepo = useCallback( (waypoint: { expectedCollections?: string[] }) => waypointActivity(waypoint, repoCollectionSet) !== 'absent', [repoCollectionSet], ); // 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, // user-defined ordering respected within each category). const categorizedWaypoints = useMemo( () => personalizeCategorized(getCategorizedWaypoints(type), prefs, type).map( ({ category, waypoints }) => ({ category, waypoints: waypoints.filter(isActiveForRepo), }), ), [type, prefs, isActiveForRepo], ); const recommendedData = useMemo( () => getRecommendedWaypoints(type, collection), [type, collection] ); const recommendedWaypoints = useMemo( () => personalizeRecommended(recommendedData?.waypoints || [], prefs).filter( isActiveForRepo, ), [recommendedData, prefs, isActiveForRepo] ); const recommendedLabel = useMemo( () => recommendedData?.label || '', [recommendedData] ); // Flat list of every waypoint the user has surfaced (in any group), // scoped to the current record type. Used by the smart-expansion // heuristic below to decide which categories open by default. const availableWaypoints = useMemo(() => { const visible = new Set(); for (const g of prefs.waypointGroups) { for (const id of g.waypointIds) visible.add(id); } const out: typeof categorizedWaypoints[number]['waypoints'] = []; const customById = new Map(prefs.customWaypoints.map((c) => [c.id, c])); const seen = new Set(); for (const id of visible) { if (seen.has(id)) continue; seen.add(id); const custom = customById.get(id); if (custom) { if (custom.supportedTypes.includes(type)) { out.push(customToWaypoint(custom)); } continue; } const builtin = getWaypointsForType(type).find((w) => w.id === id); if (builtin) out.push(builtin); } return out.filter(isActiveForRepo); }, [type, prefs, isActiveForRepo]); // Smart expansion: Compute initial expanded categories based on compatible waypoints const initialExpandedCategories = useMemo(() => { const initialExpanded = new Set(); // Check each category to see if it contains compatible waypoints for (const { category, waypoints } of categorizedWaypoints) { // Check if any waypoint in this category can handle the current content const hasCompatible = waypoints.some(waypoint => { const url = waypoint.getUrl(handle, collection, rkey, did); return url !== null; }); if (hasCompatible) { initialExpanded.add(category.id); } // Also check subcategories and auto-expand them if (category.subcategories) { for (const subcategory of category.subcategories) { // Get waypoints for this subcategory const subcatWaypoints = availableWaypoints.filter(w => w.category === subcategory.id); const hasSubcatCompatible = subcatWaypoints.some(waypoint => { const url = waypoint.getUrl(handle, collection, rkey, did); return url !== null; }); if (hasSubcatCompatible) { // Expand both parent and subcategory initialExpanded.add(category.id); initialExpanded.add(subcategory.id); } } } } return initialExpanded; }, [categorizedWaypoints, availableWaypoints, handle, collection, rkey, did]); const [expandedCategories, setExpandedCategories] = useState>(initialExpandedCategories); // Update expanded categories when the initial computation changes useEffect(() => { setExpandedCategories(initialExpandedCategories); }, [initialExpandedCategories]); const handleCopy = async (url: string, waypointId: string, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); try { await navigator.clipboard.writeText(url); setCopiedId(waypointId); setTimeout(() => setCopiedId(null), 2000); } catch (err) { console.error('Failed to copy:', err); } }; const handleWaypointClick = (url: string, e: React.MouseEvent) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement; if ( target.closest('button') || target.closest('a') || target.tagName === 'BUTTON' || target.tagName === 'A' ) { return; } // Open in new tab window.open(url, '_blank', 'noopener,noreferrer'); }; const toggleCategory = (categoryId: string) => { setExpandedCategories(prev => { const next = new Set(prev); if (next.has(categoryId)) { next.delete(categoryId); } else { next.add(categoryId); } return next; }); }; const getContextText = () => { switch (type) { case 'post': return `Open post by ${display} on...`; case 'profile': return `Open profile for ${display} on...`; case 'list': return `Open list by ${display} on...`; case 'record': return `Open record from ${display} on...`; default: return `Open content from ${display} on...`; } }; const renderRecommendedWaypoints = () => { if (!recommendedWaypoints || recommendedWaypoints.length === 0) return null; return (

{recommendedLabel}

{recommendedWaypoints.map((waypoint, index) => { const url = waypoint.getUrl(handle, collection, rkey, did); if (!url) return null; const isCopied = copiedId === waypoint.id; // Organic rotation for each button const rotations = [0.3, -0.2, 0.4]; const rotation = rotations[index % rotations.length]; return (
handleWaypointClick(url, e)} style={{ cursor: 'pointer', transform: `rotate(${rotation}deg)`, // @ts-expect-error - CSS custom property '--button-rotation': `rotate(${rotation}deg)` }} >
{waypoint.icon}
{waypoint.name}
{typeof waypoint.description === 'function' ? waypoint.description(collection, type) : waypoint.description}
); })}
); }; return (
{newWaypoints.length > 0 && ( update((p) => addWaypointsToDefaultGroups(p, newWaypointIds))} onDismiss={() => update((p) => markWaypointsKnown(p, newWaypointIds))} /> )} {/* Header */}

Choose where to view

{getContextText()}

{/* Waypoint Options */}
{availableWaypoints.length === 0 ? (

No waypoints available for this content type yet.

) : ( <> {/* Recommended Waypoints */} {renderRecommendedWaypoints()} {/* More Options Section */} {(() => { // Filter categories to only show those with at least one compatible waypoint const filteredCategories = categorizedWaypoints .map(({ category, waypoints }) => { // Check if any waypoints are compatible (don't filter by recommended status) const compatibleWaypoints = waypoints.filter(waypoint => { const url = waypoint.getUrl(handle, collection, rkey, did); return url !== null; }); // Prepare subcategories data if they exist const subcategoriesData = category.subcategories?.map(subcat => { const subcatWaypoints = availableWaypoints .filter(w => w.category === subcat.id); const compatibleSubcatWaypoints = subcatWaypoints.filter(waypoint => { const url = waypoint.getUrl(handle, collection, rkey, did); return url !== null; }); return { category: subcat, waypoints: subcatWaypoints, compatibleCount: compatibleSubcatWaypoints.length, isExpanded: expandedCategories.has(subcat.id), onToggle: () => toggleCategory(subcat.id), }; }).filter(sub => sub.compatibleCount > 0); // Calculate total compatible waypoints (category + subcategories) const totalCompatible = compatibleWaypoints.length + (subcategoriesData?.reduce((sum, sub) => sum + sub.compatibleCount, 0) || 0); return { category, waypoints, // Keep all waypoints, not just non-recommended ones subcategoriesData, hasCompatible: totalCompatible > 0, }; }) .filter(data => data.hasCompatible); // Only show "More Options" section if there are categories to display if (filteredCategories.length === 0) { return null; } return (
{/* Other Waypoints Header */} {recommendedWaypoints.length > 0 && (

More Options

)} {/* Categorized Waypoints */}
{filteredCategories.map(({ category, waypoints, subcategoriesData }) => ( toggleCategory(category.id)} handle={handle} collection={collection} rkey={rkey} did={did} copiedId={copiedId} onCopy={handleCopy} onWaypointClick={handleWaypointClick} subcategories={subcategoriesData} alwaysShowCategoryHeader={true} /> ))}
); })()} )}
{/* About aturi.to */}

What is aturi.to?

Tour the Atmosphere.

Switch between clients, share universal links, and explore any account’s PDS data.

); }