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:
parent
6364f08ede
commit
e69bace784
5 changed files with 222 additions and 13 deletions
30
AGENTS.md
30
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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue