diff --git a/AGENTS.md b/AGENTS.md index ce97a2a..2e173b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,9 +189,19 @@ session with **no other memory of this project**. So: - **Routing / directions**: `RouteDialog` is the single in-app router, opened from the map spot sheet, the food cards, and the spot / shop / event detail pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed). - Road modes (skate-walk / bike / moto / car) come from keyless **OSRM** via the - `proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is - labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`, + Road modes (skate-walk / bike / moto / car) come from keyless **OSRM** + (`src/lib/routing.ts`). ⚠️ **Use the per-profile FOSSGIS servers, not the demo + one:** `router.project-osrm.org` hosts a SINGLE car dataset and **ignores the + profile in the URL**, so foot/bike/car all returned identical distance+time + (the "every transport option shows the same time" report). We now call + `https://routing.openstreetmap.de/routed-{foot|bike|car}/route/v1/{foot|bike| + driving}/…` (CORS `*`, direct first, `proxy.shakespeare.diy` proxy only as + fallback), request `steps=true` and expose `RouteStep[]` (verified 2026-09-27: + same trip = walk 13 min / bike 5 / drive 2). The demo server survives only as a + last-resort fallback and its result is flagged `approximate` ("driving + estimate") so we never pass driving numbers off as walking. Moto≈car is + labelled honestly; the foot summary also shows a ~10 km/h `skateTime` cruise + estimate ("~6 min on a board") next to the walking time. **Transit** mode uses **Transitous** (`api.transitous.org`, MOTIS 2, open CORS) — note origin/destination go to that service, unlike road modes. **No external map-app handoff anywhere** (product decision) and no scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from @@ -234,6 +244,20 @@ session with **no other memory of this project**. So: `isInteractiveSigner` is true, so merely opening the notification bell (which marks read) no longer pops Amber twice (encrypt + sign). Interactive signers only prompt for real signed actions. + - ⚠️ **`staleTime: Infinity` does NOT stop the FIRST decrypt (fixed + 2026-09-27).** That was the "Amber pops on every screen" report: the + encrypted `useSyncedAppData` blobs (read-state, preferences) ran their + queryFn once on mount for each racing consumer, and relay responses landing + during navigation made it look per-page. `useSyncedAppData` now takes + **`interactiveNetwork` (default false)**: for an interactive signer the blob + is **local-only** — the query is not even enabled and `patch()` is a no-op — + so no decrypt on mount and no publish on a nav-tab change. localStorage is + the session source of truth. Only set `interactiveNetwork: true` if a feature + genuinely cannot work from localStorage. **Diagnostic recipe:** mock an + `x-amber` login by writing `localStorage['nostr:login']` before load, then + wrap `localStorage.setItem` to log writes of `amber_pending_operation` + (captures every prompt + a stack). Beware stale harness profiles — use a + fresh `--user-data-dir` per run or the ops array is from a previous run. - ⚠️ **Don't make read-state mutators (or any hook they flow from) unstable (fixed 2026-09-27).** `/newsletter` was stuck in an **infinite render loop** (~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData` diff --git a/release-notes.txt b/release-notes.txt index 9ea524e..f43fa68 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -10,6 +10,13 @@ v1.5.2 🧭 Directions actually work now - Fixed the route map never drawing (you'd get an empty dialog and "no route"). Routes now show your position, the spot, and the line between them. +- Real per-mode routing: Skate/Walk, Bike, Moto and Car now give genuinely + different routes and times (walking 13 min vs biking 5 vs driving 2 on the same + trip) instead of one number copy-pasted across every mode. +- Added turn-by-turn route steps for orientation — the actual turns and street + names, not just a line on a map. +- Skating gets its own honest estimate ("~6 min cruise on a board") next to the + walking time, and moto is clearly labelled as using driving roads. - Opens instantly from your last known location; "Near Me" sorts by distance. 📱 Nav fixes @@ -25,8 +32,11 @@ v1.5.2 the drops. ✨ Smoother everywhere -- Amber only pops for things that need a signature — no more prompts just for - reading your notifications. +- Amber is properly scoped now: it only opens for real signed actions (log in / + sign up, messages, your Stash, marketplace trades, invites, publishing). + Browsing, reading notifications and moving between sections no longer trigger + a signer prompt — the app's encrypted cross-device data stays local-only on + Amber instead of decrypting on every screen. - Logo no longer crowds the nav. --- diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index b47fe1c..fe2869e 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -22,6 +22,7 @@ import { TRANSIT_ATTRIBUTION, ROUTE_ERR_BUSY, ROUTE_ERR_NO_ROUTE, + skateTime, type TravelMode, type RouteResult, } from '@/lib/routing'; @@ -311,7 +312,7 @@ export function RouteDialog({ {mode === 'transit' ? 'Public transport via Transitous (open timetables + OpenStreetMap)' - : 'Routed in-app · nothing leaves to Google or Apple Maps'} + : 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'} @@ -436,6 +437,10 @@ export function RouteDialog({ {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( · uses driving roads )} + {route.approximate && · driving estimate} + {mode === 'foot' && ( + · {skateTime(route.distanceM / 1000)} on a board + )} @@ -449,6 +454,38 @@ export function RouteDialog({ )} + + {/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */} + {mode !== 'transit' && route?.steps && route.steps.length > 1 && ( +
+
+

+ Route steps +

+ {mode === 'foot' && ( +

walking route — quicker on a board

+ )} +
+
    + {route.steps.map((s, i) => ( +
  1. + + {i + 1} + + {s.instruction} + {s.distanceM >= 10 && ( + + {formatDistance(s.distanceM / 1000)} + + )} +
  2. + ))} +
+

+ Orientation only — no live turn-by-turn or voice guidance yet. +

+
+ )} ); diff --git a/src/hooks/useSyncedAppData.ts b/src/hooks/useSyncedAppData.ts index fa7910a..d3a3cc2 100644 --- a/src/hooks/useSyncedAppData.ts +++ b/src/hooks/useSyncedAppData.ts @@ -52,12 +52,28 @@ interface UseSyncedAppDataOptions { enabled?: boolean; /** Coalesce writes into at most one publish per this many ms (default 4000). */ debounceMs?: number; + /** + * Opt in to the encrypted relay copy while using an INTERACTIVE signer + * (Amber/NIP-55). Off by default: for an interactive signer the blob is + * **local-only** — no decrypt on mount and no publish on patch — because + * every decrypt/encrypt/sign is a user-facing Amber approval screen. Only set + * this for a feature that truly cannot work from the localStorage copy. + */ + interactiveNetwork?: boolean; } const APP_DATA_KIND = 30078; // NIP-78 application-specific data export function useSyncedAppData(options: UseSyncedAppDataOptions) { - const { dTag, alt, empty, merge, enabled = true, debounceMs = 4000 } = options; + const { + dTag, + alt, + empty, + merge, + enabled = true, + debounceMs = 4000, + interactiveNetwork = false, + } = options; const { nostr } = useNostr(); const { user } = useCurrentUser(); @@ -76,6 +92,13 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { // they keep normal refetching.) const interactive = isInteractiveSigner(user, logins); + // For an interactive signer the relay copy is OPT-IN. `staleTime: Infinity` + // only stops *refetching* — it never stopped the FIRST decrypt, so the app + // still popped Amber once per blob (twice: read-state + preferences, and + // racing mounts could multiply that). The localStorage copy is the session + // source of truth, so we skip the network entirely unless a caller opts in. + const canUseNetwork = !interactive || interactiveNetwork; + const queryKey = useMemo( () => ['synced-app-data', dTag, user?.pubkey] as const, [dTag, user?.pubkey], @@ -84,7 +107,8 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { // ── Fetch + decrypt the single event ────────────────────────────────────── const { data, isLoading } = useQuery({ queryKey, - enabled: enabled && canSync, + // Interactive signer → no decrypt unless the caller opted in (see above). + enabled: enabled && canSync && canUseNetwork, // For interactive signers: never consider it stale and never evict it from // memory during the session → one decrypt, then cache hits. staleTime: interactive ? Infinity : 60_000, @@ -178,7 +202,9 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { */ const patch = useCallback( async (producePatch: T | ((current: T) => T)) => { - if (!canSync) return; + // Local-only for an interactive signer: publishing costs an Amber + // encrypt + sign, and reading the remote copy first costs a decrypt. + if (!canSync || !canUseNetwork) return; const latest = await fetchLatest(); const asPatch = @@ -195,7 +221,7 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs); }, - [canSync, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs], + [canSync, canUseNetwork, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs], ); // Flush any pending write when the tab is hidden/closed so a quick glance + diff --git a/src/lib/routing.ts b/src/lib/routing.ts index b27c461..3f0758b 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -56,6 +56,13 @@ export interface RouteResult { durationS: number; /** Which mode the route was requested for. */ mode: TravelMode; + /** Turn-by-turn instructions (orientation aid; not live navigation). */ + steps?: RouteStep[]; + /** + * True when the numbers came from the car-only fallback server, so the + * distance/time are a driving approximation for the chosen mode. + */ + approximate?: boolean; /** Present only for transit routes — the chosen public-transport itinerary. */ transit?: TransitItinerary; } @@ -86,18 +93,104 @@ export interface TransitItinerary { /** Attribution required by Transitous / OpenStreetMap. */ export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors'; -const OSRM_BASE = 'https://router.project-osrm.org/route/v1'; +/** + * Real per-profile OSRM endpoints (FOSSGIS / OpenStreetMap Germany). + * + * ⚠️ The old `router.project-osrm.org` demo server hosts ONE dataset (car) and + * **ignores the profile segment of the URL** — foot/bike/car all came back with + * the same distance and time, so the mode toggle looked broken and the times + * were dishonest. FOSSGIS runs a separate engine per profile, so foot really is + * a walking route and bike a cycling one (they differ in distance AND time). + * It sends `Access-Control-Allow-Origin: *`, so we can call it directly. + */ +const OSRM_PROFILE_BASE: Record<'foot' | 'bike' | 'car', string> = { + foot: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot', + bike: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike', + car: 'https://routing.openstreetmap.de/routed-car/route/v1/driving', +}; + +/** Last-resort fallback: the demo server (car-only, hence approximate). */ +const OSRM_FALLBACK_BASE = 'https://router.project-osrm.org/route/v1'; + const CORS_PROXY = 'https://proxy.shakespeare.diy/?url='; +interface OsrmStep { + distance?: number; + name?: string; + maneuver?: { type?: string; modifier?: string; location?: [number, number] }; +} + +interface OsrmLeg { + steps?: OsrmStep[]; +} + interface OsrmResponse { code: string; routes?: Array<{ distance: number; duration: number; geometry: { coordinates: Array<[number, number]> }; // [lng, lat] + legs?: OsrmLeg[]; }>; } +/** One turn-by-turn instruction (used for orientation, not live navigation). */ +export interface RouteStep { + /** Human instruction, e.g. "Turn left onto Sukhumvit Rd". */ + instruction: string; + /** Length of this step in metres. */ + distanceM: number; + /** Where the manoeuvre happens. */ + lat: number; + lng: number; +} + +/** Turn OSRM's terse maneuver codes into something readable. */ +function maneuverLabel(type: string, modifier: string): string { + const t = type.toLowerCase(); + const m = modifier.toLowerCase(); + if (t === 'depart') return 'Head out'; + if (t === 'arrive') return 'Arrive at the spot'; + if (t === 'new name') return 'Continue'; + if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue'; + if (t === 'turn') { + if (m.includes('sharp left')) return 'Sharp left'; + if (m.includes('sharp right')) return 'Sharp right'; + if (m.includes('slight left')) return 'Bear left'; + if (m.includes('slight right')) return 'Bear right'; + if (m.includes('left')) return 'Turn left'; + if (m.includes('right')) return 'Turn right'; + return 'Turn'; + } + if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork'; + if (t === 'merge') return 'Merge'; + if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout'; + if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right'; + if (t === 'on ramp') return 'Take the ramp'; + if (t === 'off ramp') return 'Take the exit'; + return t.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function parseSteps(data: OsrmResponse): RouteStep[] { + const route = data.routes?.[0]; + if (!route?.legs) return []; + const steps: RouteStep[] = []; + for (const leg of route.legs) { + for (const s of leg.steps ?? []) { + const label = maneuverLabel(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? ''); + const road = (s.name ?? '').trim(); + const loc = s.maneuver?.location; + steps.push({ + instruction: road && !label.startsWith('Arrive') ? `${label} onto ${road}` : label, + distanceM: s.distance ?? 0, + lat: loc?.[1] ?? 0, + lng: loc?.[0] ?? 0, + }); + } + } + return steps; +} + /** Error message constants the UI branches on (kept in sync with RouteDialog). */ export const ROUTE_ERR_NO_ROUTE = 'No route found'; export const ROUTE_ERR_BUSY = 'Routing service busy'; @@ -149,9 +242,26 @@ export async function fetchRoute( if (!isPoint(from) || !isPoint(to)) throw new Error(ROUTE_ERR_NO_ROUTE); // OSRM wants lon,lat;lon,lat const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`; - const target = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson`; + const query = `overview=full&geometries=geojson&steps=true`; + + // 1) The real per-profile server (foot ≠ bike ≠ car distances and times). + let data: OsrmResponse | null = null; + let approximate = false; + try { + data = await fetchOsrm(`${OSRM_PROFILE_BASE[profile]}/${coords}?${query}`, signal); + if (data.code !== 'Ok') data = null; + } catch (e) { + if ((e as Error)?.name === 'AbortError') throw e; + } + + // 2) Fallback: the demo server, which is CAR-ONLY — flag the result as + // approximate so we can say so plainly rather than passing driving + // numbers off as a walking/cycling route. + if (!data) { + data = await fetchOsrm(`${OSRM_FALLBACK_BASE}/driving/${coords}?${query}`, signal); + approximate = profile !== 'car'; + } - const data = await fetchOsrm(target, signal); if (data.code === 'TooManyRequests') throw new Error(ROUTE_ERR_BUSY); if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { throw new Error(ROUTE_ERR_NO_ROUTE); @@ -168,6 +278,8 @@ export async function fetchRoute( distanceM: route.distance, durationS: route.duration, mode, + steps: parseSteps(data), + approximate: approximate || undefined, }; }