fix(amber,routing): scope Amber prompts to real actions; real per-mode routes + steps

AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)

ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
This commit is contained in:
SkateSpots 2026-09-27 18:35:17 +07:00
parent 6364f08ede
commit e69bace784
5 changed files with 222 additions and 13 deletions

View file

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

View file

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

View file

@ -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({
<DialogDescription className="text-xs">
{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'}
</DialogDescription>
</DialogHeader>
@ -436,6 +437,10 @@ export function RouteDialog({
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span className="ml-1">· uses driving roads</span>
)}
{route.approximate && <span className="ml-1">· driving estimate</span>}
{mode === 'foot' && (
<span className="ml-1">· {skateTime(route.distanceM / 1000)} on a board</span>
)}
</div>
</div>
</div>
@ -449,6 +454,38 @@ export function RouteDialog({
</div>
)}
</div>
{/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */}
{mode !== 'transit' && route?.steps && route.steps.length > 1 && (
<div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Route steps
</p>
{mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
)}
</div>
<ol className="max-h-56 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
{route.steps.map((s, i) => (
<li key={`${i}-${s.instruction}`} className="flex items-start gap-2 text-xs">
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
{i + 1}
</span>
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
{s.distanceM >= 10 && (
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{formatDistance(s.distanceM / 1000)}
</span>
)}
</li>
))}
</ol>
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
Orientation only — no live turn-by-turn or voice guidance yet.
</p>
</div>
)}
</DialogContent>
</Dialog>
);

View file

@ -52,12 +52,28 @@ interface UseSyncedAppDataOptions<T> {
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<T>(options: UseSyncedAppDataOptions<T>) {
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<T>(options: UseSyncedAppDataOptions<T>) {
// 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<T>(options: UseSyncedAppDataOptions<T>) {
// ── 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<T>(options: UseSyncedAppDataOptions<T>) {
*/
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<T>(options: UseSyncedAppDataOptions<T>) {
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 +

View file

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