diff --git a/.changeset/guest-readable-public-posts.md b/.changeset/guest-readable-public-posts.md new file mode 100644 index 00000000..892cc0c9 --- /dev/null +++ b/.changeset/guest-readable-public-posts.md @@ -0,0 +1,5 @@ +--- +"@civfix/shared": patch +--- + +`getPost` is `auth: "optional"`: a public post is guest-readable (share-link previews). diff --git a/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md b/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md index 041e0ab2..3af7257f 100644 --- a/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md +++ b/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md @@ -153,7 +153,7 @@ Permission↔usage parity is clean and there is **no tracking/ads/analytics SDK* Identity and asset pipeline are largely submission-ready (real reverse-DNS ids; marketing icon has **no alpha**; encryption flag accurate; OSM attribution present on the *main* map). The blocking work is operational + a couple of review/ToS risks. Note: `android/` and `ios/` are gitignored (regenerated by `expo prebuild`); **no `ios/` has ever been generated on this box**, so the iOS native build is unproven. - 🟠 **H9 — iPad supported while portrait-locked + phone-first.** `app.config.ts:43` `orientation:"portrait"` + `:55` `ios.supportsTablet:true`. With tablet support on, App Store Connect demands a full iPad screenshot set and reviewers test on iPad; a stretched iPhone layout draws 4.x design rejections. The shared UI adapts by width (≥840px sidebar shell) so it *may* pass, but must be verified on real iPad hardware. *Fix:* either commit to iPad (test + upload iPad screenshots) or set `supportsTablet:false`. -- 🟠 **H10 — LocationPicker map disables OSM/CARTO attribution.** `LocationPicker.native.tsx:96-98` sets both `logo={false}` and `attribution={false}` and renders no text credit (only a "Tap the map to place the pin" hint), while drawing the same CARTO Voyager (OSM data) tiles as the home map. This violates the OSM Tile Usage Policy / CARTO basemap ToS on that screen. *(Compliant comparators: `MiniMap.native.tsx:56-58` draws a faint "CARTO / OSM" text credit; `Map.native.tsx:245` keeps the native (i) button.)* *Fix:* remove `attribution={false}` or add a static "© OpenStreetMap / CARTO" credit. +- 🟠 **H10 — LocationPicker map disables OSM/CARTO attribution.** `LocationPicker.native.tsx:96-98` sets both `logo={false}` and `attribution={false}` and renders no text credit (only a "Tap the map to place the pin" hint), while drawing the same CARTO Voyager (OSM data) tiles as the home map. This violates the OSM Tile Usage Policy / CARTO basemap ToS on that screen. *(Compliant comparator: the home map, `Map.native.tsx`, keeps the native attribution control — the (i) button placed by `attributionPosition`.)* *Fix:* remove `attribution={false}` or add a static "© OpenStreetMap / CARTO" credit. - 🟡 **M12 — Release build signed with the debug keystore.** `android/app/build.gradle:118-121` `release` uses `signingConfigs.debug` (prebuild default; comment warns against it). Fine for local APKs, not for store upload. Informational since `android/` is regenerated. *Fix:* let EAS manage the upload keystore (`eas credentials`). - 🟡 **M14 — No `ios/` project + Google iOS URL scheme placeholder.** `app.config.ts:26` falls back to `com.googleusercontent.apps.PLACEHOLDER` when the env var is unset; an iOS build without the real reversed-client-id ships a non-functional Google sign-in scheme. Overlaps **B8**. *Fix:* set `EXPO_PUBLIC_GOOGLE_IOS_*` in the EAS env; run a macOS/EAS iOS build to validate. - 🟡 **M15 — New Architecture on + native libs pinned for compatibility.** `app.config.ts:46` `newArchEnabled:true`; README notes `react-native-mmkv` pinned to v3 and `vision-camera` to v4 to avoid Nitro, `react-native-compressor` excluded from the doctor check. Any module misbehaving under Fabric is a crash-on-launch risk. Not a config error — a **test obligation**. *Fix:* run the Play pre-launch report + a TestFlight pass on real devices (camera, MapLibre, video). @@ -193,7 +193,7 @@ Expected: `ACCESS_COARSE/FINE_LOCATION`, `CAMERA`, `RECORD_AUDIO`, `POST_NOTIFIC - **Bundle id / package** are clean reverse-DNS (`org.civfix.community`), consistent iOS↔Android, no `com.example`/`com.anonymous`/`host.exp.exponent` leakage. - **Marketing icon** has no alpha; **fonts** (Baloo 2, Bricolage Grotesque, JetBrains Mono, Manrope) are OFL/Apache and Lucide is ISC — all redistributable, no restrictive assets. - **Prior `["threads"]` cache-collision crash** is now guarded with split query keys + tests (`packages/ui/src/data/__tests__/discussion-cache.test.ts`); low residual risk. Push-token and Google-sign-in failures are both wrapped non-fatal (dead-feature, not crash). -- **Main map (home) shows OSM attribution** (`Map.native.tsx:245`) and `MiniMap` shows a text credit — only `LocationPicker` is non-compliant (H10). +- **Main map (home) shows OSM attribution** (`Map.native.tsx`, the native attribution control at `attributionPosition`) — only `LocationPicker` is non-compliant (H10). --- diff --git a/apps/community-mobile/app/index.tsx b/apps/community-mobile/app/index.tsx index d2d67404..2fa4ff59 100644 --- a/apps/community-mobile/app/index.tsx +++ b/apps/community-mobile/app/index.tsx @@ -11,6 +11,8 @@ import { useLayoutMode, Map as SharedMap, MapControls, + useMapFlyTo, + useMapFocus, useMapViewport, useReportFilterStore, enabledCategoriesArray, @@ -273,9 +275,16 @@ export default function MapHomeScreen() { }, []) const resolveLocation = location.resolve + const awaitFirstFix = location.awaitFirstFix const recalledViewport = recallMapViewport() !== null const initialCenterOwnedRef = useRef(recalledViewport) + + const onUserCameraMove = useCallback(() => { + initialCenterOwnedRef.current = true + beginCameraRequest() + }, [beginCameraRequest]) + const [rememberedCenter] = useState(readLastCenter) const centerPlan = useMemo( @@ -303,19 +312,25 @@ export default function MapHomeScreen() { const onLocate = useCallback(() => { const requestGeneration = beginCameraRequest() void (async () => { - const precise = await resolveLocation() + const { coords, prompted } = await resolveLocation() const fallback = approximatePointRef.current initialCenterOwnedRef.current = true - if (precise) { + if (coords) { adoptedSourceRef.current = "precise" - centerOnTarget({ ...precise, zoom: PRECISE_ZOOM }, requestGeneration) + centerOnTarget({ ...coords, zoom: PRECISE_ZOOM }, requestGeneration) return } - if (!fallback) return - adoptedSourceRef.current = "approximate" - centerOnTarget({ ...fallback, zoom: APPROX_ZOOM }, requestGeneration) + if (fallback) { + adoptedSourceRef.current = "approximate" + centerOnTarget({ ...fallback, zoom: APPROX_ZOOM }, requestGeneration) + } + if (!prompted) return + const firstFix = await awaitFirstFix() + if (!firstFix) return + adoptedSourceRef.current = "precise" + centerOnTarget({ ...firstFix, zoom: PRECISE_ZOOM }, requestGeneration) })() - }, [beginCameraRequest, centerOnTarget, resolveLocation]) + }, [awaitFirstFix, beginCameraRequest, centerOnTarget, resolveLocation]) const onboardingDone = useOnboardingStore((s) => s.completedVersion >= ONBOARDING_VERSION) const tourPresenting = useOnboardingStore((s) => s.presenting) @@ -358,6 +373,7 @@ export default function MapHomeScreen() { if (!shouldAdoptCenter(adoptedSourceRef.current, source)) return adoptedSourceRef.current = source initialCenterOwnedRef.current = true + if (useMapFocus.getState().focus || useMapFlyTo.getState().highlight) return centerOnTarget(center) }, [centerPlan, seedCenter, centerOnTarget]) @@ -483,6 +499,7 @@ export default function MapHomeScreen() { { - leftRef.current = true - if (router.canGoBack()) router.back() - else goHome(router) - }, [router]) + const leave = useCallback( + (action: SecondaryShellLeaveAction) => { + leftRef.current = true + if (action === "home" || !router.canGoBack()) { + goHome(router) + return + } + router.back() + }, + [router], + ) const armedRef = useRef(false) useFocusEffect( @@ -110,7 +120,7 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. if (useNavStore.getState().active === null && entryRef.current) seedEntry(entryRef.current) if (useNavStore.getState().active === null) { armedRef.current = false - leave() + leave("back") return } armedRef.current = true @@ -121,7 +131,7 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. } if (!armedRef.current) return armedRef.current = false - leave() + leave(secondaryShellLeaveAction(state.lastTransition)) }) }, [leave]), ) diff --git a/apps/community-mobile/src/hooks/useUserLocation.ts b/apps/community-mobile/src/hooks/useUserLocation.ts index 25a38180..c0e56e2a 100644 --- a/apps/community-mobile/src/hooks/useUserLocation.ts +++ b/apps/community-mobile/src/hooks/useUserLocation.ts @@ -1,16 +1,22 @@ import { useEffect, useMemo, useState, useCallback } from "react" import * as Location from "expo-location" import type { LatLng } from "@civfix/shared/geocode" -import { GPS_TIMEOUT_MS, LAST_KNOWN_MAX_AGE_MS, withTimeout } from "@/lib/withTimeout" +import { FIRST_FIX_TIMEOUT_MS, GPS_TIMEOUT_MS, LAST_KNOWN_MAX_AGE_MS, withTimeout } from "@/lib/withTimeout" export type LocationPermission = "undetermined" | "granted" | "denied" +export interface LocationRefreshResult { + coords: LatLng | null + prompted: boolean +} + export interface UserLocationState { permission: LocationPermission permissionResolved: boolean coords: LatLng | null - resolve: () => Promise - refresh: () => Promise + resolve: () => Promise + refresh: () => Promise + awaitFirstFix: () => Promise } export function useUserLocation(): UserLocationState { @@ -18,13 +24,13 @@ export function useUserLocation(): UserLocationState { const [permissionResolved, setPermissionResolved] = useState(false) const [coords, setCoords] = useState(null) - const readFix = useCallback(async (): Promise => { + const readFix = useCallback(async (timeoutMs: number = GPS_TIMEOUT_MS): Promise => { try { const pos = (await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS })) ?? (await withTimeout( Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }), - GPS_TIMEOUT_MS, + timeoutMs, )) if (!pos) return null const next = { lat: pos.coords.latitude, lng: pos.coords.longitude } @@ -35,17 +41,21 @@ export function useUserLocation(): UserLocationState { } }, []) - const refresh = useCallback(async (): Promise => { + const refresh = useCallback(async (): Promise => { + const before = await Location.getForegroundPermissionsAsync().catch(() => null) const { status } = await Location.requestForegroundPermissionsAsync() setPermissionResolved(true) if (status !== Location.PermissionStatus.GRANTED) { setPermission("denied") - return null + return { coords: null, prompted: false } } setPermission("granted") - return readFix() + const prompted = before !== null && before.status !== Location.PermissionStatus.GRANTED + return { coords: await readFix(), prompted } }, [readFix]) + const awaitFirstFix = useCallback(() => readFix(FIRST_FIX_TIMEOUT_MS), [readFix]) + useEffect(() => { let active = true void (async () => { @@ -71,7 +81,8 @@ export function useUserLocation(): UserLocationState { coords, resolve: refresh, refresh, + awaitFirstFix, }), - [permission, permissionResolved, coords, refresh], + [permission, permissionResolved, coords, refresh, awaitFirstFix], ) } diff --git a/apps/community-mobile/src/lib/detailRouteRestore.test.ts b/apps/community-mobile/src/lib/detailRouteRestore.test.ts index 60f363ad..69cfd580 100644 --- a/apps/community-mobile/src/lib/detailRouteRestore.test.ts +++ b/apps/community-mobile/src/lib/detailRouteRestore.test.ts @@ -421,3 +421,12 @@ test("DetailRouteHost takes its snapshot through detailShellSnapshot, not a raw assert.match(host, /detailShellSnapshot\(useNavStore\.getState\(\)\.stack, seedKey, entryIdentity\)/) assert.doesNotMatch(host, /stack\.filter\(\(e\) => entryIdentity\(e\) !== seedKey\)/) }) + +test("DetailRouteHost dismisses to the root shell when a view switch empties the store", () => { + const host = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "components", "DetailRouteHost.tsx"), + "utf8", + ) + assert.ok(host.includes("leave(secondaryShellLeaveAction(state.lastTransition))")) + assert.ok(host.includes('if (action === "home" || !router.canGoBack())')) +}) diff --git a/apps/community-mobile/src/lib/secondaryShellBack.test.ts b/apps/community-mobile/src/lib/secondaryShellBack.test.ts index e4ad781b..c71226ea 100644 --- a/apps/community-mobile/src/lib/secondaryShellBack.test.ts +++ b/apps/community-mobile/src/lib/secondaryShellBack.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { secondaryShellBackAction } from "./secondaryShellBack.ts" +import { secondaryShellBackAction, secondaryShellLeaveAction } from "./secondaryShellBack.ts" test("hardware back pops the store stack while a parent page remains beneath the top one", () => { assert.equal(secondaryShellBackAction(2, true), "pop-detail") @@ -20,3 +20,18 @@ test("an empty store stack has nothing to pop, so the press falls through to the assert.equal(secondaryShellBackAction(0, true), "system") assert.equal(secondaryShellBackAction(0, false), "system") }) + +test("a leave with no transition, or one caused by a pop, push, replace or restore, goes back to the route beneath", () => { + assert.equal(secondaryShellLeaveAction(null), "back") + assert.equal(secondaryShellLeaveAction({ type: "pop", count: 1 }), "back") + assert.equal(secondaryShellLeaveAction({ type: "push" }), "back") + assert.equal(secondaryShellLeaveAction({ type: "replace" }), "back") + assert.equal(secondaryShellLeaveAction({ type: "restore" }), "back") +}) + +test("a leave caused by a view switch dismisses to the root shell", () => { + assert.equal(secondaryShellLeaveAction({ type: "select" }), "home") + assert.equal(secondaryShellLeaveAction({ type: "select", consumes: true }), "home") + assert.equal(secondaryShellLeaveAction({ type: "seed" }), "home") + assert.equal(secondaryShellLeaveAction({ type: "reset" }), "home") +}) diff --git a/apps/community-mobile/src/lib/secondaryShellBack.ts b/apps/community-mobile/src/lib/secondaryShellBack.ts index cb11dcad..1a656763 100644 --- a/apps/community-mobile/src/lib/secondaryShellBack.ts +++ b/apps/community-mobile/src/lib/secondaryShellBack.ts @@ -1,3 +1,5 @@ +import type { NavTransition } from "@civfix/ui" + export type SecondaryShellBackAction = "pop-detail" | "system" export function secondaryShellBackAction(stackLength: number, canPopRoute: boolean): SecondaryShellBackAction { @@ -5,3 +7,11 @@ export function secondaryShellBackAction(stackLength: number, canPopRoute: boole if (stackLength === 1 && !canPopRoute) return "pop-detail" return "system" } + +export type SecondaryShellLeaveAction = "back" | "home" + +const VIEW_SWITCH_TRANSITIONS: ReadonlySet = new Set(["select", "seed", "reset"]) + +export function secondaryShellLeaveAction(transition: NavTransition | null): SecondaryShellLeaveAction { + return transition && VIEW_SWITCH_TRANSITIONS.has(transition.type) ? "home" : "back" +} diff --git a/apps/community-mobile/src/lib/withTimeout.ts b/apps/community-mobile/src/lib/withTimeout.ts index 44e44e69..5741d392 100644 --- a/apps/community-mobile/src/lib/withTimeout.ts +++ b/apps/community-mobile/src/lib/withTimeout.ts @@ -23,6 +23,8 @@ */ export const GPS_TIMEOUT_MS = 4000 +export const FIRST_FIX_TIMEOUT_MS = 15_000 + /** * Cap how OLD a CACHED device fix may be before we stop treating it as "where the user is". * diff --git a/apps/community-mobile/tests/locateOnGrant.test.ts b/apps/community-mobile/tests/locateOnGrant.test.ts new file mode 100644 index 00000000..22b9dbd0 --- /dev/null +++ b/apps/community-mobile/tests/locateOnGrant.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { test } from "node:test" +import { FIRST_FIX_TIMEOUT_MS, GPS_TIMEOUT_MS } from "../src/lib/withTimeout.ts" + +const hook = readFileSync(new URL("../src/hooks/useUserLocation.ts", import.meta.url), "utf8") +const home = readFileSync(new URL("../app/index.tsx", import.meta.url), "utf8") +const timeouts = readFileSync(new URL("../src/lib/withTimeout.ts", import.meta.url), "utf8") +const nativeMap = readFileSync( + new URL("../../../packages/ui/src/map/Map.native.tsx", import.meta.url), + "utf8", +) + +const refreshBody = hook.slice( + hook.indexOf("const refresh = useCallback("), + hook.indexOf("const awaitFirstFix = useCallback("), +) +const launchEffect = hook.slice(hook.indexOf("useEffect(() => {"), hook.indexOf("return useMemo(")) +const onLocateBody = home.slice( + home.indexOf("const onLocate = useCallback("), + home.indexOf("}, [awaitFirstFix,"), +) +const adoptEffect = home.slice( + home.indexOf("if (initialCenterOwnedRef.current) return"), + home.indexOf("}, [centerPlan, seedCenter, centerOnTarget])"), +) + +function count(haystack: string, needle: string): number { + return haystack.split(needle).length - 1 +} + +test("refresh reads the permission silently BEFORE it asks, so it knows whether the dialog granted it", () => { + const before = refreshBody.indexOf("Location.getForegroundPermissionsAsync().catch(() => null)") + const request = refreshBody.indexOf("Location.requestForegroundPermissionsAsync()") + assert.ok(before > -1) + assert.ok(request > before) + assert.match( + refreshBody, + /const prompted = before !== null && before\.status !== Location\.PermissionStatus\.GRANTED/, + ) +}) + +test("a rejected permission probe never counts as a prompted grant", () => { + assert.match(refreshBody, /getForegroundPermissionsAsync\(\)\.catch\(\(\) => null\)/) + assert.match(refreshBody, /before !== null &&/) + assert.doesNotMatch(refreshBody, /before\?\./) +}) + +test("the first-fix wait is the same fix read under the longer cap", () => { + assert.match(hook, /const readFix = useCallback\(async \(timeoutMs: number = GPS_TIMEOUT_MS\)/) + assert.match(hook, /const awaitFirstFix = useCallback\(\(\) => readFix\(FIRST_FIX_TIMEOUT_MS\), \[readFix\]\)/) +}) + +test("the silent launch check never raises the OS dialog", () => { + assert.match(launchEffect, /getForegroundPermissionsAsync/) + assert.doesNotMatch(launchEffect, /requestForegroundPermissionsAsync/) + assert.equal(count(hook, "requestForegroundPermissionsAsync"), 1) +}) + +test("a locate tap claims ONE camera generation and delivers the late first fix on it, only after a prompted grant", () => { + assert.equal(count(onLocateBody, "beginCameraRequest()"), 1) + const gate = onLocateBody.indexOf("if (!prompted) return") + const wait = onLocateBody.indexOf("await awaitFirstFix()") + assert.ok(gate > -1) + assert.ok(wait > gate) + const flights = onLocateBody.match(/centerOnTarget\([^\n]*\n?/g) ?? [] + assert.equal(flights.length, 3) + for (const flight of flights) assert.match(flight, /requestGeneration\)/) +}) + +test("the silent adoption effect stays latched off by a locate and never waits for a first fix", () => { + assert.ok(adoptEffect.length > 0) + assert.doesNotMatch(adoptEffect, /awaitFirstFix/) + assert.match(home, /if \(initialCenterOwnedRef\.current\) return/) +}) + +test("the first-fix cap lives beside the GPS cap and outlasts it", () => { + assert.match(timeouts, /export const FIRST_FIX_TIMEOUT_MS = /) + assert.ok(FIRST_FIX_TIMEOUT_MS > GPS_TIMEOUT_MS) +}) + +test("a user map gesture claims a newer camera generation, which strands the late first fix", () => { + assert.match( + home, + /const onUserCameraMove = useCallback\(\(\) => \{\n\s+initialCenterOwnedRef\.current = true\n\s+beginCameraRequest\(\)\n\s+\}, \[beginCameraRequest\]\)/, + ) + assert.match(home, /onUserCameraMove=\{onUserCameraMove\}/) +}) + +test("the native seam reports only user-driven camera starts", () => { + assert.match(nativeMap, /onRegionWillChange=\{handleRegionWillChange\}/) + assert.match( + nativeMap, + /if \(!event\.nativeEvent\.userInteraction\) return\n\s+useMapFlyTo\.getState\(\)\.clear\(\)\n\s+onUserCameraMoveRef\.current\?\.\(\)/, + ) +}) + +test("the one-time launch move yields to an active Show on map target and to a gesture, as on web", () => { + const owned = adoptEffect.indexOf("initialCenterOwnedRef.current = true") + const yieldToTarget = adoptEffect.indexOf( + "if (useMapFocus.getState().focus || useMapFlyTo.getState().highlight) return", + ) + const flight = adoptEffect.indexOf("centerOnTarget(center)") + assert.ok(owned > -1) + assert.ok(yieldToTarget > owned) + assert.ok(flight > yieldToTarget) + assert.ok(home.indexOf("const initialCenterOwnedRef = useRef(") < home.indexOf("const onUserCameraMove = useCallback(")) +}) diff --git a/apps/community-mobile/tests/mapLifecycle.test.ts b/apps/community-mobile/tests/mapLifecycle.test.ts index 411c6dd1..a26f9bda 100644 --- a/apps/community-mobile/tests/mapLifecycle.test.ts +++ b/apps/community-mobile/tests/mapLifecycle.test.ts @@ -48,6 +48,44 @@ test("a late ready callback from an old map cannot consume the new map target", assert.deepEqual(lifecycle.takePendingMapTarget(state, 21).target, target) }) +test("a late first fix on the SAME generation still flies when nothing newer began", () => { + const request = lifecycle.beginMapRequest(lifecycle.createMapLifecycleState()) + const approximate = { lat: 34.05, lng: -118.25, zoom: 10 } + const precise = { lat: 34.0522, lng: -118.2437, zoom: 13 } + let state = lifecycle.resolveMapRequest(request.state, request.generation, approximate) + state = lifecycle.mountMap(state, 7) + state = lifecycle.markMapReady(state, 7) + state = lifecycle.takePendingMapTarget(state, 7).state + state = lifecycle.resolveMapRequest(state, request.generation, precise) + assert.deepEqual(state.pendingTarget?.target, precise) +}) + +test("a user gesture during the first-fix wait supersedes the locate, so the late fix never flies", () => { + const request = lifecycle.beginMapRequest(lifecycle.createMapLifecycleState()) + const approximate = { lat: 34.05, lng: -118.25, zoom: 10 } + const precise = { lat: 40.758, lng: -73.9855, zoom: 13 } + let state = lifecycle.resolveMapRequest(request.state, request.generation, approximate) + state = lifecycle.mountMap(state, 8) + state = lifecycle.markMapReady(state, 8) + state = lifecycle.takePendingMapTarget(state, 8).state + + const gesture = lifecycle.beginMapRequest(state) + state = lifecycle.resolveMapRequest(gesture.state, request.generation, precise) + + assert.equal(state.pendingTarget, null) + assert.equal(lifecycle.takePendingMapTarget(state, 8).target, null) +}) + +test("without a gesture the late first fix on the locate generation flies", () => { + const request = lifecycle.beginMapRequest(lifecycle.createMapLifecycleState()) + const precise = { lat: 40.758, lng: -73.9855, zoom: 13 } + let state = lifecycle.mountMap(request.state, 9) + state = lifecycle.markMapReady(state, 9) + state = lifecycle.resolveMapRequest(state, request.generation, precise) + + assert.deepEqual(lifecycle.takePendingMapTarget(state, 9).target, precise) +}) + test("a delayed initial-location result cannot replace a newer camera request", () => { const initial = lifecycle.beginMapRequest(lifecycle.createMapLifecycleState()) const detail = lifecycle.beginMapRequest(initial.state) diff --git a/apps/community-mobile/tests/onboardingShell.test.ts b/apps/community-mobile/tests/onboardingShell.test.ts index fbe8471d..6e6332fe 100644 --- a/apps/community-mobile/tests/onboardingShell.test.ts +++ b/apps/community-mobile/tests/onboardingShell.test.ts @@ -216,7 +216,7 @@ test("no map is mounted until a real centre exists", () => { test("a better source upgrades the camera and a worse one never downgrades it", () => { assert.match( home, - /if \(!shouldAdoptCenter\(adoptedSourceRef\.current, source\)\) return\n\s+adoptedSourceRef\.current = source\n\s+initialCenterOwnedRef\.current = true\n\s+centerOnTarget\(center\)/, + /if \(!shouldAdoptCenter\(adoptedSourceRef\.current, source\)\) return\n\s+adoptedSourceRef\.current = source\n\s+initialCenterOwnedRef\.current = true\n\s+if \(useMapFocus\.getState\(\)\.focus \|\| useMapFlyTo\.getState\(\)\.highlight\) return\n\s+centerOnTarget\(center\)/, ) assert.match( home, diff --git a/apps/community-mobile/tests/postLinkNav.test.ts b/apps/community-mobile/tests/postLinkNav.test.ts new file mode 100644 index 00000000..4f89951d --- /dev/null +++ b/apps/community-mobile/tests/postLinkNav.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, join } from "node:path" +import { test } from "node:test" +import { isInternalLink } from "../src/lib/links.ts" +import { bridgeKey } from "../src/lib/navBridge.ts" + +type RouteEntry = { kind: string; id?: string } + +async function loadNavRoutes(): Promise<{ + entryFromPath: (path: string) => RouteEntry | null + pathForEntry: (entry: RouteEntry) => string +}> { + const require = createRequire(import.meta.url) + const routesPath = join(dirname(require.resolve("@civfix/ui/package.json")), "src", "nav", "routes.ts") + const ts = require("typescript") + const { outputText } = ts.transpileModule(readFileSync(routesPath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }) + return import(`data:text/javascript,${encodeURIComponent(outputText)}`) +} + +const { entryFromPath, pathForEntry } = await loadNavRoutes() +const adapter = readFileSync(new URL("../src/components/MobileNavAdapter.tsx", import.meta.url), "utf8") + +test("a /post/ notification or chat link opens the in-shell post page, not the bridged thread route", () => { + assert.equal(isInternalLink("/post/p1"), true) + const entry = entryFromPath("/post/p1") + assert.deepEqual(entry, { kind: "post", id: "p1" }) + assert.equal(bridgeKey(entry as never), null) +}) + +test("only the explicit /thread address bridges to the native post thread route", () => { + const entry = entryFromPath("/post/p1/thread") + assert.deepEqual(entry, { kind: "post-thread", id: "p1" }) + assert.equal(bridgeKey(entry as never), "post-thread:p1") + assert.equal(pathForEntry({ kind: "post-thread", id: "p1" }), "/post/p1/thread") +}) + +test("applyInternalHref resolves hrefs through the shared parser and bridges by that entry", () => { + const apply = adapter.slice(adapter.indexOf("export function applyInternalHref")) + const body = apply.slice(0, apply.indexOf("\n}")) + assert.match(body, /const entry = entryFromPath\(href\)/) + assert.match(body, /const key = bridgeKey\(entry\)/) +}) diff --git a/apps/community-web/README.md b/apps/community-web/README.md index b67aac35..933a10aa 100644 --- a/apps/community-web/README.md +++ b/apps/community-web/README.md @@ -378,10 +378,10 @@ realtime contract as-is: `GET /threads`, `GET /cleanups/:id/messages`, and the ` preview image - and `public/_headers` pins `Content-Type: image/png` on both files so iMessage gets one un-redirected image response. 2. **Per-entity previews at the edge.** The export is static, so this app's Pages Functions - (`apps/community-web/functions/{pin,cleanups,people,e,orgs}/[[path]].ts` - all paths below are + (`apps/community-web/functions/{pin,cleanups,people,e,orgs,post}/[[path]].ts` - all paths below are relative to this app dir, which is also the cwd `wrangler pages deploy` runs in) fetch the shell from `env.ASSETS`, call the PUBLIC guest API (`/v1/reports/:id`, `/v1/cleanups/:id`, - `/v1/people/:id`, ...) and rewrite `head > title` + + `/v1/people/:id`, `/v1/posts/:id`, ...) and rewrite `head > title` + the meta tags. The split is deliberate: `functions/_preview-core.ts` holds the whole security boundary as plain functions (route parsing, id validation, upstream request construction, fallback policy, cache key/TTL, header re-application) and is unit-tested in node @@ -389,8 +389,8 @@ realtime contract as-is: `GET /threads`, `GET /cleanups/:id/messages`, and the ` `functions/_preview.ts` is only the `HTMLRewriter` glue, injected as `deps.rewrite`. Only `onRequestGet` is exported, so HEAD/POST/OPTIONS have no handler and Cloudflare passes them straight to the asset server (`parsePreviewRoute` re-asserts GET-only anyway). - `public/_routes.json` limits Function invocation to `/pin/*`, `/cleanups/*`, `/people/*`, `/e/*` - and `/orgs/*` (with + `public/_routes.json` limits Function invocation to `/pin/*`, `/cleanups/*`, `/people/*`, `/e/*`, + `/orgs/*` and `/post/*` (with `/cleanups/` and `/people/` excluded so the browse pages are never intercepted - `scripts/cf-pages-postbuild.mjs` fails the build if a colliding browse route is included but not excluded). Cloudflare does NOT apply `_headers` to Function responses, so @@ -427,17 +427,40 @@ realtime contract as-is: `GET /threads`, `GET /cleanups/:id/messages`, and the ` environments - without it a staging negative entry could be replayed for a live prod url. The negative entry stores the outcome (`missing` / `transient`) so a cache replay makes the same noindex decision as the original fetch. -4. **Ids.** Only a single path segment matching `/^[a-z0-9][a-z0-9_-]{0,63}$/` (the shape of the - API's uuid primary keys) is ever previewed. Anything else - a dot, uppercase, 65+ chars, - `..%2F`, a nested route such as `/pin//edit` - short-circuits to the plain SPA shell and - never reaches the API. -5. **Mapping** (pure + unit-tested in `src/lib/link-preview.ts`): report -> title and description - are `REPORT_TYPE_LABELS`/`REPORT_CATEGORY_LABELS`, `REPORT_STATUS_LABELS` and the coarse - `cityName` only (`Graffiti · Los Angeles, CA` / `Graffiti — In progress · Los Angeles, CA`); - image = the first `ready` image whose URL is an UNSIGNED https URL, else the brand image. - Event -> the event `title` (its public name, capped at 80 chars) + the schedule formatted in - `America/Los_Angeles` + the fixed line `A volunteer event on civfix`. Person -> `Name (@handle)` - + `On civfix` + a public avatar. `og:url`, `rel=canonical`, `og:image` and the icon links are +4. **Ids.** Only a single path segment matching one of its kind's id shapes + (`ID_PATTERNS` in `functions/_preview-core.ts`, every one anchored and length-bounded) is ever + previewed: report -> a uuid or a reference code (`GR-12-000001`, type codes from + `REPORT_TYPE_CODE`); event -> a uuid, an `EVENT-12-000045` reference code or a page slug; + person -> a uuid or a handle (`HANDLE_REGEX`, mixed case allowed); org -> an org slug; signup -> + a page slug; post -> a uuid. The id is passed upstream exactly as shared, never lowercased: the + API matches reference codes case-sensitively, so `gr-12-000001` is not a report id and gets the + plain shell. Anything else - a dot, a wrong-case code, an over-long slug, `..%2F`, a nested route + such as `/pin//edit` - short-circuits to the plain SPA shell and never reaches the API. +5. **Mapping** (pure + unit-tested in `src/lib/link-preview.ts`) follows X's card format: the + title is ` on civfix` (headline clamped at 90 chars, 80 for an event title, before + the suffix), the description is the entity's own public text clamped at 200 chars, and the + image is the entity's first picture. Report -> `Graffiti in Los Angeles, CA on civfix` (type or + category label + the coarse `cityName`; reports are publicly anonymous, so there is no byline) + and `In progress · · <description>`, falling back to + `Graffiti — In progress · Los Angeles, CA` when the resident wrote neither. Post -> + `Ada Rivera (@ada) on civfix` (the organization's name and slug when it was posted as one) and + the body, else the attached report's or event's title; a plain repost renders the ORIGINAL + post's card, a quote keeps the quoter's text and borrows the quoted post's image only when it + has none. Event -> `<title> on civfix` and `Cancelled? · <schedule in the event's zone> · <host> + · <description, else "A volunteer event on civfix">`, host = the organization, else + `Name (@handle)` of a live organizer; an UNLISTED event keeps only its title and + `Cancelled? · <schedule> · A volunteer event on civfix` (see 6). Person -> `Name (@handle) on civfix` and the bio. Org -> + `Name (@slug) on civfix` and `Verified … · N events · <description>`. Signup pages keep the + host's own `seo` title/description. Images: a report or post uses the FIRST `ready` slide of + the carousel (`firstCarouselImage`) - its thumbnail, else the full image when it is an image + with no thumbnail (never with `og:image:width`/`height`: the media worker stores the + pre-rotation size, so a portrait phone photo's stored dimensions can be swapped; only the + brand `og.png` carries dimensions); a slide that + has neither is the brand image, never slide two. A post with no media falls back to the + attached report's thumbnail. An event uses its cover, else its first gallery image; a person + their avatar; an org its logo. Every image must be an UNSIGNED https URL. `twitter:card` is + `summary_large_image`, except `summary` for an avatar or logo (a square picture in a large + card is a blurry crop). `og:url`, `rel=canonical`, `og:image` and the icon links are built from the request's ENVIRONMENT origin - `resolveSiteOrigin(request.url)` in `src/lib/site-meta.ts`, which maps an exact known hostname onto that environment's ONE canonical origin (`civfix.org` / `www.civfix.org` / `civfix-web.pages.dev` -> `https://civfix.org`; @@ -448,24 +471,32 @@ realtime contract as-is: `GET /threads`, `GET /cleanups/:id/messages`, and the ` header, so the origin cannot be poisoned into a preview; it exists so a staging card points at `https://civfix.dev/og.png` (which exists) instead of a prod URL that 404s. The entity id still comes from the DTO, falling back to the validated route id. -6. **Privacy rules (load-bearing).** A preview NEVER carries user-authored free text: the report - `title` and `description`, the event `description` and the profile `bio` are all dropped, and - the corresponding fields are absent from `ReportPreviewInput` / `EventPreviewInput` / - `PersonPreviewInput` so they cannot be reintroduced by accident (`link-preview.test.ts` asserts - none of them ever reach the emitted tags). Only server-controlled vocabulary - enum labels, the - formatted schedule, the coarse `cityName`, and a person's own display name/handle - is injected. - A preview NEVER carries a street address (`addr` / `address`), coordinates, an email, or any - viewer-specific field. Non-public reports (`visibility !== "public"`) and deleted accounts get - the defaults, never a preview. Presigned media URLs are refused so no signed token is baked into - HTML that is cached at the edge. Posts (`/post/:id`) are deliberately NOT previewed: `getPost` - is `auth: "required"`, so there is no guest-readable payload to build one from. +6. **Privacy rules (load-bearing).** A preview carries the entity's OWN public text in X's + format; it NEVER carries a street-address field, coordinates, an account email or a + viewer-specific field, never a non-public or deleted entity, never a presigned URL. Owner's + decision: unlisted events stay shareable by link with a title + schedule card, noindex, no + cover, no host text - no organizer or organization byline and no event description, exactly + what an unlisted link showed before the X format. Text a resident typed into a PUBLIC field - a + post body, a report title/description, an event description, an org description, a profile + bio - is theirs to publish and is carried as written (escaped, one line, clamped). What is + dropped, per field: `addr` / `address` (report, event, signup event), `lat` / `lng`, `email`, + and every viewer field (`viewer.*`, `mine`, `chatUnread`, `following`, `isFollowing`, + `blockedByMe`, `myRole`), plus a post's counts and mentions; none of them is declared on the + `*PreviewInput` types, so they cannot be read by accident (`link-preview.test.ts` asserts they + never reach the emitted tags). Non-public reports (`visibility !== "public"`), private events, + deleted accounts, deleted posts and reposts of a deleted post get the defaults, never a + preview. Presigned media URLs are refused so no signed token is baked into + HTML that is cached at the edge. Posts (`/post/:id`) are previewed from `GET /v1/posts/:id`, + which serves a PUBLIC post to a guest; a hidden or deleted post is a 404 there, so it unfurls as + the default card. That route also serves a PUBLIC reply to a guest (accepted, `DECISIONS.md` §55). 7. **Open decisions (follow-ups, not implemented).** - Whether `/people/<id>` previews should ship `robots: noindex` (a public profile card is shareable but arguably should not be search-indexed) - needs a privacy-policy call. - Whether the Function needs its own rate-limit bucket (a shared-secret header from the Function to the API so `/v1/*` can meter edge-originated preview fetches separately from residents). 8. **Verify after deploy:** `curl -sL https://civfix.org/pin/<id> | grep -i 'og:\|<title'` (any UA works; - the rewrite is UA-independent), then the Twitter/X card validator, Discord (paste the link) and + the rewrite is UA-independent), the same for a reference code + (`curl -sL https://civfix.org/pin/GR-12-000001`) and a post (`curl -sL https://civfix.org/post/<uuid>`), then the Twitter/X card validator, Discord (paste the link) and Slack's unfurl. For the Apple/WhatsApp path also curl as their crawlers - `-A 'facebookexternalhit/1.1 Facebot Twitterbot/1.0'` (iMessage) and `-A 'WhatsApp/2.23'` - and confirm `curl -sI https://civfix.org/og.png` answers `200` + `content-type: image/png` with no diff --git a/apps/community-web/functions/_preview-core.ts b/apps/community-web/functions/_preview-core.ts index 7194cbd8..1f04382a 100644 --- a/apps/community-web/functions/_preview-core.ts +++ b/apps/community-web/functions/_preview-core.ts @@ -1,3 +1,12 @@ +import { + HANDLE_REGEX, + ORG_SLUG_MAX, + ORG_SLUG_MIN, + PAGE_SLUG_MAX, + PAGE_SLUG_MIN, + REPORT_TYPE_CODE, +} from "@civfix/shared" + import { PERVASIVE_HEADERS } from "../src/lib/edge-headers" import { PRODUCTION_SITE_URL, @@ -10,12 +19,14 @@ import { previewForEvent, previewForOrganization, previewForPerson, + previewForPost, previewForReport, previewForSignupPage, type EventPreviewInput, type LinkPreview, type OrganizationPreviewInput, type PersonPreviewInput, + type PostPreviewInput, type PreviewContext, type PreviewKind, type ReportPreviewInput, @@ -42,7 +53,6 @@ export interface PreviewDeps { rewrite(shell: Response, preview: LinkPreview): Response } -export const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/ export const API_TIMEOUT_MS = 1500 export const PREVIEW_CACHE_TTL_SEC = 300 export const PREVIEW_NEGATIVE_CACHE_TTL_SEC = 60 @@ -56,12 +66,28 @@ const ALLOWED_API_HOSTNAMES: readonly string[] = ["api.civfix.org", "api.civfix. export const HTML_CONTENT_TYPE = "text/html; charset=utf-8" +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const REPORT_REF = new RegExp(`^(?:${Object.values(REPORT_TYPE_CODE).join("|")})-\\d{1,6}-\\d{6}$`) +const EVENT_REF = /^EVENT-\d{1,6}-\d{6}$/ +const PAGE_SLUG = new RegExp(`^(?=.{${PAGE_SLUG_MIN},${PAGE_SLUG_MAX}}$)[a-z0-9]+(?:-[a-z0-9]+)*$`) +const ORG_SLUG = new RegExp(`^(?=.{${ORG_SLUG_MIN},${ORG_SLUG_MAX}}$)[a-z0-9]+(?:-[a-z0-9]+)*$`) + +export const ID_PATTERNS: Record<PreviewKind, readonly RegExp[]> = { + report: [UUID, REPORT_REF], + event: [UUID, EVENT_REF, PAGE_SLUG], + person: [UUID, HANDLE_REGEX], + signup: [PAGE_SLUG], + org: [ORG_SLUG], + post: [UUID], +} + export const SHELL_PATH: Record<PreviewKind, string> = { report: "/pin/_/", event: "/cleanups/_/", person: "/people/_/", signup: "/e/_/", org: "/orgs/_/", + post: "/post/_/", } export const BROWSE_PATH: Record<PreviewKind, string> = { @@ -70,6 +96,7 @@ export const BROWSE_PATH: Record<PreviewKind, string> = { person: "/__spa/people/", signup: "/e/_/", org: "/orgs/_/", + post: "/post/_/", } export const CANONICAL_PREFIX: Record<PreviewKind, string> = { @@ -78,6 +105,7 @@ export const CANONICAL_PREFIX: Record<PreviewKind, string> = { person: "/people/", signup: "/e/", org: "/orgs/", + post: "/post/", } export const API_PREFIX: Record<PreviewKind, string> = { @@ -86,6 +114,7 @@ export const API_PREFIX: Record<PreviewKind, string> = { person: "/v1/people/", signup: "/v1/pages/", org: "/v1/orgs/by-slug/", + post: "/v1/posts/", } export type PreviewOutcome = "found" | "missing" | "transient" @@ -96,11 +125,12 @@ export type PreviewRoute = | { action: "shell" } | { action: "preview"; id: string } -export function isValidPreviewId(id: string): boolean { - return ID_PATTERN.test(id) +export function isValidPreviewId(kind: PreviewKind, id: string): boolean { + return ID_PATTERNS[kind].some((pattern) => pattern.test(id)) } export function parsePreviewRoute( + kind: PreviewKind, method: string, raw: string | string[] | undefined, ): PreviewRoute { @@ -108,7 +138,7 @@ export function parsePreviewRoute( const segments = (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((part) => part.length > 0) if (segments.length === 0) return { action: "browse" } const id = segments[0] as string - if (segments.length > 1 || !isValidPreviewId(id)) return { action: "shell" } + if (segments.length > 1 || !isValidPreviewId(kind, id)) return { action: "shell" } return { action: "preview", id } } @@ -234,7 +264,7 @@ export function resolveEntityId(kind: PreviewKind, payload: unknown, fallback: s : kind === "signup" || kind === "org" ? root?.slug : root?.id - return typeof raw === "string" && isValidPreviewId(raw) ? raw : fallback + return typeof raw === "string" && isValidPreviewId(kind, raw) ? raw : fallback } export function previewContextFor( @@ -257,6 +287,7 @@ export function buildPreview( if (kind === "event") return previewForEvent(payload as EventPreviewInput, context) if (kind === "signup") return previewForSignupPage(payload as SignupPagePreviewInput, context) if (kind === "org") return previewForOrganization(payload as OrganizationPreviewInput, context) + if (kind === "post") return previewForPost(payload as PostPreviewInput, context) const profile = (payload as { profile?: unknown }).profile if (!profile || typeof profile !== "object") return null return previewForPerson(profile as PersonPreviewInput, context) @@ -330,7 +361,7 @@ export async function runPreview( deps: PreviewDeps, ): Promise<Response> { const { request, env } = context - const route = parsePreviewRoute(request.method, context.params.path) + const route = parsePreviewRoute(kind, request.method, context.params.path) if (route.action === "passthrough") { return withPervasiveHeaders(await env.ASSETS.fetch(request)) diff --git a/apps/community-web/functions/_preview.test.ts b/apps/community-web/functions/_preview.test.ts index ccf01e8e..74cd51b4 100644 --- a/apps/community-web/functions/_preview.test.ts +++ b/apps/community-web/functions/_preview.test.ts @@ -32,8 +32,10 @@ import { type PreviewContextArg, type PreviewEnv, } from "./_preview-core" +import type { PreviewKind } from "../src/lib/link-preview" const SHELL_HTML = "<html><head><title>civfix" +const UUID = "8f14e45f-ceea-467a-9b2e-9a1f0d7c1b22" const REPORT_PAYLOAD = { id: "8f14e45f-ceea-467a-9b2e-9a1f0d7c1b22", visibility: "public", @@ -116,43 +118,48 @@ afterEach(() => { describe("parsePreviewRoute", () => { it("only ever previews a GET", () => { - expect(parsePreviewRoute("HEAD", ["abc"])).toEqual({ action: "passthrough" }) - expect(parsePreviewRoute("POST", ["abc"])).toEqual({ action: "passthrough" }) - expect(parsePreviewRoute("OPTIONS", ["abc"])).toEqual({ action: "passthrough" }) - expect(parsePreviewRoute("get", ["abc"])).toEqual({ action: "preview", id: "abc" }) + expect(parsePreviewRoute("report", "HEAD", [UUID])).toEqual({ action: "passthrough" }) + expect(parsePreviewRoute("report", "POST", [UUID])).toEqual({ action: "passthrough" }) + expect(parsePreviewRoute("report", "OPTIONS", [UUID])).toEqual({ action: "passthrough" }) + expect(parsePreviewRoute("report", "get", [UUID])).toEqual({ action: "preview", id: UUID }) }) it("serves the browse page for the bare prefix", () => { - expect(parsePreviewRoute("GET", undefined)).toEqual({ action: "browse" }) - expect(parsePreviewRoute("GET", [])).toEqual({ action: "browse" }) - expect(parsePreviewRoute("GET", [""])).toEqual({ action: "browse" }) + expect(parsePreviewRoute("report", "GET", undefined)).toEqual({ action: "browse" }) + expect(parsePreviewRoute("report", "GET", [])).toEqual({ action: "browse" }) + expect(parsePreviewRoute("report", "GET", [""])).toEqual({ action: "browse" }) }) it("leaves nested SPA routes such as /pin//edit untouched", () => { - expect(parsePreviewRoute("GET", ["abc", "edit"])).toEqual({ action: "shell" }) - expect(parsePreviewRoute("GET", ["abc", "chat", "1"])).toEqual({ action: "shell" }) + expect(parsePreviewRoute("report", "GET", [UUID, "edit"])).toEqual({ action: "shell" }) + expect(parsePreviewRoute("report", "GET", [UUID, "chat", "1"])).toEqual({ action: "shell" }) }) - it("rejects every id shape that is not a plain lowercase entity id", () => { - for (const bad of [ - "a.b", - "ABCDEF", - "MixedCase", - "a".repeat(65), - "..%2F", - "..%2Fetc", - "../secret", - "a/b", - "a b", - "a?b", - "%2e%2e", - "-leading", - "", - ]) { - expect(isValidPreviewId(bad)).toBe(false) + it("accepts exactly the id shapes each kind's API resolves, case intact", () => { + const matrix: Record = { + report: { + ok: [UUID, "GR-12-000001", "DU-4970-000001"], + bad: ["gr-12-000001", "EVENT-12-000045", "XX-1-000001", "GR-12-1"], + }, + event: { + ok: [UUID, "EVENT-12-000045", "beach-cleanup-may"], + bad: ["Event-12-000045", "EVENT-12-45", "GR-12-000001", "Beach-Cleanup", "ab"], + }, + person: { + ok: [UUID, "ada", "Ada_Rivera"], + bad: ["ab", "ada.r", "a".repeat(21), "ada-r"], + }, + org: { ok: ["river-keepers"], bad: ["River-Keepers", "ab", "a".repeat(41)] }, + signup: { ok: ["beach-cleanup-may"], bad: ["a".repeat(61)] }, + post: { ok: [UUID], bad: ["GR-12-000001", "ada"] }, + } + const hostile = ["a.b", "..%2F", "../secret", "a/b", "a b", "a?b", "%2e%2e", "-leading", ""] + for (const [kind, { ok, bad }] of Object.entries(matrix) as [PreviewKind, (typeof matrix)[PreviewKind]][]) { + for (const id of ok) expect(isValidPreviewId(kind, id), `${kind} ${id}`).toBe(true) + for (const id of [...bad, ...hostile]) { + expect(isValidPreviewId(kind, id), `${kind} ${id}`).toBe(false) + } } - expect(isValidPreviewId("8f14e45f-ceea-467a-9b2e-9a1f0d7c1b22")).toBe(true) - expect(isValidPreviewId("a".repeat(64))).toBe(true) }) }) @@ -168,6 +175,22 @@ describe("buildUpstreamRequest", () => { expect(API_TIMEOUT_MS).toBe(1500) }) + it("forwards a reference code and a mixed-case handle upstream unchanged", () => { + const base = "https://api.civfix.org" + expect(buildUpstreamRequest("report", "GR-12-000001", base).url).toBe( + "https://api.civfix.org/v1/reports/GR-12-000001", + ) + expect(buildUpstreamRequest("event", "EVENT-12-000045", base).url).toBe( + "https://api.civfix.org/v1/cleanups/EVENT-12-000045", + ) + expect(buildUpstreamRequest("person", "Ada_Rivera", base).url).toBe( + "https://api.civfix.org/v1/people/Ada_Rivera", + ) + expect(buildUpstreamRequest("post", UUID, base).url).toBe( + `https://api.civfix.org/v1/posts/${UUID}`, + ) + }) + it("percent-encodes the id into the upstream path", () => { expect(buildUpstreamRequest("event", "a-b_c", "https://api.civfix.dev").url).toBe( "https://api.civfix.dev/v1/cleanups/a-b_c", @@ -306,7 +329,7 @@ describe("cache policy", () => { const response = await runPreview(prod.context, "report", { rewrite: prod.rewrite }) expect(prod.fetchSpy).toHaveBeenCalledTimes(1) const html = await response.text() - expect(html).toContain('') + expect(html).toContain('') expect(html).not.toContain("robots") }) }) @@ -347,7 +370,16 @@ describe("runPreview", () => { }) it("never lets a hostile id reach upstream", async () => { - for (const bad of ["..%2F", "../secret", "a.b", "ABCDEF", "a".repeat(65)]) { + for (const bad of [ + "..%2F", + "../secret", + "a.b", + "ABCDEF", + "a".repeat(65), + "gr-12-000001", + "EVENT-12-000045", + "Ada", + ]) { const h = harness({ path: [bad] }) const response = await runPreview(h.context, "report", { rewrite: h.rewrite }) expect(h.fetchSpy).not.toHaveBeenCalled() @@ -355,6 +387,19 @@ describe("runPreview", () => { } }) + it("previews a report shared by its reference code", async () => { + const cache = makeCache() + vi.stubGlobal("caches", { default: cache }) + const h = harness({ url: "https://civfix.org/pin/GR-12-000001", path: ["GR-12-000001"] }) + const html = await (await runPreview(h.context, "report", { rewrite: h.rewrite })).text() + expect(h.fetchSpy.mock.calls[0]?.[0]?.url).toBe( + "https://api.civfix.org/v1/reports/GR-12-000001", + ) + await Promise.all(h.waited) + expect([...cache.entries.keys()].some((key) => key.includes("/report/GR-12-000001"))).toBe(true) + expect(html).toContain(``) + }) + it("falls back to the branded default head on 404, 500, 429 and a timeout", async () => { const upstreams = [ async () => new Response("", { status: 404 }), @@ -442,7 +487,7 @@ describe("runPreview", () => { const h = harness({ url: `https://${host}/pin/${id}` }) const html = await (await runPreview(h.context, "report", { rewrite: h.rewrite })).text() expect(html).toContain('') - expect(html).toContain('') + expect(html).toContain('') } }) @@ -453,7 +498,7 @@ describe("runPreview", () => { const h = harness({ url: `https://${host}/pin/${id}` }) const html = await (await runPreview(h.context, "report", { rewrite: h.rewrite })).text() expect(html).not.toContain("robots") - expect(html).toContain('') + expect(html).toContain('') } }) @@ -497,7 +542,7 @@ describe("runPreview", () => { expect(h.rewrite).toHaveBeenCalledTimes(1) expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate") const html = await response.text() - expect(html).toContain('') + expect(html).toContain('') }) }) @@ -523,11 +568,11 @@ describe("canonical url", () => { it("reads the person id out of the profile envelope", () => { const preview = buildPreview( "person", - { profile: { id: "ada-id", name: "Ada Rivera", handle: "ada" } }, + { profile: { id: "5b2d7e1c-3f4a-4b6c-8d9e-0a1b2c3d4e5f", name: "Ada Rivera", handle: "ada" } }, "route-id", "https://civfix.org", ) - expect(preview?.url).toBe("https://civfix.org/people/ada-id") + expect(preview?.url).toBe("https://civfix.org/people/5b2d7e1c-3f4a-4b6c-8d9e-0a1b2c3d4e5f") }) }) @@ -556,29 +601,30 @@ describe("preview head on the staging host", () => { const { html, response } = await renderEvent( `https://civfix.dev/cleanups/${EVENT_PAYLOAD.id}`, ) - const description = "Sat, Sep 12, 10:00 AM PDT · A volunteer event on civfix" + const description = "Sat, Sep 12, 10:00 AM PDT · Bring gloves. Ask for Dana at 12 Elm St, apt 5." + const title = "Ballona Creek cleanup on civfix" expect(response.headers.get("Content-Type")).toBe(HTML_CONTENT_TYPE) for (const tag of [ - "Ballona Creek cleanup · civfix", + `${title}`, ``, '', '', '', ``, - '', + ``, ``, '', '', '', '', '', - '', + ``, '', - '', + ``, ``, '', - '', + ``, ``, '', '', @@ -588,11 +634,10 @@ describe("preview head on the staging host", () => { expect(html).not.toContain("civfix.org") }) - it("still refuses to echo host-authored free text", async () => { + it("carries the host's description but never the address field", async () => { const { html } = await renderEvent(`https://civfix.dev/cleanups/${EVENT_PAYLOAD.id}`) - for (const secret of ["gloves", "Dana", "Elm St", "Secret Ln"]) { - expect(html).not.toContain(secret) - } + expect(html).toContain("gloves") + expect(html).not.toContain("Secret Ln") }) it("takes the origin from the served host only, never from a client-supplied header", async () => { @@ -771,3 +816,101 @@ describe("signup page previews (/e/:slug)", () => { expect(h.rewrite).not.toHaveBeenCalled() }) }) + +const POST_ID = "0c9a4f3e-2b1d-4e6f-9a8b-7c6d5e4f3a2b" +const POST_PAYLOAD = { + id: POST_ID, + kind: "post", + body: "Cleared the storm drain on Venice Blvd this morning.", + author: { id: "a1", name: "Ada Rivera", handle: "ada", email: "ada@example.com" }, + organization: null, + media: [ + { + id: "m1", + kind: "image", + status: "ready", + url: "https://cdn.civfix.dev/media/m1.jpg", + thumbUrl: "https://cdn.civfix.dev/media/m1_thumb.jpg", + width: 3024, + height: 4032, + }, + ], + viewer: { liked: true, reposted: false, saved: true }, +} + +function postHarness(options: { path?: string[]; upstream?: () => Promise } = {}): Harness { + return harness({ + url: `https://civfix.dev/post/${POST_ID}`, + path: options.path ?? [POST_ID], + upstream: options.upstream ?? (async () => new Response(JSON.stringify(POST_PAYLOAD), { status: 200 })), + }) +} + +describe("post previews (/post/:id)", () => { + it("reads the post from /v1/posts/ and serves the /post/_/ shell", async () => { + const h = harness({ + url: `https://civfix.org/post/${POST_ID}`, + path: [POST_ID], + upstream: async () => new Response(JSON.stringify(POST_PAYLOAD), { status: 200 }), + }) + await runPreview(h.context, "post", { rewrite: h.rewrite }) + expect(String(h.assets.mock.calls[0]?.[0])).toBe("https://civfix.org/post/_/") + expect(h.fetchSpy.mock.calls[0]?.[0]?.url).toBe(`https://api.civfix.org/v1/posts/${POST_ID}`) + }) + + it("canonicalizes to /post/ from the DTO", () => { + const preview = buildPreview("post", POST_PAYLOAD, "route-id", "https://civfix.org") + expect(preview?.url).toBe(`https://civfix.org/post/${POST_ID}`) + }) + + it("emits the author byline, body, first image and article type on the staging origin", async () => { + const h = postHarness() + const html = await (await runPreview(h.context, "post", { rewrite: h.rewrite })).text() + const title = "Ada Rivera (@ada) on civfix" + const body = POST_PAYLOAD.body + const thumb = "https://cdn.civfix.dev/media/m1_thumb.jpg" + for (const tag of [ + `${title}`, + '', + ``, + '', + ``, + ``, + ``, + ``, + ``, + ``, + '', + ``, + ``, + ``, + ``, + ]) { + expect(html).toContain(tag) + } + expect(html).not.toContain("og:image:width") + expect(html).not.toContain("ada@example.com") + }) + + it("serves the branded default, noindexed, when the API answers 404 — a hidden post is indistinguishable from a missing one", async () => { + const h = harness({ + url: `https://civfix.org/post/${POST_ID}`, + path: [POST_ID], + upstream: async () => new Response(JSON.stringify({ error: { code: "NOT_FOUND" } }), { status: 404 }), + }) + const html = await (await runPreview(h.context, "post", { rewrite: h.rewrite })).text() + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain(``) + }) + + it("leaves /post//thread as the SPA shell and rejects a non-uuid id before any upstream call", async () => { + for (const path of [[POST_ID, "thread"], ["GR-12-000001"], ["ada"], [POST_ID.toUpperCase()]]) { + const h = postHarness({ path }) + const response = await runPreview(h.context, "post", { rewrite: h.rewrite }) + expect(h.fetchSpy).not.toHaveBeenCalled() + expect(h.rewrite).not.toHaveBeenCalled() + expect(await response.text()).toBe(SHELL_HTML) + } + }) +}) diff --git a/apps/community-web/functions/post/[[path]].ts b/apps/community-web/functions/post/[[path]].ts new file mode 100644 index 00000000..0cfada05 --- /dev/null +++ b/apps/community-web/functions/post/[[path]].ts @@ -0,0 +1,3 @@ +import { handlePreview, type PreviewContextArg } from "../_preview" + +export const onRequestGet = (context: PreviewContextArg) => handlePreview(context, "post") diff --git a/apps/community-web/public/_headers b/apps/community-web/public/_headers index 5d96f47a..a37b2996 100644 --- a/apps/community-web/public/_headers +++ b/apps/community-web/public/_headers @@ -58,6 +58,9 @@ /apple-touch-icon.png Content-Type: image/png +/manifest.webmanifest + Content-Type: application/manifest+json + # --- Internal SPA browse-fallback copies: keep them out of search indexes. --- # scripts/cf-pages-postbuild.mjs copies each browse page to /__spa// so the _redirects # protective rules have a non-looping target. They duplicate the canonical // page, so tell diff --git a/apps/community-web/public/_routes.json b/apps/community-web/public/_routes.json index 4b21ea1b..4a7a6a0f 100644 --- a/apps/community-web/public/_routes.json +++ b/apps/community-web/public/_routes.json @@ -1,5 +1,5 @@ { "version": 1, - "include": ["/pin/*", "/cleanups/*", "/people/*", "/e/*", "/orgs/*"], + "include": ["/pin/*", "/cleanups/*", "/people/*", "/e/*", "/orgs/*", "/post/*"], "exclude": ["/cleanups/", "/people/"] } diff --git a/apps/community-web/src/app/layout.tsx b/apps/community-web/src/app/layout.tsx index 8d8ad10c..a73da13b 100644 --- a/apps/community-web/src/app/layout.tsx +++ b/apps/community-web/src/app/layout.tsx @@ -56,6 +56,7 @@ export const metadata: Metadata = { description: DEFAULT_DESCRIPTION, images: [{ url: BRAND_IMAGE_PATH, alt: SITE_NAME }], }, + appleWebApp: { capable: true, title: SITE_NAME, statusBarStyle: "default" }, } export const viewport: Viewport = { @@ -65,6 +66,7 @@ export const viewport: Viewport = { ], width: "device-width", initialScale: 1, + viewportFit: "cover", } const APPEARANCE_SCRIPT = `(function(){try{ diff --git a/apps/community-web/src/app/manifest.test.ts b/apps/community-web/src/app/manifest.test.ts new file mode 100644 index 00000000..79ed1ee0 --- /dev/null +++ b/apps/community-web/src/app/manifest.test.ts @@ -0,0 +1,25 @@ +import { tokens } from "@civfix/shared/tokens" +import { describe, expect, it } from "vitest" +import { APPLE_TOUCH_ICON_PATH, ICON_PATH, SITE_NAME } from "@/lib/site-meta" +import manifest from "./manifest" + +describe("the web app manifest", () => { + const value = manifest() + + it("installs as a standalone app that opens on the home feed", () => { + expect(value.display).toBe("standalone") + expect(value.start_url).toBe("/") + expect(value.name).toBe(SITE_NAME) + }) + + it("paints the launch surface in the shell's paper colour", () => { + expect(value.background_color).toBe(tokens.color.neutral.paper) + expect(value.theme_color).toBe(tokens.color.neutral.paper) + }) + + it("only points at icons the export actually ships", () => { + const sources = (value.icons ?? []).map((icon) => icon.src) + expect(sources.length).toBeGreaterThan(0) + for (const src of sources) expect([ICON_PATH, APPLE_TOUCH_ICON_PATH]).toContain(src) + }) +}) diff --git a/apps/community-web/src/app/manifest.ts b/apps/community-web/src/app/manifest.ts new file mode 100644 index 00000000..e1fb7ad2 --- /dev/null +++ b/apps/community-web/src/app/manifest.ts @@ -0,0 +1,29 @@ +import type { MetadataRoute } from "next" +import { tokens } from "@civfix/shared/tokens" +import { + APPLE_TOUCH_ICON_PATH, + APPLE_TOUCH_ICON_SIZES, + BRAND_IMAGE_TYPE, + DEFAULT_DESCRIPTION, + ICON_PATH, + ICON_TYPE, + SITE_NAME, +} from "@/lib/site-meta" + +export const dynamic = "force-static" + +export default function manifest(): MetadataRoute.Manifest { + return { + name: SITE_NAME, + short_name: SITE_NAME, + description: DEFAULT_DESCRIPTION, + start_url: "/", + display: "standalone", + background_color: tokens.color.neutral.paper, + theme_color: tokens.color.neutral.paper, + icons: [ + { src: ICON_PATH, sizes: "any", type: ICON_TYPE }, + { src: APPLE_TOUCH_ICON_PATH, sizes: APPLE_TOUCH_ICON_SIZES, type: BRAND_IMAGE_TYPE }, + ], + } +} diff --git a/apps/community-web/src/components/home/home-shell.test.ts b/apps/community-web/src/components/home/home-shell.test.ts index 2ed14d30..46510379 100644 --- a/apps/community-web/src/components/home/home-shell.test.ts +++ b/apps/community-web/src/components/home/home-shell.test.ts @@ -25,6 +25,10 @@ vi.mock("@/components/home/use-web-nav-adapter", () => ({ useWebNavAdapter: vi.fn(), })) +vi.mock("react-native-safe-area-context", () => ({ + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, +})) + const teamInviteAccept = vi.hoisted(() => vi.fn()) vi.mock("@/components/home/use-team-invite-accept", () => ({ @@ -61,11 +65,22 @@ function renderShell(): React.ReactElement<{ children?: React.ReactNode }> { return AppShellFrame() } +function findAppShell(nodes: React.ReactNode): React.ReactElement | undefined { + for (const child of React.Children.toArray(nodes)) { + if (isAppShellElement(child)) return child + if (React.isValidElement<{ children?: React.ReactNode }>(child)) { + const nested = findAppShell(child.props.children) + if (nested) return nested + } + } + return undefined +} + function appShellSlots(): AppShellSlots { const shell = renderShell() const shellChildren = React.Children.toArray(shell.props.children) const content = shellChildren.find(isMainElement) - const appShell = React.Children.toArray(content?.props.children ?? shellChildren).find(isAppShellElement) + const appShell = findAppShell(content?.props.children ?? shellChildren) if (!appShell) throw new Error("HomeShell did not render AppShell") return appShell.props diff --git a/apps/community-web/src/components/home/home-shell.tsx b/apps/community-web/src/components/home/home-shell.tsx index e0fa47fe..769840ea 100644 --- a/apps/community-web/src/components/home/home-shell.tsx +++ b/apps/community-web/src/components/home/home-shell.tsx @@ -2,6 +2,7 @@ import * as React from "react" import dynamic from "next/dynamic" +import { SafeAreaProvider, type Metrics } from "react-native-safe-area-context" import { useLayoutMode } from "@civfix/ui/theme" import { useT } from "@civfix/ui/i18n" @@ -168,6 +169,13 @@ function skipToContent(event: React.MouseEvent): void { target.focus({ preventScroll: true }) } +const NO_SAFE_AREA: Metrics = { + insets: { top: 0, right: 0, bottom: 0, left: 0 }, + frame: { x: 0, y: 0, width: 0, height: 0 }, +} + +const SHELL_FILL = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const + function ShellFrame({ children }: { children: React.ReactNode }) { const shell = React.useRef(null) const layoutMode = useLayoutMode() @@ -207,24 +215,26 @@ export function AppShellFrame() { {/* The fixed banner comes before the app shell in DOM order, while the skip link targets this stable main landmark in both map and non-map compact views. */} - - - - } - mapControls={ - - } - authOverlay={ - <> - - - - } - /> + + + + + } + mapControls={ + + } + authOverlay={ + <> + + + + } + /> + ) diff --git a/apps/community-web/src/components/home/nav-history.test.ts b/apps/community-web/src/components/home/nav-history.test.ts index 050b395a..eb0ab8d0 100644 --- a/apps/community-web/src/components/home/nav-history.test.ts +++ b/apps/community-web/src/components/home/nav-history.test.ts @@ -8,6 +8,7 @@ import { } from "@civfix/ui/nav" import { + entryFromWebPath, pathForSnapshot, readNavHistory, reconcilePlan, @@ -400,12 +401,27 @@ describe("pathForSnapshot", () => { snapshot({ stack: [{ kind: "person", id: "u1" }] }), snapshot({ stack: [{ kind: "thread", id: "t1", roomKind: "dm" }] }), snapshot({ stack: [{ kind: "activity" }] }), + snapshot({ stack: [{ kind: "post", id: "p1" }] }), + snapshot({ stack: [{ kind: "post-thread", id: "p1" }] }), ] for (const source of cases) { const path = pathForSnapshot(source) - const entry = entryFromPath(path) + const entry = entryFromWebPath(path) expect(entry).not.toBeNull() expect(pathForSnapshot(snapshot({ ...source, stack: [entry as DetailEntry] }))).toBe(path) } }) }) + +describe("web post addresses", () => { + it("writes a post thread at the short shared address, never the /thread form", () => { + expect(pathForSnapshot(snapshot({ stack: [{ kind: "post-thread", id: "p1" }] }))).toBe("/post/p1/") + }) + + it("reads a shared post link as its thread on web while the shared parser keeps it a post", () => { + expect(entryFromPath("/post/p1")).toEqual({ kind: "post", id: "p1" }) + expect(entryFromWebPath("/post/p1")).toEqual({ kind: "post-thread", id: "p1" }) + expect(entryFromWebPath("/post/p1/thread")).toEqual({ kind: "post-thread", id: "p1" }) + expect(entryFromWebPath("/pin/a")).toEqual(entryFromPath("/pin/a")) + }) +}) diff --git a/apps/community-web/src/components/home/nav-history.ts b/apps/community-web/src/components/home/nav-history.ts index 4f2f6922..c99ec69b 100644 --- a/apps/community-web/src/components/home/nav-history.ts +++ b/apps/community-web/src/components/home/nav-history.ts @@ -1,6 +1,7 @@ import { ALL_DETAIL_KINDS, ALL_VIEWS, + entryFromPath, entryIdentity, pathForEntry, pathForView, @@ -174,9 +175,18 @@ function exportPath(path: string): string { return path.endsWith("/") ? path : `${path}/` } +export function entryFromWebPath(path: string | null | undefined): DetailEntry | null { + const entry = entryFromPath(path) + return entry?.kind === "post" && entry.id ? { kind: "post-thread", id: entry.id } : entry +} + +export function webPathForEntry(entry: DetailEntry): string { + return entry.kind === "post-thread" && entry.id ? `/post/${entry.id}` : pathForEntry(entry) +} + export function pathForSnapshot(snapshot: NavSnapshot): string { const active = snapshot.stack[snapshot.stack.length - 1] - if (active && active.kind !== "drop-pin") return exportPath(pathForEntry(active)) + if (active && active.kind !== "drop-pin") return exportPath(webPathForEntry(active)) const viewPath = pathForView(snapshot.view) if (viewPath) return exportPath(viewPath) const listKind = LIST_KIND_FOR_VIEW[snapshot.view] diff --git a/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx b/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx index 1cfb9887..79c80aff 100644 --- a/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx +++ b/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx @@ -9,7 +9,7 @@ vi.mock("@civfix/ui", async () => { import { entryFromPath, useNavStore, type DetailEntry } from "@civfix/ui/nav" import { readNavHistory } from "./nav-history" -import { useWebNavAdapter } from "./use-web-nav-adapter" +import { useWebNavAdapter, webOpenInternalHref } from "./use-web-nav-adapter" function Host() { useWebNavAdapter() @@ -165,6 +165,41 @@ describe("mount", () => { expect(nav().view).toBe("report") }) + it("opens a cold shared post link as its thread and keeps the short address", () => { + window.history.replaceState(null, "", "/post/p1") + mount() + expect(nav().stack).toEqual([{ kind: "post-thread", id: "p1" }]) + expect(nav().seededDetailPage).toBe(true) + expect(path()).toBe("/post/p1/") + expect(depth()).toBe(1) + }) + + it("opens a post notification as the same thread screen a reload or a cold link shows", async () => { + mount() + const entry = webOpenInternalHref.entryFor?.("/post/p1") ?? null + expect(entry).toEqual({ kind: "post-thread", id: "p1" }) + await drive(() => nav().push(entry as DetailEntry)) + expect(path()).toBe("/post/p1/") + await reload() + expect(nav().active).toEqual({ kind: "post-thread", id: "p1" }) + cleanup() + window.history.replaceState(null, "", "/post/p1/") + mount() + expect(nav().active).toEqual({ kind: "post-thread", id: "p1" }) + }) + + it("opens an in-chat post link as the thread too", async () => { + mount() + let opened = false + await drive(() => { + opened = webOpenInternalHref.open("/post/p2") + }) + expect(opened).toBe(true) + expect(nav().active).toEqual({ kind: "post-thread", id: "p2" }) + expect(path()).toBe("/post/p2/") + expect(webOpenInternalHref.open("/not-a-route")).toBe(false) + }) + it("restores the stamped snapshot instead of re-seeding, so a reload keeps the stack", async () => { mount() await drive(() => nav().push(PIN_A)) diff --git a/apps/community-web/src/components/home/use-web-nav-adapter.test.ts b/apps/community-web/src/components/home/use-web-nav-adapter.test.ts index 85e45022..d04e3c6d 100644 --- a/apps/community-web/src/components/home/use-web-nav-adapter.test.ts +++ b/apps/community-web/src/components/home/use-web-nav-adapter.test.ts @@ -5,12 +5,13 @@ const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url) const adapter = read("./use-web-nav-adapter.ts") const preview = read("../dev/landscape-preview.tsx") +const providers = read("../providers.tsx") describe("the mount seed takes its layout mode from the shared shell rule", () => { it("seeds the nav store through layoutModeFor, not a local orientation test", () => { expect(adapter).toContain("layoutModeFor,") expect(adapter).toContain("return layoutModeFor(window.innerWidth, window.innerHeight)") - expect(adapter).toContain("seed(entryFromPath(pathname), liveMode())") + expect(adapter).toContain("seed(entryFromWebPath(pathname), liveMode())") expect(adapter).not.toContain("window.innerWidth >= window.innerHeight") }) @@ -20,3 +21,21 @@ describe("the mount seed takes its layout mode from the shared shell rule", () = expect(preview).not.toContain("window.innerWidth >= window.innerHeight") }) }) + +describe("focus follows the page on top", () => { + it("skips hidden and inert layers and falls back to the page layer when it has no heading", () => { + expect(adapter).toContain(`.filter((element) => !element.closest('[aria-hidden="true"], [inert]'))`) + expect(adapter).toContain( + 'lastFocusable("[data-civfix-panel-heading]") ?? lastFocusable("[data-civfix-page-layer]")', + ) + }) +}) + +describe("in-app hrefs resolve through the web address map", () => { + it("hands the web mapping to the internal-href capability that notifications and chat links use", () => { + expect(adapter).toContain("entryFor: entryFromWebPath,") + expect(adapter).toMatch(/const entry = entryFromWebPath\(path\)\s+if \(!entry\) return false/) + expect(providers).toContain("openInternalHref: webOpenInternalHref,") + expect(providers).not.toContain("entryFromPath") + }) +}) diff --git a/apps/community-web/src/components/home/use-web-nav-adapter.ts b/apps/community-web/src/components/home/use-web-nav-adapter.ts index 1960348a..79b3b7b2 100644 --- a/apps/community-web/src/components/home/use-web-nav-adapter.ts +++ b/apps/community-web/src/components/home/use-web-nav-adapter.ts @@ -3,15 +3,16 @@ import * as React from "react" import { useNavStore, - entryFromPath, layoutModeFor, takeNavSnapshot, ROOT_NAV_SNAPSHOT, type NavSnapshot, type NavTransition, } from "@civfix/ui" +import type { OpenInternalHrefCapability } from "@civfix/ui/capabilities" import { + entryFromWebPath, pathForSnapshot, readNavHistory, reconcilePlan, @@ -49,7 +50,23 @@ function seedPathname(): string { * layout mode. */ function seedStoreFromPath(pathname: string): void { - useNavStore.getState().seed(entryFromPath(pathname), liveMode()) + useNavStore.getState().seed(entryFromWebPath(pathname), liveMode()) +} + +export const webOpenInternalHref: OpenInternalHrefCapability = { + entryFor: entryFromWebPath, + open: (path: string): boolean => { + const entry = entryFromWebPath(path) + if (!entry) return false + useNavStore.getState().push(entry) + return true + }, +} + +function lastFocusable(selector: string): Element | undefined { + return Array.from(document.querySelectorAll(selector)) + .filter((element) => !element.closest('[aria-hidden="true"], [inert]')) + .pop() } function liveSnapshot(): NavSnapshot { @@ -344,8 +361,9 @@ export function useWebNavAdapter(): void { if (typeof window === "undefined") return if (active) { requestAnimationFrame(() => { - const h = document.querySelector("[data-civfix-panel-heading]") - if (h instanceof HTMLElement) h.focus() + const target = + lastFocusable("[data-civfix-panel-heading]") ?? lastFocusable("[data-civfix-page-layer]") + if (target instanceof HTMLElement) target.focus({ preventScroll: true }) }) } }) diff --git a/apps/community-web/src/components/promo/app-download-banner.tsx b/apps/community-web/src/components/promo/app-download-banner.tsx index 6ca1789f..7f7c05ac 100644 --- a/apps/community-web/src/components/promo/app-download-banner.tsx +++ b/apps/community-web/src/components/promo/app-download-banner.tsx @@ -71,7 +71,8 @@ export function AppDownloadBanner() { gap: 10, // Safe-area padding is a no-op in a normal Safari tab (its viewport already starts below the // status bar) but keeps the banner clear of the notch in any edge-to-edge browser chrome. - padding: "calc(8px + env(safe-area-inset-top, 0px)) 12px 8px", + padding: + "calc(8px + env(safe-area-inset-top, 0px)) calc(12px + env(safe-area-inset-right, 0px)) 8px calc(12px + env(safe-area-inset-left, 0px))", background: "var(--card, #fff)", borderBottom: "1px solid var(--ink-5)", boxShadow: "0 1px 3px rgba(0,0,0,0.06)", diff --git a/apps/community-web/src/components/providers.tsx b/apps/community-web/src/components/providers.tsx index f1b184ea..a3c05e16 100644 --- a/apps/community-web/src/components/providers.tsx +++ b/apps/community-web/src/components/providers.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" import { AppError } from "@civfix/shared" import { colorSchemes } from "@civfix/shared/tokens" -import { ToastProvider, entryFromPath, setSourceCommit, useNavStore } from "@civfix/ui" +import { ToastProvider, setSourceCommit } from "@civfix/ui" import { I18nProvider, FALLBACK_LOCALE } from "@civfix/ui/i18n" import { ThemeProvider, @@ -44,6 +44,7 @@ import { AuthHydrator } from "@/components/auth/auth-hydrator" import { BootSplash } from "@/components/boot-splash" import { RealtimeChannel } from "@/components/realtime/realtime-channel" import { FirstRunGate } from "@/features/auth/first-run-gate" +import { webOpenInternalHref } from "@/components/home/use-web-nav-adapter" setAppearancePreferenceStore({ get: () => useAppearanceStore.getState().preference, @@ -63,14 +64,7 @@ const webCapabilities: PlatformCapabilities = { window.open(url, "_blank", "noopener,noreferrer") }, }, - openInternalHref: { - open: (path: string): boolean => { - const entry = entryFromPath(path) - if (!entry) return false - useNavStore.getState().push(entry) - return true - }, - }, + openInternalHref: webOpenInternalHref, blurSurface: { supported: false }, } diff --git a/apps/community-web/src/features/map/home-map.tsx b/apps/community-web/src/features/map/home-map.tsx index dac96d06..ab1c80c7 100644 --- a/apps/community-web/src/features/map/home-map.tsx +++ b/apps/community-web/src/features/map/home-map.tsx @@ -24,9 +24,11 @@ import { // The detail-panel focus camera's published target. Read (never written) here, so the one-time initial // center cannot fly away from a deep-linked detail - see the effect below. useMapFocus, + useMapFlyTo, MapPending, resolveMapCenter, shouldAdoptCenter, + holdsRememberedCamera, PRECISE_ZOOM, APPROX_ZOOM, type MapCenterSource, @@ -39,7 +41,7 @@ import { useApproximateLocation, useCleanups, useMapReports } from "@civfix/ui/d import { decideRegionFetch } from "@/features/map/region-fetch" import { readCameraSnapshot, writeCameraSnapshot } from "@/features/map/camera-snapshot" -import { resolvePreciseCenter, getBrowserPosition } from "@/lib/locate" +import { resolvePreciseCenterAfterPrompt, getBrowserPosition } from "@/lib/locate" import { useMapRecenterStore } from "@/features/map/map-recenter" /** @@ -140,20 +142,14 @@ export function HomeMap() { // once. Measured on an earlier build without the guard: /cleanups/e1 landed on the clear-strip centre // 1/4 cold loads at both 840x630 and 1440x900 (the marker at x=-5416 on the misses). Reading the store // at ADOPT time (not at mount) is what makes the guard honest: whoever published last wins. - // - // AND IT DOES NOT FLY AT ALL WHEN THE MAP BOOTED FROM THE PERSISTED CAMERA. `bootCamera` already put - // the first frame where the user last left the map (the standard maps-app boot), so flying to the - // freshly-resolved location would yank the camera off it AND re-trigger the second region fetch this - // seed exists to eliminate. The resolve still runs - for the dot, and for the Locate button, which - // remains the deliberate way to recenter. Only a first-ever visit (no snapshot) ever takes a flight, - // and even then only to UPGRADE the source (remembered -> approximate -> precise), never to repeat or - // downgrade one (`shouldAdoptCenter`). const [preciseCenter, setPreciseCenter] = React.useState(null) + const promptGrantRef = React.useRef(false) React.useEffect(() => { let cancelled = false void (async () => { - const precise = await resolvePreciseCenter() + const { precise, prompted } = await resolvePreciseCenterAfterPrompt() if (cancelled || !precise) return + promptGrantRef.current = prompted setPreciseCenter(precise) setUserLocation(precise) })() @@ -196,14 +192,14 @@ export function HomeMap() { const cameraOwnedRef = React.useRef(false) React.useEffect(() => { if (cameraOwnedRef.current) return - if (seedSourceRef.current === "remembered") return + if (holdsRememberedCamera(seedSourceRef.current, promptGrantRef.current)) return if (seedCenter === null) return const { center, source } = centerPlan if (!center || !source) return if (!shouldAdoptCenter(adoptedSourceRef.current, source)) return adoptedSourceRef.current = source cameraOwnedRef.current = true - if (useMapFocus.getState().focus) return + if (useMapFocus.getState().focus || useMapFlyTo.getState().highlight) return mapRef.current?.flyTo(center.lat, center.lng, center.zoom) }, [centerPlan, seedCenter]) @@ -251,6 +247,10 @@ export function HomeMap() { setBbox(decision.region) }, []) + const onUserCameraMove = React.useCallback(() => { + cameraOwnedRef.current = true + }, []) + // Commit the requested region as LOADED only once its query really resolved with data for it. If the // fetch fails hard (the shared useMapReports sets retry:false) we clear both refs instead, so the next // move-settle re-requests the region - otherwise regionCovers() would keep reporting the failed region @@ -381,6 +381,7 @@ export function HomeMap() { userLocation={userLocation} showUserLocation={userLocation != null} onRegionChange={onRegionChange} + onUserCameraMove={onUserCameraMove} onPressPin={onPressPin} onPressCleanup={onPressCleanup} onPressCluster={onPressCluster} diff --git a/apps/community-web/src/features/map/initial-camera.test.ts b/apps/community-web/src/features/map/initial-camera.test.ts index 9afdb5aa..79da6d7e 100644 --- a/apps/community-web/src/features/map/initial-camera.test.ts +++ b/apps/community-web/src/features/map/initial-camera.test.ts @@ -58,6 +58,7 @@ describe("the resolved centre is the only centre", () => { "seedSourceRef.current =", "approximatePointRef.current =", "cameraOwnedRef.current =", + "promptGrantRef.current =", ]) { let at = renderBody.indexOf(write) while (at !== -1) { @@ -72,10 +73,17 @@ describe("the resolved centre is the only centre", () => { describe("the camera adoption effect", () => { it("reads the published focus and gives the camera up to it", () => { - expect(adoptEffect).toContain("if (useMapFocus.getState().focus) return") + expect(adoptEffect).toContain("if (useMapFocus.getState().focus || useMapFlyTo.getState().highlight) return") expect(code).toContain("useMapFocus,") }) + it("gives the camera up to a Show on map fly-to as well", () => { + expect(code).toContain("useMapFlyTo,") + const guard = adoptEffect.indexOf("useMapFlyTo.getState().highlight") + expect(guard).toBeGreaterThan(-1) + expect(adoptEffect.indexOf("mapRef.current?.flyTo(")).toBeGreaterThan(guard) + }) + it("checks at ADOPT time, not at mount, so a later focus still wins by publishing", () => { const guard = adoptEffect.indexOf("useMapFocus.getState().focus") const fly = adoptEffect.indexOf("mapRef.current?.flyTo(") @@ -83,11 +91,31 @@ describe("the camera adoption effect", () => { expect(fly).toBeGreaterThan(guard) }) - it("never yanks a map that booted from the persisted camera", () => { - const boot = adoptEffect.indexOf('if (seedSourceRef.current === "remembered") return') + it("holds the persisted boot camera unless the user just answered the boot prompt with Allow", () => { + const hold = adoptEffect.indexOf( + "if (holdsRememberedCamera(seedSourceRef.current, promptGrantRef.current)) return", + ) const fly = adoptEffect.indexOf("mapRef.current?.flyTo(") - expect(boot).toBeGreaterThan(-1) - expect(fly).toBeGreaterThan(boot) + expect(hold).toBeGreaterThan(-1) + expect(fly).toBeGreaterThan(hold) + expect(code).toContain("holdsRememberedCamera,") + }) + + it("resolves the centre together with its prompt answer, and latches the answer before the centre lands", () => { + const mount = code.slice(code.indexOf("const { precise, prompted } = await resolvePreciseCenterAfterPrompt()")) + const latch = mount.indexOf("promptGrantRef.current = prompted") + const land = mount.indexOf("setPreciseCenter(precise)") + expect(code).toContain("const { precise, prompted } = await resolvePreciseCenterAfterPrompt()") + expect(latch).toBeGreaterThan(0) + expect(land).toBeGreaterThan(latch) + }) + + it("a user gesture on the map claims the camera, so a late adoption never overrides it", () => { + expect(code).toMatch( + /const onUserCameraMove = React\.useCallback\(\(\) => \{\n\s+cameraOwnedRef\.current = true\n\s+\}, \[\]\)/, + ) + const map = code.slice(code.indexOf(" { diff --git a/apps/community-web/src/lib/link-preview.test.ts b/apps/community-web/src/lib/link-preview.test.ts index de03f94c..56010226 100644 --- a/apps/community-web/src/lib/link-preview.test.ts +++ b/apps/community-web/src/lib/link-preview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import type { CleanupDTO, OrganizationDTO, ReportDTO, UserProfileDTO } from "@civfix/shared" +import type { CleanupDTO, OrganizationDTO, PostDTO, ReportDTO, UserProfileDTO } from "@civfix/shared" import { clamp, @@ -15,14 +15,18 @@ import { previewForEvent, previewForOrganization, previewForPerson, + previewForPost, previewForReport, + previewForSignupPage, withNoindex, type EventPreviewInput, type OrganizationPreviewInput, type PersonPreviewInput, + type PostPreviewInput, type PreviewContext, type ReportPreviewInput, } from "./link-preview" +import { DEFAULT_DESCRIPTION } from "./site-meta" const ctx: PreviewContext = { url: "https://civfix.org/pin/abc", @@ -31,13 +35,24 @@ const ctx: PreviewContext = { const BRAND_IMAGE = "https://civfix.org/og.png" +const SIZED_IMAGE = { + kind: "image", + status: "ready", + url: "https://cdn.x/a.jpg", + width: 1600, + height: 1200, +} + +const SIZED_THUMBED = { ...SIZED_IMAGE, thumbUrl: "https://cdn.x/a_t.jpg" } + describe("contract shapes", () => { it("accepts the published DTOs without a cast", () => { const report: ReportPreviewInput = {} as ReportDTO const event: EventPreviewInput = {} as CleanupDTO const person: PersonPreviewInput = {} as UserProfileDTO const org: OrganizationPreviewInput = {} as OrganizationDTO - expect([report, event, person, org]).toHaveLength(4) + const post: PostPreviewInput = {} as PostDTO + expect([report, event, person, org, post]).toHaveLength(5) }) }) @@ -57,6 +72,19 @@ describe("escaping and clamping", () => { expect(clamp("short", 12)).toBe("short") }) + it("clamps on code points, so an emoji or a CJK supplementary character is never split", () => { + const emoji = String.fromCodePoint(0x1f600) + const clamped = clamp(`${"a".repeat(199)}${emoji}${emoji}`, 200) + expect(clamped).toBe(`${"a".repeat(199)}${emoji}…`) + expect(clamped).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])|(? { const preview = previewForEvent( { title: ``, scheduledAt: "2026-09-12T17:00:00.000Z" }, @@ -103,61 +131,78 @@ describe("previewForReport", () => { cityName: "Los Angeles, CA", } - it("builds title and description from controlled vocabulary only", () => { + it("builds an X-style title from the type label and city, and a status-led description", () => { const preview = previewForReport(base, ctx) - expect(preview?.title).toBe("Graffiti · Los Angeles, CA") + expect(preview?.title).toBe("Graffiti in Los Angeles, CA on civfix") expect(preview?.description).toBe("Graffiti — In progress · Los Angeles, CA") expect(preview?.type).toBe("article") + expect(preview?.card).toBe("summary_large_image") }) - it("never echoes the resident's own title or body text", () => { - const html = metaTagsHtml( - previewForReport( - { - ...base, - title: "Underpass tagging by the skate ramp", - description: "My neighbour Dana keeps spraying the wall behind 12 Elm.", - } as ReportPreviewInput, - ctx, - )!, + it("carries the resident's title and description, status first", () => { + const preview = previewForReport( + { + ...base, + title: "Underpass tagging by the skate ramp", + description: "My neighbour Dana keeps spraying the wall behind 12 Elm.", + }, + ctx, + )! + expect(preview.description).toBe( + "In progress · Underpass tagging by the skate ramp · My neighbour Dana keeps spraying the wall behind 12 Elm.", ) - expect(html).not.toContain("Underpass") - expect(html).not.toContain("skate ramp") - expect(html).not.toContain("Dana") - expect(html).not.toContain("neighbour") - expect(html).toContain("Graffiti — In progress · Los Angeles, CA") + expect(metaTagsHtml(preview)).toContain("Underpass") }) it("falls back to the category label when the type is unknown", () => { expect(previewForReport({ ...base, type: null }, ctx)?.title).toBe( - "Graffiti · Los Angeles, CA", + "Graffiti in Los Angeles, CA on civfix", ) }) + it("drops the city from the title when the server sent none", () => { + expect(previewForReport({ ...base, cityName: null }, ctx)?.title).toBe("Graffiti on civfix") + }) + it("refuses to preview a non-public report", () => { expect(previewForReport({ ...base, visibility: "hidden" }, ctx)).toBeNull() }) - it("uses the brand image unless a ready public image exists", () => { + it("uses the FIRST ready slide of the carousel: a thumbnail, a full image only when it has no thumbnail, and the brand image when that slide is not shareable", () => { + const withMedia = (media: ReportPreviewInput["media"]) => previewForReport({ ...base, media }, ctx)! expect(previewForReport(base, ctx)?.image).toBe(BRAND_IMAGE) + expect( - previewForReport( - { ...base, media: [{ kind: "image", status: "validating", url: "https://cdn.x/a.jpg" }] }, - ctx, - )?.image, - ).toBe(BRAND_IMAGE) + withMedia([ + { kind: "image", status: "validating", url: "https://cdn.x/a.jpg" }, + { kind: "image", status: "ready", url: "https://cdn.x/b.jpg" }, + ]).image, + ).toBe("https://cdn.x/b.jpg") + + const thumbed = withMedia([ + SIZED_THUMBED, + ]) + expect(thumbed.image).toBe("https://cdn.x/a_t.jpg") + expect(thumbed.imageIsBrand).toBe(false) + + const full = withMedia([ + SIZED_IMAGE, + ]) + expect(full.image).toBe("https://cdn.x/a.jpg") + expect( - previewForReport( - { ...base, media: [{ kind: "image", status: "ready", url: "https://cdn.x/a.jpg?sig=1" }] }, - ctx, - )?.image, - ).toBe(BRAND_IMAGE) - const withImage = previewForReport( - { ...base, media: [{ kind: "image", status: "ready", url: "https://cdn.x/a.jpg" }] }, - ctx, + withMedia([{ kind: "video", status: "ready", url: "https://cdn.x/v.mp4", thumbUrl: "https://cdn.x/v_t.jpg" }]).image, + ).toBe("https://cdn.x/v_t.jpg") + expect(withMedia([{ kind: "video", status: "ready", url: "https://cdn.x/v.mp4" }]).image).toBe( + BRAND_IMAGE, ) - expect(withImage?.image).toBe("https://cdn.x/a.jpg") - expect(withImage?.imageIsBrand).toBe(false) + + const presignedFirst = withMedia([ + { kind: "image", status: "ready", url: "https://cdn.x/a.jpg?sig=1" }, + { kind: "image", status: "ready", url: "https://cdn.x/b.jpg" }, + ]) + expect(presignedFirst.image).toBe(BRAND_IMAGE) + expect(presignedFirst.imageIsBrand).toBe(true) }) it("never leaks the street address or coordinates", () => { @@ -209,10 +254,16 @@ describe("previewForEvent", () => { expect(preview?.description).toBe("Sat, Sep 12, 1:00 PM EDT · A volunteer event on civfix") }) - it("shares the host's uploaded cover, and the brand card when there is none", () => { - const withCover = previewForEvent({ ...base, coverUrl: "https://cdn.civfix.org/c/1.jpg" }, ctx) - expect(withCover?.image).toBe("https://cdn.civfix.org/c/1.jpg") + it("uses the cover, else the first gallery image, else the brand card", () => { + const cover = "https://cdn.civfix.org/c/1.jpg" + const gallery = ["https://cdn.civfix.org/g/1.jpg", "https://cdn.civfix.org/g/2.jpg"] + const withCover = previewForEvent({ ...base, coverUrl: cover, galleryUrls: gallery }, ctx) + expect(withCover?.image).toBe(cover) expect(withCover?.imageIsBrand).toBe(false) + expect(previewForEvent({ ...base, galleryUrls: gallery }, ctx)?.image).toBe(gallery[0]) + expect( + previewForEvent({ ...base, galleryUrls: ["https://cdn.civfix.org/g/1.jpg?sig=1"] }, ctx)?.image, + ).toBe(BRAND_IMAGE) expect(previewForEvent(base, ctx)?.imageIsBrand).toBe(true) }) @@ -221,16 +272,33 @@ describe("previewForEvent", () => { expect(previewForEvent({ ...base, coverUrl: "https://cdn.civfix.org/c/1.jpg?token=x" }, ctx)?.imageIsBrand).toBe(true) }) - it("withholds the cover of an event that is not public, exactly as the signup page does", () => { + it("keeps an unlisted event shareable by link with a title + schedule card, no cover, no host text and noindex, and refuses a private one", () => { const cover = "https://cdn.civfix.org/c/1.jpg" - for (const visibility of ["unlisted", "private"]) { - const preview = previewForEvent({ ...base, visibility, coverUrl: cover }, ctx) - expect(preview?.imageIsBrand).toBe(true) - expect(metaTagsHtml(preview!)).not.toContain(cover) + const unlisted = previewForEvent( + { + ...base, + visibility: "unlisted", + coverUrl: cover, + galleryUrls: [cover], + description: "Meet by the blue gate behind the school.", + organizer: { name: "Ada Rivera", handle: "ada" }, + organization: { name: "River Keepers LA" }, + }, + ctx, + )! + expect(unlisted.title).toBe("Ballona Creek cleanup on civfix") + expect(unlisted.description).toContain("Sat, Sep 12, 10:00 AM PDT") + expect(unlisted.description).toBe("Sat, Sep 12, 10:00 AM PDT · A volunteer event on civfix") + expect(unlisted.imageIsBrand).toBe(true) + expect(unlisted.noindex).toBe(true) + const unlistedHtml = metaTagsHtml(unlisted) + for (const leaked of [cover, "blue gate", "Ada Rivera", "@ada", "River Keepers"]) { + expect(unlistedHtml).not.toContain(leaked) } - expect(previewForEvent({ ...base, visibility: "public", coverUrl: cover }, ctx)?.image).toBe( - cover, - ) + expect(previewForEvent({ ...base, visibility: "private", coverUrl: cover }, ctx)).toBeNull() + const open = previewForEvent({ ...base, visibility: "public", coverUrl: cover }, ctx) + expect(open?.image).toBe(cover) + expect(open?.noindex).toBe(false) }) it("treats a missing visibility as public, so an older server keeps its cover", () => { @@ -239,28 +307,40 @@ describe("previewForEvent", () => { expect(previewForEvent({ ...base, visibility: null, coverUrl: cover }, ctx)?.image).toBe(cover) }) - it("uses the event title and a date-led fixed description", () => { + it("uses the event title and a date-led description", () => { const preview = previewForEvent(base, ctx) - expect(preview?.title).toBe("Ballona Creek cleanup") + expect(preview?.title).toBe("Ballona Creek cleanup on civfix") expect(preview?.description).toBe("Sat, Sep 12, 10:00 AM PDT · A volunteer event on civfix") expect(preview?.imageIsBrand).toBe(true) }) - it("never echoes the host's description and caps the title at 80 chars", () => { - const html = metaTagsHtml( - previewForEvent( - { - ...base, - description: "Bring gloves. Ask for Dana at 12 Elm St, apt 5.", - } as EventPreviewInput, - ctx, - )!, + it("carries the host's description after the schedule and caps the headline at 80 chars", () => { + const preview = previewForEvent( + { ...base, description: "Bring gloves. Ask for Dana at 12 Elm St, apt 5." }, + ctx, + ) + expect(preview?.description).toBe( + "Sat, Sep 12, 10:00 AM PDT · Bring gloves. Ask for Dana at 12 Elm St, apt 5.", ) - expect(html).not.toContain("gloves") - expect(html).not.toContain("Dana") - expect(html).not.toContain("Elm St") const long = previewForEvent({ ...base, title: "Cleanup ".repeat(20) }, ctx)! - expect(long.title.length).toBeLessThanOrEqual(81) + expect(long.title.length).toBeLessThanOrEqual(91) + expect(long.title.endsWith("… on civfix")).toBe(true) + }) + + it("names the host: the organization when there is one, else the organizer", () => { + expect( + previewForEvent( + { ...base, organization: { name: "Reach Out LA" }, organizer: { name: "Ada", handle: "ada" } }, + ctx, + )?.description, + ).toBe("Sat, Sep 12, 10:00 AM PDT · Reach Out LA · A volunteer event on civfix") + expect( + previewForEvent({ ...base, organizer: { name: "Ada", handle: "ada" } }, ctx)?.description, + ).toBe("Sat, Sep 12, 10:00 AM PDT · Ada (@ada) · A volunteer event on civfix") + expect( + previewForEvent({ ...base, organizer: { name: "Ada", handle: "ada", deleted: true } }, ctx) + ?.description, + ).toBe("Sat, Sep 12, 10:00 AM PDT · A volunteer event on civfix") }) it("marks a cancelled event and never exposes the meeting address", () => { @@ -284,31 +364,37 @@ describe("previewForPerson", () => { avatarUrl: "https://cdn.civfix.org/a/1.jpg", } - it("builds the display-name and handle title", () => { + it("builds the display-name and handle title on civfix, as a summary card", () => { const preview = previewForPerson(base, ctx) - expect(preview?.title).toBe("Ada Rivera (@ada)") - expect(preview?.description).toBe("Ada Rivera (@ada) · On civfix") + expect(preview?.title).toBe("Ada Rivera (@ada) on civfix") + expect(preview?.description).toBe(DEFAULT_DESCRIPTION) expect(preview?.image).toBe("https://cdn.civfix.org/a/1.jpg") + expect(preview?.card).toBe("summary") expect(preview?.type).toBe("profile") }) - it("never echoes the bio", () => { + it("uses the bio as the description", () => { + const preview = previewForPerson( + { ...base, bio: "Organizer in Mar Vista. Saturdays at the creek." }, + ctx, + )! + expect(preview.description).toBe("Organizer in Mar Vista. Saturdays at the creek.") + expect(metaTagsHtml(preview)).toContain("Mar Vista") + }) + + it("never reads the account email field", () => { const html = metaTagsHtml( - previewForPerson( - { ...base, bio: "Organizer in Mar Vista, reach me at ada@example.com" } as PersonPreviewInput, - ctx, - )!, + previewForPerson({ ...base, email: "ada@example.com" } as PersonPreviewInput, ctx)!, ) - expect(html).not.toContain("Mar Vista") expect(html).not.toContain("ada@example.com") - expect(html).not.toContain("Organizer") }) - it("falls back when there is no handle or public avatar", () => { + it("falls back to a large brand card when there is no handle or public avatar", () => { const preview = previewForPerson({ name: "Ada Rivera", handle: null }, ctx) - expect(preview?.title).toBe("Ada Rivera") - expect(preview?.description).toBe("Ada Rivera · On civfix") + expect(preview?.title).toBe("Ada Rivera on civfix") + expect(preview?.description).toBe(DEFAULT_DESCRIPTION) expect(preview?.image).toBe(BRAND_IMAGE) + expect(preview?.card).toBe("summary_large_image") }) it("refuses to preview a deleted account", () => { @@ -317,6 +403,178 @@ describe("previewForPerson", () => { }) }) +describe("previewForPost", () => { + const base: PostPreviewInput = { + kind: "post", + body: "Cleared the storm drain on Venice Blvd this morning.", + author: { name: "Ada Rivera", handle: "ada" }, + organization: null, + media: [], + } + + it("titles the card with the author's name and handle, on civfix, and uses the body as the description", () => { + const preview = previewForPost(base, ctx) + expect(preview?.title).toBe("Ada Rivera (@ada) on civfix") + expect(preview?.description).toBe("Cleared the storm drain on Venice Blvd this morning.") + expect(preview?.type).toBe("article") + expect(preview?.card).toBe("summary_large_image") + expect(preview?.imageIsBrand).toBe(true) + }) + + it("bylines a post published as an organization with the org, not the author", () => { + const preview = previewForPost( + { ...base, organization: { name: "River Keepers LA", slug: "river-keepers" } }, + ctx, + ) + expect(preview?.title).toBe("River Keepers LA (@river-keepers) on civfix") + }) + + it("uses the first ready slide of the post's media and never carries its stored dimensions", () => { + const thumbed = previewForPost( + { + ...base, + media: [ + SIZED_THUMBED, + ], + }, + ctx, + )! + expect(thumbed.image).toBe("https://cdn.x/a_t.jpg") + + const full = previewForPost( + { ...base, media: [SIZED_IMAGE] }, + ctx, + )! + expect(full.image).toBe("https://cdn.x/a.jpg") + expect(metaTagsHtml(full)).not.toContain("1600") + }) + + it("falls back to the attached report's thumbnail when the post carries no media", () => { + expect( + previewForPost({ ...base, report: { title: "Graffiti", thumbUrl: "https://cdn.x/r.jpg" } }, ctx) + ?.image, + ).toBe("https://cdn.x/r.jpg") + expect( + previewForPost({ ...base, report: { title: "Graffiti", thumbUrl: "https://cdn.x/r.jpg?sig=1" } }, ctx) + ?.image, + ).toBe(BRAND_IMAGE) + }) + + it("renders a plain repost as the ORIGINAL post's card", () => { + const original = { + kind: "post", + body: "Fresh mural on the underpass.", + author: { name: "Bo Kim", handle: "bo" }, + media: [{ kind: "image", status: "ready", thumbUrl: "https://cdn.x/o_t.jpg", url: "https://cdn.x/o.jpg" }], + } + for (const input of [ + { ...base, kind: "repost", body: null, repostOf: original }, + { ...base, kind: "post", body: " ", media: [], repostOf: original }, + ]) { + const preview = previewForPost(input, ctx) + expect(preview?.title).toBe("Bo Kim (@bo) on civfix") + expect(preview?.description).toBe("Fresh mural on the underpass.") + expect(preview?.image).toBe("https://cdn.x/o_t.jpg") + } + }) + + it("keeps the quoter's text and media for a quote, borrowing the quoted post's image only when it has none", () => { + const quoted = { + kind: "post", + body: "Original text.", + author: { name: "Bo Kim", handle: "bo" }, + media: [{ kind: "image", status: "ready", thumbUrl: "https://cdn.x/q_t.jpg", url: "https://cdn.x/q.jpg" }], + } + const borrowing = previewForPost({ ...base, kind: "quote", body: "This!", repostOf: quoted }, ctx) + expect(borrowing?.title).toBe("Ada Rivera (@ada) on civfix") + expect(borrowing?.description).toBe("This!") + expect(borrowing?.image).toBe("https://cdn.x/q_t.jpg") + + const own = previewForPost( + { + ...base, + kind: "quote", + body: "This!", + media: [{ kind: "image", status: "ready", thumbUrl: "https://cdn.x/mine_t.jpg" }], + repostOf: quoted, + }, + ctx, + ) + expect(own?.image).toBe("https://cdn.x/mine_t.jpg") + }) + + it("describes a bodiless report or event share by the attachment's title", () => { + expect( + previewForPost({ ...base, body: null, report: { title: "Overflowing bin on 5th" } }, ctx) + ?.description, + ).toBe("Overflowing bin on 5th") + expect( + previewForPost({ ...base, body: null, event: { title: "Ballona Creek cleanup" } }, ctx) + ?.description, + ).toBe("Ballona Creek cleanup") + expect(previewForPost({ ...base, body: null }, ctx)?.description).toBe(DEFAULT_DESCRIPTION) + }) + + it("refuses a deleted author, a deleted original and a repost of nothing", () => { + expect(previewForPost({ ...base, author: { name: "Ada", handle: "ada", deleted: true } }, ctx)).toBeNull() + expect( + previewForPost( + { ...base, kind: "repost", body: null, repostOf: { deleted: true, author: { name: "Bo" } } }, + ctx, + ), + ).toBeNull() + expect(previewForPost({ ...base, kind: "repost", body: null, repostOf: null }, ctx)).toBeNull() + }) + + it("escapes html in the body and never reads viewer, counts or mentions", () => { + const html = metaTagsHtml( + previewForPost( + { + ...base, + body: ``, + viewer: { liked: true, reposted: false, saved: true }, + counts: { likes: 4242, reposts: 0, replies: 0, saves: 0 }, + mentions: [{ id: "u2", handle: "secretfriend", displayName: "Secret Friend" }], + } as PostPreviewInput, + ctx, + )!, + ) + expect(html).not.toContain(" { + const html = metaTagsHtml( + previewForPost( + { + ...base, + body: null, + report: { + title: "Overflowing bin on 5th", + addr: "1234 Elm Street, Apt 5", + lat: 34.0522, + lng: -118.2437, + }, + event: { + title: "Ballona Creek cleanup", + lat: 33.9911, + lng: -118.4265, + organizer: { name: "Dana", handle: "dana", bio: "Call me at 555-0199" }, + }, + } as PostPreviewInput, + ctx, + )!, + ) + expect(html).toContain("Overflowing bin on 5th") + for (const leaked of ["Elm Street", "34.05", "118.24", "33.99", "118.42", "555-0199"]) { + expect(html).not.toContain(leaked) + } + }) +}) + const personWithAvatar: PersonPreviewInput = { name: "Ada", handle: "ada", @@ -327,24 +585,24 @@ describe("metaTagsHtml", () => { it("emits the full open-graph and twitter set with the canonical url", () => { const html = metaTagsHtml(previewForPerson({ name: "Ada", handle: "ada" }, ctx)!) for (const tag of [ - '', + ``, '', '', '', '', - '', - '', + '', + ``, '', '', '', '', '', - '', + '', '', - '', - '', + '', + ``, '', - '', + '', '', '', '', @@ -371,8 +629,9 @@ describe("metaTagsHtml", () => { expect(html).not.toContain("civfix.org") }) - it("omits the brand image dimensions and type when a content photo is used", () => { + it("emits a summary card for an avatar and omits the brand dimensions and type", () => { const html = metaTagsHtml(previewForPerson(personWithAvatar, ctx)!) + expect(html).toContain('') expect(html).not.toContain("og:image:width") expect(html).not.toContain("og:image:height") expect(html).not.toContain("og:image:type") @@ -382,6 +641,34 @@ describe("metaTagsHtml", () => { }) }) +describe("metaTagsHtml image dimensions", () => { + it("emits width and height only for the brand image, never for entity media, whose stored size may predate its rotation", () => { + const post: PostPreviewInput = { body: "Hi", author: { name: "Ada", handle: "ada" } } + const full = metaTagsHtml( + previewForPost( + { ...post, media: [SIZED_IMAGE] }, + ctx, + )!, + ) + expect(full).toContain('') + expect(full).not.toContain("og:image:width") + expect(full).not.toContain("og:image:height") + expect(full).not.toContain("og:image:type") + + const thumb = metaTagsHtml( + previewForPost( + { + ...post, + media: [SIZED_THUMBED], + }, + ctx, + )!, + ) + expect(thumb).not.toContain("og:image:width") + expect(thumb).not.toContain("og:image:height") + }) +}) + describe("defaultPreview", () => { it("is the branded card, rendered by the same tag list as an entity", () => { const html = metaTagsHtml(defaultPreview(ctx)) @@ -441,13 +728,19 @@ describe("robots is a managed meta tag", () => { describe("documentTitle", () => { it("suffixes the og:title with the site name", () => { - expect(documentTitle(previewForPerson({ name: "Ada", handle: "ada" }, ctx)!)).toBe( - "Ada (@ada) · civfix", + expect( + documentTitle(previewForSignupPage({ event: { title: "Beach cleanup" } }, ctx)!), + ).toBe("Beach cleanup · civfix") + }) + + it("leaves a title that already ends with \" on civfix\" alone", () => { + expect(documentTitle(previewForPost({ body: "Hi", author: { name: "Ada", handle: "ada" } }, ctx)!)).toBe( + "Ada (@ada) on civfix", ) }) it("never doubles a title that already ends with the site name", () => { - expect(documentTitle(previewForEvent({ title: "Cleanup · civfix" }, ctx)!)).toBe( + expect(documentTitle(previewForSignupPage({ event: { title: "Cleanup · civfix" } }, ctx)!)).toBe( "Cleanup · civfix", ) }) @@ -477,7 +770,8 @@ describe("previewForOrganization", () => { it("builds the name-and-handle title and a verified, counted description", () => { const preview = previewForOrganization(base, ctx) - expect(preview?.title).toBe("River Keepers LA (@river-keepers)") + expect(preview?.title).toBe("River Keepers LA (@river-keepers) on civfix") + expect(preview?.card).toBe("summary") expect(preview?.description).toBe( "Verified nonprofit · 12 events · We keep the LA River clean, one Saturday at a time.", ) diff --git a/apps/community-web/src/lib/link-preview.ts b/apps/community-web/src/lib/link-preview.ts index 13ed044e..ac89c346 100644 --- a/apps/community-web/src/lib/link-preview.ts +++ b/apps/community-web/src/lib/link-preview.ts @@ -29,13 +29,14 @@ const TITLE_MAX = 90 const EVENT_TITLE_MAX = 80 const DESCRIPTION_MAX = 200 -export type PreviewKind = "report" | "event" | "person" | "signup" | "org" +export type PreviewKind = "report" | "event" | "person" | "signup" | "org" | "post" export interface LinkPreview { title: string description: string image: string imageIsBrand: boolean + card: "summary" | "summary_large_image" url: string origin: string type: "website" | "article" | "profile" @@ -57,6 +58,7 @@ export function defaultPreview(context: PreviewContext): LinkPreview { description: DEFAULT_DESCRIPTION, image: brandImageUrl(context.origin), imageIsBrand: true, + card: "summary_large_image", url: context.url, origin: context.origin, type: "website", @@ -82,11 +84,11 @@ export function oneLine(value: string): string { } export function clamp(value: string, max: number): string { - const text = oneLine(value) - if (text.length <= max) return text - const cut = text.slice(0, max) + const chars = Array.from(oneLine(value)) + if (chars.length <= max) return chars.join("") + const cut = chars.slice(0, max) const lastSpace = cut.lastIndexOf(" ") - return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…` + return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).join("").trimEnd()}…` } export function isPublicMediaUrl(url: string | null | undefined): url is string { @@ -109,18 +111,54 @@ function finishDescription(parts: readonly (string | null | undefined)[]): strin return clamp(joinParts(parts), DESCRIPTION_MAX) } +const ON_SITE_SUFFIX = ` on ${SITE_NAME}` + +function onSite(headline: string): string { + return `${headline}${ON_SITE_SUFFIX}` +} + +function withHandle(name: string, handle: string | null | undefined): string { + const bare = handle ? oneLine(handle).replace(/^@/, "") : "" + return bare ? `${name} (@${bare})` : name +} + +interface BylineInput { + name?: string | null + handle?: string | null + deleted?: boolean | null +} + +function personByline(person: BylineInput | null | undefined): string | null { + if (!person || person.deleted || !person.name) return null + const name = oneLine(person.name) + return name ? withHandle(name, person.handle) : null +} + +export interface MediaSlideInput { + kind?: string | null + status?: string | null + url?: string | null + thumbUrl?: string | null +} + +export function firstCarouselImage( + media: readonly MediaSlideInput[] | null | undefined, +): string | null { + const slide = (media ?? []).find((item) => item.status === "ready") + if (!slide) return null + if (isPublicMediaUrl(slide.thumbUrl)) return slide.thumbUrl + return slide.kind === "image" && isPublicMediaUrl(slide.url) ? slide.url : null +} + export interface ReportPreviewInput { category?: string | null type?: string | null status?: string | null visibility?: string | null cityName?: string | null - media?: readonly { - kind?: string | null - status?: string | null - url?: string | null - thumbUrl?: string | null - }[] | null + title?: string | null + description?: string | null + media?: readonly MediaSlideInput[] | null } export interface EventPreviewInput { @@ -129,12 +167,17 @@ export interface EventPreviewInput { timezone?: string | null status?: string | null visibility?: string | null + description?: string | null coverUrl?: string | null + galleryUrls?: readonly string[] | null + organizer?: BylineInput | null + organization?: { name?: string | null } | null } export interface PersonPreviewInput { name?: string | null handle?: string | null + bio?: string | null avatarUrl?: string | null deleted?: boolean | null } @@ -143,15 +186,6 @@ function isPublicVisibility(visibility: string | null | undefined): boolean { return visibility === undefined || visibility === null || visibility === "public" } -function reportImage(input: ReportPreviewInput): string | null { - for (const item of input.media ?? []) { - if (item.kind !== "image" || item.status !== "ready") continue - if (isPublicMediaUrl(item.thumbUrl)) return item.thumbUrl - if (isPublicMediaUrl(item.url)) return item.url - } - return null -} - export function previewForReport( input: ReportPreviewInput, context: PreviewContext, @@ -164,21 +198,24 @@ export function previewForReport( : undefined const kindLabel = typeLabel ?? categoryLabel ?? "Report" const cityName = input.cityName ? oneLine(input.cityName) : "" - const title = clamp(joinParts([kindLabel, cityName]), TITLE_MAX) + const headline = cityName ? `${kindLabel} in ${cityName}` : kindLabel const statusLabel = input.status ? REPORT_STATUS_LABELS[input.status as ReportStatus] : undefined const description = - finishDescription([statusLabel ? `${kindLabel} — ${statusLabel}` : kindLabel, cityName]) || + (joinParts([input.title, input.description]) + ? finishDescription([statusLabel, input.title, input.description]) + : finishDescription([statusLabel ? `${kindLabel} — ${statusLabel}` : kindLabel, cityName])) || DEFAULT_DESCRIPTION - const image = reportImage(input) + const image = firstCarouselImage(input.media) return { - title: title || DEFAULT_TITLE, + title: onSite(clamp(headline, TITLE_MAX)), description, image: image ?? brandImageUrl(context.origin), imageIsBrand: image === null, + card: "summary_large_image", url: context.url, origin: context.origin, type: "article", @@ -209,47 +246,59 @@ export function previewForEvent( input: EventPreviewInput, context: PreviewContext, ): LinkPreview | null { + if (input.visibility === "private") return null const title = input.title ? clamp(input.title, EVENT_TITLE_MAX) : "" if (!title) return null const when = formatEventWhen(input.scheduledAt, input.timezone) const cancelled = input.status === "cancelled" ? "Cancelled" : null - const description = - finishDescription([cancelled, when, `A volunteer event on ${SITE_NAME}`]) || DEFAULT_DESCRIPTION - + const tagline = `A volunteer event on ${SITE_NAME}` const isPublic = isPublicVisibility(input.visibility) - const cover = isPublic && isPublicMediaUrl(input.coverUrl) ? input.coverUrl : null + const description = + (isPublic + ? finishDescription([cancelled, when, eventHost(input), input.description || tagline]) + : finishDescription([cancelled, when, tagline])) || DEFAULT_DESCRIPTION + const cover = isPublic ? eventCover(input) : null return { - title, + title: onSite(title), description, image: cover ?? brandImageUrl(context.origin), imageIsBrand: cover === null, + card: "summary_large_image", url: context.url, origin: context.origin, type: "article", - noindex: false, + noindex: !isPublic, } } +function eventHost(input: EventPreviewInput): string | null { + return input.organization?.name ? oneLine(input.organization.name) : personByline(input.organizer) +} + +function eventCover(input: EventPreviewInput): string | null { + if (isPublicMediaUrl(input.coverUrl)) return input.coverUrl + const first = input.galleryUrls?.[0] + return isPublicMediaUrl(first) ? first : null +} + export function previewForPerson( input: PersonPreviewInput, context: PreviewContext, ): LinkPreview | null { - if (input.deleted) return null - const name = input.name ? oneLine(input.name) : "" - if (!name) return null + const byline = personByline(input) + if (!byline) return null - const handle = input.handle ? oneLine(input.handle).replace(/^@/, "") : "" - const title = clamp(handle ? `${name} (@${handle})` : name, TITLE_MAX) - const description = finishDescription([title, `On ${SITE_NAME}`]) + const description = (input.bio ? clamp(input.bio, DESCRIPTION_MAX) : "") || DEFAULT_DESCRIPTION const image = isPublicMediaUrl(input.avatarUrl) ? input.avatarUrl : null return { - title, + title: onSite(clamp(byline, TITLE_MAX)), description, image: image ?? brandImageUrl(context.origin), imageIsBrand: image === null, + card: image ? "summary" : "summary_large_image", url: context.url, origin: context.origin, type: "profile", @@ -298,6 +347,7 @@ export function previewForSignupPage( description, image: cover ?? brandImageUrl(context.origin), imageIsBrand: cover === null, + card: "summary_large_image", url: context.url, origin: context.origin, type: "article", @@ -328,8 +378,7 @@ export function previewForOrganization( const name = input.name ? oneLine(input.name) : "" if (!name) return null - const handle = input.slug ? oneLine(input.slug) : "" - const title = clamp(handle ? `${name} (@${handle})` : name, TITLE_MAX) + const title = onSite(clamp(withHandle(name, input.slug), TITLE_MAX)) const verified = input.verifiedStatus === "verified" ? (ORG_KIND_LABEL[input.verifiedKind ?? ""] ?? "Verified organization") @@ -350,6 +399,7 @@ export function previewForOrganization( description: description || DEFAULT_DESCRIPTION, image: image ?? brandImageUrl(context.origin), imageIsBrand: image === null, + card: image ? "summary" : "summary_large_image", url: context.url, origin: context.origin, type: "profile", @@ -357,6 +407,68 @@ export function previewForOrganization( } } +interface PostSubjectInput { + kind?: string | null + body?: string | null + deleted?: boolean | null + author?: BylineInput | null + organization?: { name?: string | null; slug?: string | null } | null + media?: readonly MediaSlideInput[] | null + report?: { title?: string | null; thumbUrl?: string | null } | null + event?: { title?: string | null } | null +} + +export interface PostPreviewInput extends PostSubjectInput { + repostOf?: PostSubjectInput | null +} + +function bylineOf(subject: PostSubjectInput): string | null { + const org = subject.organization + if (org?.name) return withHandle(oneLine(org.name), org.slug) + return personByline(subject.author) +} + +function attachmentThumb(subject: PostSubjectInput): string | null { + const thumb = subject.report?.thumbUrl + return isPublicMediaUrl(thumb) ? thumb : null +} + +export function previewForPost( + input: PostPreviewInput, + context: PreviewContext, +): LinkPreview | null { + if (input.deleted || input.author?.deleted) return null + const isRepost = + input.kind === "repost" || + (!oneLine(input.body ?? "") && !!input.repostOf && (input.media ?? []).length === 0) + const subject = isRepost ? input.repostOf : input + if (!subject || subject.deleted) return null + const byline = bylineOf(subject) + if (!byline) return null + + const body = clamp(subject.body ?? "", DESCRIPTION_MAX) + const attached = subject.report?.title ?? subject.event?.title ?? "" + const description = body || (attached ? clamp(attached, DESCRIPTION_MAX) : "") || DEFAULT_DESCRIPTION + + const quoted = !isRepost && input.repostOf && !input.repostOf.deleted ? input.repostOf : null + const image = + firstCarouselImage(subject.media) ?? + attachmentThumb(subject) ?? + (quoted ? (firstCarouselImage(quoted.media) ?? attachmentThumb(quoted)) : null) + + return { + title: onSite(clamp(byline, TITLE_MAX)), + description, + image: image ?? brandImageUrl(context.origin), + imageIsBrand: image === null, + card: "summary_large_image", + url: context.url, + origin: context.origin, + type: "article", + noindex: false, + } +} + const MANAGED_META_NAMES: readonly string[] = [ "description", "robots", @@ -414,7 +526,13 @@ export function isManagedLink(rel: string | null | undefined): boolean { export function documentTitle(preview: LinkPreview): string { const suffix = ` · ${SITE_NAME}` - if (preview.title === SITE_NAME || preview.title.endsWith(suffix)) return preview.title + if ( + preview.title === SITE_NAME || + preview.title.endsWith(suffix) || + preview.title.endsWith(ON_SITE_SUFFIX) + ) { + return preview.title + } return `${preview.title}${suffix}` } @@ -456,7 +574,7 @@ export function metaTagsHtml(preview: LinkPreview): string { meta("property", "og:title", preview.title), meta("property", "og:description", preview.description), ...imageTags(preview), - meta("name", "twitter:card", "summary_large_image"), + meta("name", "twitter:card", preview.card), meta("name", "twitter:title", preview.title), meta("name", "twitter:description", preview.description), meta("name", "twitter:image", preview.image), diff --git a/apps/community-web/src/lib/locate.test.ts b/apps/community-web/src/lib/locate.test.ts index f44b734b..c90b9019 100644 --- a/apps/community-web/src/lib/locate.test.ts +++ b/apps/community-web/src/lib/locate.test.ts @@ -126,3 +126,77 @@ describe("getSharedBrowserFix dedupes every one-shot consumer onto one browser r await expect(getBrowserPosition()).resolves.toBeNull() }) }) + +describe("resolvePreciseCenterAfterPrompt counts a grant as prompted only on a prompt -> granted transition", () => { + function stubPermissions(query: ReturnType) { + vi.stubGlobal("navigator", { geolocation: geo.geolocation, permissions: { query } }) + } + + function sequence(...states: string[]) { + const query = vi.fn() + for (const state of states) query.mockResolvedValueOnce({ state }) + return query + } + + async function settle(resolveFix: boolean) { + const { resolvePreciseCenterAfterPrompt } = await freshModules() + const pending = resolvePreciseCenterAfterPrompt() + await vi.waitFor(() => expect(geo.calls).toHaveLength(1)) + if (resolveFix) geo.calls[0]!.resolve(34.05, -118.24) + else geo.calls[0]!.reject() + return pending + } + + it("is prompted when the state was prompt before the request and granted after the fix", async () => { + const query = sequence("prompt", "granted") + stubPermissions(query) + await expect(settle(true)).resolves.toEqual({ precise: { lat: 34.05, lng: -118.24 }, prompted: true }) + expect(query).toHaveBeenCalledTimes(2) + expect(query).toHaveBeenCalledWith({ name: "geolocation" }) + }) + + it("reads the before state ahead of the browser request", async () => { + const query = sequence("prompt", "granted") + stubPermissions(query) + const { resolvePreciseCenterAfterPrompt } = await freshModules() + const pending = resolvePreciseCenterAfterPrompt() + await vi.waitFor(() => expect(geo.calls).toHaveLength(1)) + expect(query).toHaveBeenCalledTimes(1) + geo.calls[0]!.resolve(34.05, -118.24) + await pending + expect(query).toHaveBeenCalledTimes(2) + }) + + it("is not prompted when WebKit still says prompt after the fix, since no dialog was answered", async () => { + stubPermissions(sequence("prompt", "prompt")) + await expect(settle(true)).resolves.toMatchObject({ prompted: false }) + }) + + it("is not prompted when the second query rejects", async () => { + const query = vi.fn().mockResolvedValueOnce({ state: "prompt" }).mockRejectedValueOnce(new TypeError("nope")) + stubPermissions(query) + await expect(settle(true)).resolves.toMatchObject({ prompted: false }) + }) + + it("is not prompted for a permission already granted or denied before the request, and never re-queries", async () => { + for (const state of ["granted", "denied"]) { + geo = makeGeoStub() + const query = sequence(state, "granted") + stubPermissions(query) + await expect(settle(state === "granted")).resolves.toMatchObject({ prompted: false }) + expect(query).toHaveBeenCalledTimes(1) + } + }) + + it("is not prompted when the prompt was answered with Block", async () => { + stubPermissions(sequence("prompt", "denied")) + await expect(settle(false)).resolves.toEqual({ precise: null, prompted: false }) + }) + + it("is not prompted without a Permissions API, or when the first query rejects", async () => { + await expect(settle(true)).resolves.toMatchObject({ prompted: false }) + geo = makeGeoStub() + stubPermissions(vi.fn().mockRejectedValue(new TypeError("nope"))) + await expect(settle(true)).resolves.toMatchObject({ prompted: false }) + }) +}) diff --git a/apps/community-web/src/lib/locate.ts b/apps/community-web/src/lib/locate.ts index dcf6ece4..49212425 100644 --- a/apps/community-web/src/lib/locate.ts +++ b/apps/community-web/src/lib/locate.ts @@ -123,3 +123,20 @@ export function getBrowserPosition(): Promise { () => null, ) } + +async function geolocationPermissionState(): Promise { + if (typeof navigator === "undefined" || !navigator.permissions) return null + const status = await navigator.permissions.query({ name: "geolocation" }).catch(() => null) + return status?.state ?? null +} + +export async function resolvePreciseCenterAfterPrompt(): Promise<{ + precise: LatLng | null + prompted: boolean +}> { + const before = await geolocationPermissionState() + const precise = await resolvePreciseCenter() + if (before !== "prompt") return { precise, prompted: false } + const after = await geolocationPermissionState() + return { precise, prompted: after === "granted" } +} diff --git a/apps/community-web/src/styles/design-css-tokens.test.ts b/apps/community-web/src/styles/design-css-tokens.test.ts index 050ed6d2..b2ea0364 100644 --- a/apps/community-web/src/styles/design-css-tokens.test.ts +++ b/apps/community-web/src/styles/design-css-tokens.test.ts @@ -47,6 +47,23 @@ describe("design.css neutrals mirror @civfix/shared tokens", () => { it("paints the shell and the boot splash from the same variable", () => { expect(CSS).toMatch(/\.cf-shell\s*\{[^}]*background:\s*var\(--paper\)/) }) + + it("pads every page outside the shell by all four safe areas once, at the body", () => { + const rule = CSS.match(/body:not\(:has\(\.cf-shell\)\)\s*\{([^}]*)\}/) + expect(rule).not.toBeNull() + const body = rule?.[1] ?? "" + expect(body).toMatch(/height:\s*auto;/) + expect(body).toMatch(/min-height:\s*100%;/) + expect(body.replace(/\s+/g, " ")).toContain( + "padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);", + ) + expect(CSS.match(/safe-area-inset-bottom/g)).toHaveLength(1) + }) + + it("keeps the shell clear of a landscape notch by consuming the side safe areas at the frame", () => { + expect(CSS).toMatch(/\.cf-shell\s*\{[^}]*left:\s*env\(safe-area-inset-left, 0px\)/) + expect(CSS).toMatch(/\.cf-shell\s*\{[^}]*right:\s*env\(safe-area-inset-right, 0px\)/) + }) }) const HUES = [ diff --git a/apps/community-web/src/styles/design.css b/apps/community-web/src/styles/design.css index 71caedbe..a94d1d1c 100644 --- a/apps/community-web/src/styles/design.css +++ b/apps/community-web/src/styles/design.css @@ -237,8 +237,26 @@ .cf-shell { position: fixed; inset: 0; + left: env(safe-area-inset-left, 0px); + right: env(safe-area-inset-right, 0px); background: var(--paper); overflow: hidden; + touch-action: manipulation; + --ios-focus-zoom-floor: 16px; +} + +body:not(:has(.cf-shell)) { + height: auto; + min-height: 100%; + padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) + env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); +} + +@media (pointer: coarse) { + .cf-shell input, + .cf-shell textarea { + font-size: var(--ios-focus-zoom-floor); + } } /* diff --git a/packages/shared/DECISIONS.md b/packages/shared/DECISIONS.md index 43369826..6cf65322 100644 --- a/packages/shared/DECISIONS.md +++ b/packages/shared/DECISIONS.md @@ -1939,3 +1939,17 @@ implements `GET /me/hosted-events/analytics/summary` and must move asserts it. civfix-app adopts 0.55.0 to call the endpoint and to ship the funnel's new first step; civfix-admin and civfix-govt-web bump with the routine version propagation and call nothing new. No migration — every number here is aggregated from tables that already exist. + +## 55. A public post is guest-readable (0.55.1) + +`getPost` — `GET /posts/:id` — moves from `auth: "required"` to `auth: "optional"`, mirroring +`getReport` and `getProfile`. A signed-out read of a PUBLIC post returns the ordinary `PostDTO` +with every viewer flag false (the server reads it as `NIL_VIEWER_ID`, exactly as the guest feed +does). A hidden or deleted post is a 404 byte-identical to an unknown id for every viewer, signed +in or not — never a 401/403 that would confirm it exists. The route also serves a PUBLIC REPLY to +a guest; this is accepted: a signed-in user could always read replies by id, and the guest feeds +still exclude replies. The consumer is the web edge preview of +`/post/:id` (civfix-app `apps/community-web/functions/post`), which only ever makes guest reads. +No schema changes; the typed client already attaches credentials for any `auth !== "public"` +endpoint, so no call site changes on the wire. Delivery set: civfix-backend `services/api` serves +the route to guests; `services/media-worker` and civfix-admin adopt the patch in range. diff --git a/packages/shared/src/client/endpoints.ts b/packages/shared/src/client/endpoints.ts index 1f7e0b92..ef024204 100644 --- a/packages/shared/src/client/endpoints.ts +++ b/packages/shared/src/client/endpoints.ts @@ -1398,7 +1398,7 @@ export const coreEndpoints = { path: "/posts/:id", request: null, response: GetPostResponseSchema, - auth: "required", + auth: "optional", csrf: false, version: "v1", }), diff --git a/packages/shared/src/schemas/__tests__/posts.test.ts b/packages/shared/src/schemas/__tests__/posts.test.ts index 705198b3..59461213 100644 --- a/packages/shared/src/schemas/__tests__/posts.test.ts +++ b/packages/shared/src/schemas/__tests__/posts.test.ts @@ -181,7 +181,7 @@ describe("post endpoint registry", () => { // a readable header next to a 401'd posts tab. const specs = [ [endpoints.createPost, "POST", "/posts", true, "required"], - [endpoints.getPost, "GET", "/posts/:id", false, "required"], + [endpoints.getPost, "GET", "/posts/:id", false, "optional"], [endpoints.deletePost, "DELETE", "/posts/:id", true, "required"], [endpoints.listReplies, "GET", "/posts/:id/replies", false, "required"], [endpoints.repostPost, "POST", "/posts/:id/repost", true, "required"], diff --git a/packages/ui/src/bodies/AddressRow.tsx b/packages/ui/src/bodies/AddressRow.tsx index 26a58086..d34c9e27 100644 --- a/packages/ui/src/bodies/AddressRow.tsx +++ b/packages/ui/src/bodies/AddressRow.tsx @@ -5,6 +5,7 @@ import type { AddressPrecision, EventKind, ReportCategory } from "@civfix/shared import { focusRingProps, makeThemedStyles, + useLayoutMode, useTheme, webCursorPointer, webHover, @@ -16,8 +17,7 @@ import { Icon, Text, iconMap } from "../typography" import type { IconName } from "../typography" import { useClipboard, useHaptics, useOpenExternal } from "../capabilities" import { useT } from "../i18n" -import { useMapFocus } from "../map" -import { useNavStore } from "../nav" +import { showOnMap } from "../map" import { useToast } from "../primitives" import { addressExternalPlan, @@ -193,6 +193,7 @@ export function AddressRow({ const clipboard = useClipboard() const openExternal = useOpenExternal() const haptics = useHaptics() + const mode = useLayoutMode() const toast = useToast() const [sheetOpen, setSheetOpen] = useState(false) @@ -290,23 +291,13 @@ export function AddressRow({ const onFocusMap = useCallback(() => { if (!point || !focusTarget) return haptics.selection() - if (focusTarget.kind === "report") { - useMapFocus - .getState() - .setReport({ id: focusTarget.id, lat: point.lat, lng: point.lng, category: focusTarget.category }) - } else { - useMapFocus - .getState() - .setEvent({ id: focusTarget.id, lat: point.lat, lng: point.lng, eventKind: focusTarget.eventKind }) - } - useNavStore.getState().setSnap(1) - }, [focusTarget, haptics, point]) - - const onLongPress = useCallback(() => { - if (!affordances.longPressSheet || sheetOptions.length === 0) return - haptics.impactLight() - setSheetOpen(true) - }, [affordances.longPressSheet, haptics, sheetOptions.length]) + showOnMap( + mode, + focusTarget.kind === "report" + ? { kind: "report", id: focusTarget.id, lat: point.lat, lng: point.lng, category: focusTarget.category } + : { kind: "cleanup", id: focusTarget.id, lat: point.lat, lng: point.lng, eventKind: focusTarget.eventKind }, + ) + }, [focusTarget, haptics, mode, point]) if (!display) return null @@ -326,28 +317,8 @@ export function AddressRow({ ) } - const body = affordances.focusMap ? ( - [ - styles.main, - webTransition, - webCursorPointer, - webHover(state) ? styles.mainHovered : null, - state.pressed ? styles.pressed : null, - ]} - > - - {text} - {trailing} - - ) : ( - + const body = ( + {text} {trailing} @@ -355,13 +326,32 @@ export function AddressRow({ ) return ( - - {body} - {affordances.copy ? ( - - ) : null} - {affordances.externalMaps ? ( - + + + {body} + {affordances.copy ? ( + + ) : null} + {affordances.externalMaps ? ( + + ) : null} + + {affordances.focusMap ? ( + [ + styles.showMap, + webTransition, + webCursorPointer, + webHover(state) ? styles.showMapHovered : null, + state.pressed ? styles.pressed : null, + ]} + > + + {t("row.show_map")} + ) : null} ({ borderRadius: t.radius.sm, paddingVertical: 2, }, - mainHovered: { + block: { + gap: t.space["1"], + }, + showMap: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + gap: t.space["1"], + marginLeft: 14 + t.space["2"], + paddingVertical: 2, + paddingHorizontal: t.space["1"], + borderRadius: t.radius.pill, + }, + showMapHovered: { backgroundColor: t.colors.surfaceTint, }, + showMapText: { + fontFamily: t.fontFamily.bodyBold, + fontSize: t.fontSize["13"], + color: t.colors.accentText, + }, text: { flexShrink: 1, fontFamily: t.fontFamily.bodyRegular, diff --git a/packages/ui/src/bodies/EventDetailBody.tsx b/packages/ui/src/bodies/EventDetailBody.tsx index f154b660..826217a7 100644 --- a/packages/ui/src/bodies/EventDetailBody.tsx +++ b/packages/ui/src/bodies/EventDetailBody.tsx @@ -44,7 +44,7 @@ import { useHaptics } from "../capabilities" import { useLocale, useRelativeTime, useT, useViewerTimeZone } from "../i18n" import { usePageIsActive } from "../shell/pageActive" import { useScrollHost } from "../shell/ScrollHost" -import { MiniMap, useMapFocus } from "../map" +import { useMapFocus } from "../map" import { AddressRow } from "./AddressRow" import { FeedNotice } from "./FeedNotice" import { EventActionRow, EventActionRows } from "./EventActionRow" @@ -61,40 +61,20 @@ import { generalSlotBoard } from "./eventSlotsModel" import { buildComposerEventRef } from "./postComposerModel" import { usePostComposerStore } from "./postComposerStore" -const HERO_HEIGHT = 160 - const HERO_COVER_RATIO = 16 / 9 function EventHero({ cleanup }: { cleanup: CleanupDTO }) { const styles = useStyles() - const th = useTheme() const cover = cleanup.coverUrl?.trim() - const hasCoords = cleanup.lat != null && cleanup.lng != null - - if (cover) { - return ( - - - - ) - } - - if (hasCoords) { - return ( - - - - ) - } - + if (!cover) return null return ( - - + + ) } @@ -669,7 +649,6 @@ function EventDetailSkeleton() { contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} > - @@ -729,9 +708,6 @@ const useStyles = makeThemedStyles((t) => ({ stateFill: { flex: 1, }, - skeletonHero: { - marginTop: t.space["1"], - }, skeletonStatus: { marginTop: t.space["1"], }, @@ -751,12 +727,6 @@ const useStyles = makeThemedStyles((t) => ({ width: "100%", height: "100%", }, - heroBlank: { - height: HERO_HEIGHT, - alignItems: "center", - justifyContent: "center", - backgroundColor: t.colors.bgAlt, - }, header: { marginTop: t.space["4"], diff --git a/packages/ui/src/bodies/NotificationsBody.tsx b/packages/ui/src/bodies/NotificationsBody.tsx index 1cd2e4a6..d69b86f2 100644 --- a/packages/ui/src/bodies/NotificationsBody.tsx +++ b/packages/ui/src/bodies/NotificationsBody.tsx @@ -6,6 +6,7 @@ import { Text, Icon, iconMap, type IconName } from "../typography" import { EmptyState, LoadingState, SignInPrompt } from "../primitives" import { useNotifications, useMarkNotificationsRead, useAuthState, useRequireAuth } from "../data" import { useNavStore, entryFromPath, isRootLink } from "../nav" +import { useOpenInternalHref } from "../capabilities" import { useScrollHost } from "../shell/ScrollHost" import { useT } from "../i18n" import { idKeyExtractor } from "./navHelpers" @@ -117,14 +118,15 @@ export function NotificationsBody() { if (unreadIds.length > 0) mutateRead(unreadIds) }, [notifications, mutateRead]) + const entryForHref = useOpenInternalHref()?.entryFor ?? entryFromPath const onPressItem = useCallback( (item: NotificationDTO) => { if (!item.read) mutateRead([item.id]) - const entry = entryFromPath(item.link) + const entry = entryForHref(item.link) if (entry) useNavStore.getState().push(entry) else if (isRootLink(item.link)) useNavStore.getState().selectView("home") }, - [mutateRead], + [entryForHref, mutateRead], ) const renderItem = useCallback( diff --git a/packages/ui/src/bodies/__tests__/addressRowModel.test.ts b/packages/ui/src/bodies/__tests__/addressRowModel.test.ts index 078c12e8..1f04d3e0 100644 --- a/packages/ui/src/bodies/__tests__/addressRowModel.test.ts +++ b/packages/ui/src/bodies/__tests__/addressRowModel.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs" import { describe, expect, it } from "vitest" import { addressExternalPlan, @@ -114,12 +115,11 @@ describe("addressRowAffordances", () => { hasExternalPlan: true, } - it("gives the full variant all three affordances", () => { + it("gives the full variant copy, maps and the show-on-map action", () => { expect(addressRowAffordances(base)).toEqual({ focusMap: true, copy: true, externalMaps: true, - longPressSheet: true, }) }) @@ -147,7 +147,6 @@ describe("addressRowAffordances", () => { }) expect(noPlan.externalMaps).toBe(false) expect(noPlan.copy).toBe(true) - expect(noPlan.longPressSheet).toBe(true) }) it("does not focus the map without a target, so the row never hardcodes an entity", () => { @@ -159,7 +158,6 @@ describe("addressRowAffordances", () => { focusMap: false, copy: false, externalMaps: false, - longPressSheet: false, }) }) }) @@ -233,3 +231,32 @@ describe("addressExternalPlan", () => { ).toEqual({ kind: "none" }) }) }) + +describe("AddressRow source", () => { + const strip = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "") + const row = strip(readFileSync(new URL("../AddressRow.tsx", import.meta.url), "utf8")) + + it("never turns the address text into an unlabelled press target", () => { + expect(row).not.toContain('accessibilityHint={t("row.focus_hint")}') + expect(row).not.toContain("onLongPress") + expect(row).toContain('t("row.static_a11y", { address: display })') + }) + + it("offers a labelled Show on map action gated on focusMap and wired to onFocusMap", () => { + expect(row).toContain('t("row.show_map")') + const gateAt = row.indexOf("affordances.focusMap ? (") + expect(gateAt).toBeGreaterThan(-1) + expect(row.indexOf("onPress={onFocusMap}")).toBeGreaterThan(gateAt) + }) + + it("routes Show on map through the shared flow with the layout mode", () => { + const focus = /const onFocusMap = useCallback\([\s\S]*?\n {2}\}, \[/.exec(row)?.[0] ?? "" + expect(focus).toContain("showOnMap(\n mode,") + expect(focus).not.toContain("useMapFocus") + expect(focus).not.toContain("selectView(") + }) + + it("exposes the static address as one labelled accessibility element", () => { + expect(row).toContain('') + }) +}) diff --git a/packages/ui/src/bodies/__tests__/eventCover.test.ts b/packages/ui/src/bodies/__tests__/eventCover.test.ts index df13bcce..94de60b0 100644 --- a/packages/ui/src/bodies/__tests__/eventCover.test.ts +++ b/packages/ui/src/bodies/__tests__/eventCover.test.ts @@ -41,15 +41,14 @@ describe("eventCoverErrorKey", () => { }) describe("the event hero", () => { - it("prefers an uploaded cover, and keeps the map as the no-cover fallback", () => { + it("renders the uploaded cover, and nothing at all without one", () => { const hero = detail.match(/function EventHero\([\s\S]*?\n\}/)?.[0] ?? "" expect(hero).toContain("const cover = cleanup.coverUrl?.trim()") - const coverAt = hero.indexOf("if (cover)") - const coordsAt = hero.indexOf("if (hasCoords)") - expect(coverAt).toBeGreaterThan(-1) - expect(coordsAt).toBeGreaterThan(coverAt) - expect(hero).toContain(" { + it("prefers the host's entryFor and falls back to the shared parser", () => { + expect(notifications).toContain("const entryForHref = useOpenInternalHref()?.entryFor ?? entryFromPath") + expect(notifications).toContain("const entry = entryForHref(item.link)") + expect(notifications).toContain("[entryForHref, mutateRead]") + }) +}) diff --git a/packages/ui/src/bodies/addressRowModel.ts b/packages/ui/src/bodies/addressRowModel.ts index ac64a9b1..4aa650b4 100644 --- a/packages/ui/src/bodies/addressRowModel.ts +++ b/packages/ui/src/bodies/addressRowModel.ts @@ -19,7 +19,6 @@ export interface AddressRowAffordances { focusMap: boolean copy: boolean externalMaps: boolean - longPressSheet: boolean } export type AddressExternalPlan = @@ -97,15 +96,12 @@ export function addressRowAffordances(input: { hasExternalPlan: boolean }): AddressRowAffordances { if (input.variant === "compact") { - return { focusMap: false, copy: false, externalMaps: false, longPressSheet: false } + return { focusMap: false, copy: false, externalMaps: false } } - const copy = input.hasAddress && input.hasClipboard - const externalMaps = input.hasOpenExternal && input.hasExternalPlan return { focusMap: input.hasPoint && input.hasFocusTarget, - copy, - externalMaps, - longPressSheet: copy || externalMaps, + copy: input.hasAddress && input.hasClipboard, + externalMaps: input.hasOpenExternal && input.hasExternalPlan, } } diff --git a/packages/ui/src/bodies/thread/useReplyDockInset.ts b/packages/ui/src/bodies/thread/useReplyDockInset.ts index 446b1439..4441ba67 100644 --- a/packages/ui/src/bodies/thread/useReplyDockInset.ts +++ b/packages/ui/src/bodies/thread/useReplyDockInset.ts @@ -60,8 +60,6 @@ export interface ReplyDockInset { export function useReplyDockInset(): ReplyDockInset { // The legacy shell seam: 0 on native, the visualViewport overlap on web. See the table above. const shell = useKeyboardInset() - // NOT `useSafeAreaInsets()`: this hook renders on web too, where there is no SafeAreaProvider and the - // hook throws. The context form degrades to zeros, exactly like ConversationBody does. const insets = useContext(SafeAreaInsetsContext) const [own, setOwn] = useState(0) const restingWindowHeight = useRestingWindowHeight() @@ -101,5 +99,6 @@ export function useReplyDockInset(): ReplyDockInset { }, []) const inset = Math.max(shell, own) - return { inset, restPad: inset > 0 ? 0 : (insets?.bottom ?? 0), visible: inset > 0 } + const restingSafeArea = Platform.OS === "web" ? 0 : (insets?.bottom ?? 0) + return { inset, restPad: inset > 0 ? 0 : restingSafeArea, visible: inset > 0 } } diff --git a/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts b/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts index bf53f667..f32cab0c 100644 --- a/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts +++ b/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts @@ -9,7 +9,9 @@ const code = (source: string): string => describe("openInternalHref is an OPTIONAL capability, like every other host seam", () => { it("is declared on PlatformCapabilities as optional, so a host without one still typechecks", () => { const types = code(read("../types.ts")) - expect(types).toMatch(/export interface OpenInternalHrefCapability \{\s*open\(path: string\): boolean\s*\}/) + expect(types).toMatch( + /export interface OpenInternalHrefCapability \{\s*open\(path: string\): boolean\s*entryFor\?\(path: string \| null \| undefined\): DetailEntry \| null\s*\}/, + ) expect(types).toContain("openInternalHref?: OpenInternalHrefCapability") }) diff --git a/packages/ui/src/capabilities/types.ts b/packages/ui/src/capabilities/types.ts index fe720637..c6c4bee4 100644 --- a/packages/ui/src/capabilities/types.ts +++ b/packages/ui/src/capabilities/types.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "react" +import type { DetailEntry } from "../nav/types" export interface CapturedMedia { @@ -87,6 +88,7 @@ export interface OpenExternalCapability { export interface OpenInternalHrefCapability { open(path: string): boolean + entryFor?(path: string | null | undefined): DetailEntry | null } export interface CalendarFileCapability { diff --git a/packages/ui/src/i18n/locales/de/address.json b/packages/ui/src/i18n/locales/de/address.json index c3833477..f6e5a15d 100644 --- a/packages/ui/src/i18n/locales/de/address.json +++ b/packages/ui/src/i18n/locales/de/address.json @@ -5,8 +5,7 @@ "copied": "Adresse kopiert", "copy_failed": "Adresse konnte nicht kopiert werden", "open_maps": "In einer Karten-App öffnen", - "focus_a11y": "{{address}}", - "focus_hint": "Zeigt diesen Ort auf der Karte", + "show_map": "Auf der Karte anzeigen", "static_a11y": "Adresse: {{address}}" }, "sheet": { diff --git a/packages/ui/src/i18n/locales/de/event-form.json b/packages/ui/src/i18n/locales/de/event-form.json index 6f5ad343..416cd620 100644 --- a/packages/ui/src/i18n/locales/de/event-form.json +++ b/packages/ui/src/i18n/locales/de/event-form.json @@ -19,7 +19,7 @@ }, "cover": { "label": "Titelbild", - "hint": "Erscheint oben auf deiner Veranstaltungsseite anstelle der Karte.", + "hint": "Erscheint oben auf deiner Veranstaltungsseite.", "add": "Titelbild hinzufügen", "replace": "Titelbild ersetzen", "remove": "Titelbild entfernen", diff --git a/packages/ui/src/i18n/locales/de/map-ui.json b/packages/ui/src/i18n/locales/de/map-ui.json index 1db85ffe..7613d602 100644 --- a/packages/ui/src/i18n/locales/de/map-ui.json +++ b/packages/ui/src/i18n/locales/de/map-ui.json @@ -43,7 +43,6 @@ }, "a11y": { "attribution": "CARTO / OSM", - "miniMap": "Karte mit dem Standort", "homeMap": "Karte mit Meldungen und Events", "picker": "Tippe auf die Karte, um die Standortmarkierung zu setzen, oder ziehe sie zum Feinjustieren", "userLocation": "Dein Standort" diff --git a/packages/ui/src/i18n/locales/en/address.json b/packages/ui/src/i18n/locales/en/address.json index 67d596ed..256e7038 100644 --- a/packages/ui/src/i18n/locales/en/address.json +++ b/packages/ui/src/i18n/locales/en/address.json @@ -5,8 +5,7 @@ "copied": "Address copied", "copy_failed": "Couldn't copy the address", "open_maps": "Open in a maps app", - "focus_a11y": "{{address}}", - "focus_hint": "Shows this spot on the map", + "show_map": "Show on map", "static_a11y": "Address: {{address}}" }, "sheet": { diff --git a/packages/ui/src/i18n/locales/en/event-form.json b/packages/ui/src/i18n/locales/en/event-form.json index f0d1ea44..29d2d6b7 100644 --- a/packages/ui/src/i18n/locales/en/event-form.json +++ b/packages/ui/src/i18n/locales/en/event-form.json @@ -19,7 +19,7 @@ }, "cover": { "label": "Cover image", - "hint": "Shown at the top of your event page instead of the map.", + "hint": "Shown at the top of your event page.", "add": "Add a cover", "replace": "Replace cover", "remove": "Remove cover", diff --git a/packages/ui/src/i18n/locales/en/map-ui.json b/packages/ui/src/i18n/locales/en/map-ui.json index 556f7010..4b113d5b 100644 --- a/packages/ui/src/i18n/locales/en/map-ui.json +++ b/packages/ui/src/i18n/locales/en/map-ui.json @@ -43,7 +43,6 @@ }, "a11y": { "attribution": "CARTO / OSM", - "miniMap": "Map showing the location", "homeMap": "Map of reports and events", "picker": "Tap the map to place the location pin, or drag the pin to fine-tune", "userLocation": "Your location" diff --git a/packages/ui/src/i18n/locales/es/address.json b/packages/ui/src/i18n/locales/es/address.json index f2745880..1f2d663b 100644 --- a/packages/ui/src/i18n/locales/es/address.json +++ b/packages/ui/src/i18n/locales/es/address.json @@ -5,8 +5,7 @@ "copied": "Dirección copiada", "copy_failed": "No se pudo copiar la dirección", "open_maps": "Abrir en una app de mapas", - "focus_a11y": "{{address}}", - "focus_hint": "Muestra este punto en el mapa", + "show_map": "Ver en el mapa", "static_a11y": "Dirección: {{address}}" }, "sheet": { diff --git a/packages/ui/src/i18n/locales/es/event-form.json b/packages/ui/src/i18n/locales/es/event-form.json index bad41bbe..f97e6ac6 100644 --- a/packages/ui/src/i18n/locales/es/event-form.json +++ b/packages/ui/src/i18n/locales/es/event-form.json @@ -19,7 +19,7 @@ }, "cover": { "label": "Imagen de portada", - "hint": "Aparece arriba en la página de tu evento, en lugar del mapa.", + "hint": "Aparece arriba en la página de tu evento.", "add": "Añadir portada", "replace": "Cambiar portada", "remove": "Quitar portada", diff --git a/packages/ui/src/i18n/locales/es/map-ui.json b/packages/ui/src/i18n/locales/es/map-ui.json index 68f0f176..6d9d4236 100644 --- a/packages/ui/src/i18n/locales/es/map-ui.json +++ b/packages/ui/src/i18n/locales/es/map-ui.json @@ -43,7 +43,6 @@ }, "a11y": { "attribution": "CARTO / OSM", - "miniMap": "Mapa que muestra la ubicación", "homeMap": "Mapa de reportes y eventos", "picker": "Toca el mapa para colocar el marcador de ubicación, o arrástralo para ajustarlo", "userLocation": "Tu ubicación" diff --git a/packages/ui/src/i18n/locales/ko/address.json b/packages/ui/src/i18n/locales/ko/address.json index 889aba2f..e1b85890 100644 --- a/packages/ui/src/i18n/locales/ko/address.json +++ b/packages/ui/src/i18n/locales/ko/address.json @@ -5,8 +5,7 @@ "copied": "주소를 복사했습니다", "copy_failed": "주소를 복사하지 못했습니다", "open_maps": "지도 앱에서 열기", - "focus_a11y": "{{address}}", - "focus_hint": "지도에서 이 위치를 보여줍니다", + "show_map": "지도에서 보기", "static_a11y": "주소: {{address}}" }, "sheet": { diff --git a/packages/ui/src/i18n/locales/ko/event-form.json b/packages/ui/src/i18n/locales/ko/event-form.json index 7deb1a05..2c20e742 100644 --- a/packages/ui/src/i18n/locales/ko/event-form.json +++ b/packages/ui/src/i18n/locales/ko/event-form.json @@ -19,7 +19,7 @@ }, "cover": { "label": "대표 이미지", - "hint": "이벤트 페이지 상단에 지도 대신 표시됩니다.", + "hint": "이벤트 페이지 상단에 표시됩니다.", "add": "대표 이미지 추가", "replace": "대표 이미지 교체", "remove": "대표 이미지 삭제", diff --git a/packages/ui/src/i18n/locales/ko/map-ui.json b/packages/ui/src/i18n/locales/ko/map-ui.json index d4742cd6..a5d92428 100644 --- a/packages/ui/src/i18n/locales/ko/map-ui.json +++ b/packages/ui/src/i18n/locales/ko/map-ui.json @@ -43,7 +43,6 @@ }, "a11y": { "attribution": "CARTO / OSM", - "miniMap": "위치를 표시하는 지도", "homeMap": "제보 및 이벤트 지도", "picker": "지도를 탭하여 위치 핀을 놓거나, 핀을 드래그하여 미세 조정하세요", "userLocation": "내 위치" diff --git a/packages/ui/src/map/LocationPicker.native.tsx b/packages/ui/src/map/LocationPicker.native.tsx index 6cc5ecc5..1d840b1e 100644 --- a/packages/ui/src/map/LocationPicker.native.tsx +++ b/packages/ui/src/map/LocationPicker.native.tsx @@ -27,7 +27,7 @@ * PAN recognizer, so leaving drag-pan enabled lets the embedded map swallow every vertical drag that * starts on it - in the gorhom sheet that defeats the sheet's scroll-vs-drag coordination entirely and * the whole form becomes unscrollable in portrait. So BY DEFAULT the map's pan/zoom/rotate/pitch gestures - * are DISABLED (mirrors MiniMap.native's "drag stays with the sheet" decision); only the discrete + * are DISABLED (the drag stays with the sheet); only the discrete * `onPress` tap survives, which is all the inline pin-drop model needs. Tap is not a pan, so disabling * pan never blocks dropping the pin. * @@ -90,8 +90,7 @@ export function LocationPicker({ // Structural MapStyleInput -> the concrete StyleSpecification the native Map wants (mirrors Map.native). // rasterMapStyle() rebuilds a deeply-nested style object every call; LocationPicker re-renders on every // tap (setPicked) and on every parent `value` change, so memoize it to a stable identity. The attribution - // is a module constant and the CARTO key is build-time env, so [cartoApiKey] is the correct dep - // (mirrors MiniMap.native.tsx). + // is a module constant and the CARTO key is build-time env, so [cartoApiKey] is the correct dep. const cartoApiKey = useCartoApiKey() const mapStyle = useMemo( () => @@ -190,8 +189,8 @@ export function LocationPicker({ attribution={false} // Drag / zoom gestures follow `interactive`. DEFAULT (false): the embedded inline picker lives in // the host form's scroll body (the gorhom BottomSheetScrollView in portrait), so every gesture is - // disabled and the drag stays with the sheet - only the discrete `onPress` tap drops/moves the pin - // (mirrors MiniMap). FULL-SCREEN picker (`interactive`): no sheet to protect, so the map is the + // disabled and the drag stays with the sheet - only the discrete `onPress` tap drops/moves the pin. + // FULL-SCREEN picker (`interactive`): no sheet to protect, so the map is the // moveable home map (pan / zoom / rotate), and the same `onPress` tap still drops the pin. dragPan={interactive} touchZoom={interactive} @@ -224,7 +223,7 @@ export function LocationPicker({ {/* Basemap attribution (App-Store-audit H10): the native maplibre attribution control is suppressed (attribution={false}) to keep the picker chrome clean, so a static CARTO/OSM credit stands in for - it - the same static credit pattern MiniMap.native.tsx uses. In fullBleed it is lifted above the + it. In fullBleed it is lifted above the host's floating confirm/cancel bar via attributionBottomInset. */} ({ fontSize: 12.5, color: t.colors.text, }, - // Static basemap credit (bottom-right), mirroring MiniMap.native.tsx's CARTO/OSM credit. + // Static basemap credit (bottom-right) for the CARTO/OSM tiles. credit: { position: "absolute", bottom: 4, diff --git a/packages/ui/src/map/Map.native.tsx b/packages/ui/src/map/Map.native.tsx index 7cf4b1ca..23f1bf90 100644 --- a/packages/ui/src/map/Map.native.tsx +++ b/packages/ui/src/map/Map.native.tsx @@ -25,11 +25,12 @@ import { useClusters } from "./useClusters" import { mapPointsFor } from "./mapPoints" import { createIdleRunner, type IdleRunner } from "./clusterSchedule" import { useLocationPick } from "./locationPickStore" -import { useMapFocus } from "./mapFocusStore" +import { useMapFocus, type FocusedEntity } from "./mapFocusStore" import { useMapViewport } from "./mapViewportStore" import { useDroppedPin } from "./droppedPinStore" +import { useMapFlyTo } from "./mapFlyToStore" import { longPressHitsMarker, type LongPressMarker } from "./longPressGate" -import { markerNodeIsActive } from "./markerFocus" +import { activeMarkerIds, flyToTargetOffMap, markerNodeIsActive } from "./markerFocus" import { clusterFallbackZoom, clusterListReports, @@ -90,6 +91,32 @@ const MarkerNode = memo(function MarkerNode({ node, markerId, active, onPress }: ) }) +interface TargetMarkerProps { + target: FocusedEntity + onPressPin: (event: NativeSyntheticEvent) => void + onPressCleanup: (event: NativeSyntheticEvent) => void +} + +function TargetMarker({ target, onPressPin, onPressCleanup }: TargetMarkerProps) { + if (target.kind === "cleanup") { + return ( + + + + ) + } + return ( + + + + ) +} + export const Map = memo(forwardRef(function Map(props, ref) { const { reports = NO_REPORTS, @@ -100,6 +127,7 @@ export const Map = memo(forwardRef(function Map(props, ref) userLocation = null, showUserLocation = false, onRegionChange, + onUserCameraMove, onPressPin, onPressCleanup, onPressCluster, @@ -143,6 +171,8 @@ export const Map = memo(forwardRef(function Map(props, ref) const lastRegionRef = useRef<{ bbox: BBox; zoom: number } | null>(null) const onRegionChangeRef = useRef(onRegionChange) onRegionChangeRef.current = onRegionChange + const onUserCameraMoveRef = useRef(onUserCameraMove) + onUserCameraMoveRef.current = onUserCameraMove const initialCenterRef = useRef(initialCenter) const initialViewState = useMemo( @@ -155,6 +185,10 @@ export const Map = memo(forwardRef(function Map(props, ref) const focus = useMapFocus((s) => s.focus) const droppedPin = useDroppedPin((s) => s.pin) + const flyToRequest = useMapFlyTo((s) => s.request) + const flyToHighlight = useMapFlyTo((s) => s.highlight) + const activeIds = activeMarkerIds(focusedPinId, focusedCleanupId, flyToHighlight) + const [mapLoaded, setMapLoaded] = useState(false) const recomputeRef = useRef<() => void>(() => {}) recomputeRef.current = () => { @@ -176,14 +210,21 @@ export const Map = memo(forwardRef(function Map(props, ref) } }, [runner]) + const offMapTarget = useMemo( + () => (focus ? null : flyToTargetOffMap(nodes, flyToHighlight)), + [focus, nodes, flyToHighlight], + ) + const hitMarkers = useMemo(() => { if (focus) return [{ lat: focus.lat, lng: focus.lng }] - return nodes.map((node) => ({ + const markers: LongPressMarker[] = nodes.map((node) => ({ lat: node.lat, lng: node.lng, anchor: node.type === "cluster" ? ("center" as const) : ("bottom" as const), })) - }, [focus, nodes]) + if (offMapTarget) markers.push({ lat: offMapTarget.lat, lng: offMapTarget.lng, anchor: "bottom" }) + return markers + }, [focus, nodes, offMapTarget]) const hitMarkersRef = useRef(hitMarkers) hitMarkersRef.current = hitMarkers @@ -232,7 +273,17 @@ export const Map = memo(forwardRef(function Map(props, ref) [commitRegion], ) + const handleRegionWillChange = useCallback( + (event: { nativeEvent: ViewStateChangeEvent }) => { + if (!event.nativeEvent.userInteraction) return + useMapFlyTo.getState().clear() + onUserCameraMoveRef.current?.() + }, + [], + ) + const handleMapLoad = useCallback(() => { + setMapLoaded(true) const pending = mapNativeRef.current?.getViewState() if (!pending) { runner.request() @@ -254,7 +305,17 @@ export const Map = memo(forwardRef(function Map(props, ref) useEffect(() => { if (!focus) return cameraRef.current?.flyTo({ center: [focus.lng, focus.lat], zoom: FOCUS_ZOOM, duration: 600 }) - }, [focus?.id, focus?.lat, focus?.lng]) + }, [focus]) + + useEffect(() => { + if (!flyToRequest || !mapLoaded) return + cameraRef.current?.flyTo({ + center: [flyToRequest.lng, flyToRequest.lat], + zoom: FOCUS_ZOOM, + duration: 600, + }) + useMapFlyTo.getState().consume(flyToRequest.generation) + }, [flyToRequest, mapLoaded]) const markerPressedAtRef = useRef(0) const onPressMapRef = useRef(onPressMap) @@ -287,12 +348,14 @@ export const Map = memo(forwardRef(function Map(props, ref) const handlePressPin = useCallback((event: NativeSyntheticEvent) => { markerPressedAtRef.current = Date.now() + useMapFlyTo.getState().clear() const id = event.nativeEvent.id.slice("pin-".length) hapticsRef.current.selection() onPressPinRef.current?.(id) }, []) const handlePressCluster = useCallback((event: NativeSyntheticEvent) => { markerPressedAtRef.current = Date.now() + useMapFlyTo.getState().clear() const node = nodesByMarkerRef.current.get(event.nativeEvent.id) if (!node || node.type !== "cluster") return hapticsRef.current.selection() @@ -316,12 +379,14 @@ export const Map = memo(forwardRef(function Map(props, ref) }, []) const handlePressCleanup = useCallback((event: NativeSyntheticEvent) => { markerPressedAtRef.current = Date.now() + useMapFlyTo.getState().clear() const id = event.nativeEvent.id.slice("cleanup-".length) hapticsRef.current.selection() onPressCleanupRef.current?.(id) }, []) const handlePressBlend = useCallback((event: NativeSyntheticEvent) => { markerPressedAtRef.current = Date.now() + useMapFlyTo.getState().clear() const node = nodesByMarkerRef.current.get(event.nativeEvent.id) if (!node || node.type !== "blend") return hapticsRef.current.selection() @@ -366,6 +431,7 @@ export const Map = memo(forwardRef(function Map(props, ref) attributionPosition={{ bottom: insets.bottom + 96, right: 8 }} compass={false} onDidFinishLoadingMap={handleMapLoad} + onRegionWillChange={handleRegionWillChange} onRegionDidChange={handleRegion} onPress={handleMapPress} onLongPress={handleMapLongPress} @@ -375,27 +441,12 @@ export const Map = memo(forwardRef(function Map(props, ref) {showUserLocation ? : null} {focus ? ( - focus.kind === "cleanup" ? ( - - - - ) : ( - - - - ) + ) : ( <> {markerNodes.rendered.map(({ node, markerId }) => ( @@ -403,7 +454,7 @@ export const Map = memo(forwardRef(function Map(props, ref) key={node.key} node={node} markerId={markerId} - active={markerNodeIsActive(node, focusedPinId, focusedCleanupId)} + active={markerNodeIsActive(node, activeIds.pinId, activeIds.cleanupId)} onPress={ node.type === "cluster" ? handlePressCluster @@ -415,6 +466,14 @@ export const Map = memo(forwardRef(function Map(props, ref) } /> ))} + {offMapTarget ? ( + + ) : null} )} diff --git a/packages/ui/src/map/Map.web.tsx b/packages/ui/src/map/Map.web.tsx index b93e42f2..a4da9cb8 100644 --- a/packages/ui/src/map/Map.web.tsx +++ b/packages/ui/src/map/Map.web.tsx @@ -36,9 +36,11 @@ import { expansionZoomOfCluster, } from "./clusterer" import { useLocationPick } from "./locationPickStore" -import { useMapFocus } from "./mapFocusStore" +import { useMapFocus, type FocusedEntity } from "./mapFocusStore" import { useMapViewport } from "./mapViewportStore" import { useDroppedPin } from "./droppedPinStore" +import { useMapFlyTo } from "./mapFlyToStore" +import { activeMarkerIds, flyToTargetOffMap } from "./markerFocus" import { makePinElement, applyPinElementTheme } from "./LocationPicker.web" import { occludedCenterLng } from "./dropPinCamera" import type { ClusterNode, MapClusterIndex } from "./clusterer" @@ -114,6 +116,7 @@ export const Map = React.forwardRef(function Map(props, ref userLocation = null, showUserLocation = false, onRegionChange, + onUserCameraMove, onPressPin, onPressCleanup, onPressCluster, @@ -150,9 +153,18 @@ export const Map = React.forwardRef(function Map(props, ref pickActiveRef.current = pickActive const focus = useMapFocus((s) => s.focus) + const flyToRequest = useMapFlyTo((s) => s.request) + const flyToHighlight = useMapFlyTo((s) => s.highlight) + const { pinId: activePinId, cleanupId: activeCleanupId } = activeMarkerIds( + focusedPinId, + focusedCleanupId, + flyToHighlight, + ) const onRegionChangeRef = React.useRef(onRegionChange) onRegionChangeRef.current = onRegionChange + const onUserCameraMoveRef = React.useRef(onUserCameraMove) + onUserCameraMoveRef.current = onUserCameraMove const onPressMapRef = React.useRef(onPressMap) onPressMapRef.current = onPressMap const onPressPinRef = React.useRef(onPressPin) @@ -220,27 +232,32 @@ export const Map = React.forwardRef(function Map(props, ref node: {want.node}, }) - if (useMapFocus.getState().focus) { - const f = useMapFocus.getState().focus! - if (f.kind === "cleanup") { - put(`e:${f.id}`, { - signature: `${f.eventKind}|1`, + const putTargetMarker = (target: FocusedEntity) => { + if (target.kind === "cleanup") { + put(`e:${target.id}`, { + signature: `${target.eventKind}|1`, anchor: "bottom", - lngLat: [f.lng, f.lat], - node: , - onClick: () => onPressCleanupRef.current?.(f.id), + lngLat: [target.lng, target.lat], + node: , + onClick: () => onPressCleanupRef.current?.(target.id), }) } else { - put(`r:${f.id}`, { - signature: `${f.category}|1`, + put(`r:${target.id}`, { + signature: `${target.category}|1`, anchor: "bottom", - lngLat: [f.lng, f.lat], - node: , - onClick: () => onPressPinRef.current?.(f.id), + lngLat: [target.lng, target.lat], + node: , + onClick: () => onPressPinRef.current?.(target.id), }) } + } + + const focused = useMapFocus.getState().focus + if (focused) { + putTargetMarker(focused) } else { - for (const node of query(mapBoundsToBBox(map), map.getZoom())) { + const nodes = query(mapBoundsToBBox(map), map.getZoom()) + for (const node of nodes) { if (node.type === "cluster") { const tone = clusterToneFor(node.reportCount, node.eventCount) put(node.key, { @@ -251,7 +268,7 @@ export const Map = React.forwardRef(function Map(props, ref onClick: () => pressCluster(node), }) } else if (node.type === "report") { - const active = focusedPinId === node.id + const active = activePinId === node.id put(node.key, { signature: `${node.pin.category}|${active ? 1 : 0}`, anchor: "bottom", @@ -260,7 +277,7 @@ export const Map = React.forwardRef(function Map(props, ref onClick: () => onPressPinRef.current?.(node.id), }) } else if (node.type === "event") { - const active = focusedCleanupId === node.id + const active = activeCleanupId === node.id put(node.key, { signature: `${node.event.eventKind}|${active ? 1 : 0}`, anchor: "bottom", @@ -269,7 +286,7 @@ export const Map = React.forwardRef(function Map(props, ref onClick: () => onPressCleanupRef.current?.(node.id), }) } else { - const active = focusedCleanupId === node.id + const active = activeCleanupId === node.id const event = node.event const blendReports = node.reports put(node.key, { @@ -286,6 +303,8 @@ export const Map = React.forwardRef(function Map(props, ref }) } } + const offMapTarget = flyToTargetOffMap(nodes, flyToHighlight) + if (offMapTarget) putTargetMarker(offMapTarget) } const current = markersRef.current @@ -309,6 +328,7 @@ export const Map = React.forwardRef(function Map(props, ref const onClick: { fn?: () => void } = { fn: want.onClick } el.addEventListener("click", (e: MouseEvent) => { e.stopPropagation() + useMapFlyTo.getState().clear() onClick.fn?.() }) const root = createRoot(el) @@ -372,6 +392,13 @@ export const Map = React.forwardRef(function Map(props, ref syncViewport() }) map.on("moveend", syncViewport) + const endFlyToOnUserGesture = (e: { originalEvent?: unknown }) => { + if (!e.originalEvent) return + useMapFlyTo.getState().clear() + onUserCameraMoveRef.current?.() + } + map.on("movestart", endFlyToOnUserGesture) + map.on("wheel", endFlyToOnUserGesture) const blockedTarget = (target: EventTarget | null): boolean => { if (pickActiveRef.current) return true @@ -587,7 +614,7 @@ export const Map = React.forwardRef(function Map(props, ref React.useEffect(() => { indexRef.current = index if (mapReady) runner.flush() - }, [runner, mapReady, index, points, focusedPinId, focusedCleanupId, focus, th.scheme]) + }, [runner, mapReady, index, points, activePinId, activeCleanupId, flyToHighlight, focus, th.scheme]) React.useEffect(() => { const map = mapRef.current @@ -595,7 +622,18 @@ export const Map = React.forwardRef(function Map(props, ref const lng = mode === "compact" ? focus.lng : occludedCenterLng(focus.lng, shellOcclusionLeft(), FOCUS_ZOOM) map.easeTo({ center: [lng, focus.lat], zoom: FOCUS_ZOOM, duration: 600 }) - }, [mapReady, mode, focus?.id, focus?.lat, focus?.lng]) + }, [mapReady, mode, focus]) + + React.useEffect(() => { + const map = mapRef.current + if (!map || !mapReady || !flyToRequest) return + const lng = + mode === "compact" + ? flyToRequest.lng + : occludedCenterLng(flyToRequest.lng, shellOcclusionLeft(), FOCUS_ZOOM) + map.easeTo({ center: [lng, flyToRequest.lat], zoom: FOCUS_ZOOM, duration: 600 }) + useMapFlyTo.getState().consume(flyToRequest.generation) + }, [mapReady, mode, flyToRequest]) return (
diff --git a/packages/ui/src/map/MiniMap.native.tsx b/packages/ui/src/map/MiniMap.native.tsx deleted file mode 100644 index 91f6ca2c..00000000 --- a/packages/ui/src/map/MiniMap.native.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * MiniMap (native seam) - the small non-interactive detail-location map on native - * (@maplibre/maplibre-react-native v11). Ported from the mobile `components/map/MiniMap`. - * - * A locked CARTO Voyager raster map centered once on lat/lng with the focused teardrop planted on the - * point: the category report teardrop when `category` is given, else the gold cleanup teardrop. All - * gestures (pan/zoom/rotate/pitch) are disabled so the drag stays with the sheet. An optional top-left - * glass tag pill shows `label`; a faint CARTO/OSM credit sits bottom-right. - * - * maplibre-react-native + react-native-svg are allowed here (this is the *.native.* map seam). The - * shared SVG pins keep the marker pixel-identical to the home map. - */ -import React, { useMemo } from "react" -import { View, StyleSheet } from "react-native" -import { Map, Camera, Marker } from "@maplibre/maplibre-react-native" -import type { StyleSpecification } from "@maplibre/maplibre-gl-style-spec" -import { makeThemedStyles, useTheme } from "../theme" -import { Text } from "../typography" -import { useT } from "../i18n" -import { useCartoApiKey } from "../data" -import { rasterMapStyle, DEFAULT_ATTRIBUTION } from "./mapStyle" -import { TeardropPin, EventPin } from "./pins" -import { MINIMAP_HEIGHT, MINIMAP_ZOOM, type MiniMapProps } from "./MiniMap.types" - -export function MiniMap({ lat, lng, category, label, height = MINIMAP_HEIGHT, aspectRatio, zoom = MINIMAP_ZOOM }: MiniMapProps) { - const styles = useStyles() - const scheme = useTheme().scheme - const { t } = useT("map-ui") - // The shared rasterMapStyle is typed structurally (MapStyleInput); the native Map wants the concrete - // StyleSpecification. The object is structurally identical, so cast through (mirrors Map.native). - const cartoApiKey = useCartoApiKey() - const mapStyle = useMemo( - () => rasterMapStyle(DEFAULT_ATTRIBUTION, { cartoApiKey, scheme }) as string | StyleSpecification, - [cartoApiKey, scheme], - ) - const center: [number, number] = [lng, lat] - - return ( - - - - - {category != null ? : } - - - - {label ? ( - - {label} - - ) : null} - - - {t("a11y.attribution")} - - - ) -} - -const useStyles = makeThemedStyles((t) => ({ - // .pi-detail-map: radius lg, overflow hidden, paper-2 while tiles load. - wrap: { - borderRadius: t.radius.lg, - overflow: "hidden", - backgroundColor: t.colors.bgAlt, - borderWidth: StyleSheet.hairlineWidth, - borderColor: t.colors.border, - // The mini-map is purely decorative: disabling MapLibre's gesture props (dragPan/touchZoom/...) - // stops the map panning but the native MapView STILL swallows the touch, so a drag that starts on - // the hero never reaches the sheet's BottomSheetScrollView - the "can't scroll the event detail" - // bug. Make the whole embed touch-transparent so the drag falls straight through to the scroller - // (mirrors the web seam's `interactive: false` inert canvas; the label/credit are already inert). - pointerEvents: "none", - }, - map: { - flex: 1, - }, - // .pi-detail-tag: top-left glass pill, eyebrow text. - tag: { - position: "absolute", - top: t.space["3"], - left: t.space["3"], - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: t.radius.pill, - backgroundColor: t.glass.button.fill, - borderWidth: StyleSheet.hairlineWidth, - borderColor: t.glass.button.border, - pointerEvents: "none", - }, - tagText: { - fontFamily: t.fontFamily.bodyExtraBold, - fontSize: 11, - letterSpacing: 0.5, - color: t.colors.text, - }, - credit: { - position: "absolute", - bottom: 4, - right: 6, - fontFamily: t.fontFamily.bodyRegular, - fontSize: 9, - color: t.colors.textSubtle, - }, -})) diff --git a/packages/ui/src/map/MiniMap.tsx b/packages/ui/src/map/MiniMap.tsx deleted file mode 100644 index 25fec187..00000000 --- a/packages/ui/src/map/MiniMap.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/** - * MiniMap (platform-agnostic default selector) - UI-unification Stage 4 slice 5B-2. - * - * The barrel imports `./MiniMap`. At BUNDLE time the platform resolvers pick a sibling seam by - * extension before this file is ever consulted: - * - Metro (native) resolves `./MiniMap` -> MiniMap.native.tsx (maplibre-react-native). - * - webpack / Next (web) resolves `./MiniMap` -> MiniMap.web.tsx (maplibre-gl + the createRoot pin - * bridge), because next.config prepends `.web.tsx` to resolve.extensions. - * - * This extension-less file is only consulted by tooling with NO platform awareness - chiefly `tsc` - * (typecheck + the .d.ts build) and any plain Node resolver. It re-exports the web seam so that tooling - * gets a concrete, correct implementation + types. Re-exporting the explicit `./MiniMap.web` specifier - * (not the bare `./MiniMap`) avoids resolving back into this same module. (This file does NOT import - * maplibre itself - it only re-binds the web seam's export - so the shared-source import-guard is met; - * mirrors the Map / MediaPreview seam selectors.) - */ -export { MiniMap } from "./MiniMap.web" -export type { MiniMapProps } from "./MiniMap.types" diff --git a/packages/ui/src/map/MiniMap.types.ts b/packages/ui/src/map/MiniMap.types.ts deleted file mode 100644 index bd7c0eca..00000000 --- a/packages/ui/src/map/MiniMap.types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared contract for the MiniMap seam (UI-unification Stage 4 slice 5B-2). - * - * MiniMap is the small, non-interactive map embed at the top of a report / event detail body (design - * `home.jsx` DetailMap + `.pi-detail-map`): the warm CARTO Voyager raster basemap centered on a single - * coordinate, the focused teardrop pin planted on it (a category report teardrop, or - with no category - * - the gold cleanup teardrop), an optional top-left tag pill, and a faint CARTO/OSM credit. Gestures - * are disabled so the drag stays with the sheet; the camera is seeded once from lat/lng. - * - * It is its OWN .web/.native seam (NOT a thin wrapper over the full ): the full Map carries a - * reconciling marker store, the nav-control chrome, and the viewport->bbox callback - none of which a - * static single-pin embed needs. Both seams render the SHARED react-native-svg pins (TeardropPin / - * EventPin), so the marker is pixel-identical to the home map; only the host (a maplibre-gl marker via - * the createRoot pin bridge on web, a maplibre-react-native on native) differs. maplibre stays - * confined to the seam files (the import-guard reserves it for the .web / .native map seams). - */ -import type { CategoryColorKey } from "../theme" - -export interface MiniMapProps { - lat: number - lng: number - /** The teardrop color: a report category (category report teardrop) or, when omitted, the gold cleanup teardrop. */ - category?: CategoryColorKey | string - /** Optional top-left tag pill (e.g. "YOU'RE GOING" / "EVENT" / a category label). */ - label?: string - /** Map height (design `.pi-detail-map` = 150). Ignored when `aspectRatio` is set. */ - height?: number - /** - * Size the map by aspect ratio (width / height) instead of a fixed `height`. Lets a host keep a - * CONSISTENT hero geometry across content variants - e.g. ReportDetailBody, whose hero is a 16:10 - * MediaPreview when the report has media and this map otherwise, passes the same 16/10 here so the hero - * does not jump height between the two. When unset, the fixed `height` is used (the design default). - */ - aspectRatio?: number - /** Camera zoom; the design's static detail map uses 15. */ - zoom?: number -} - -/** The default mini-map height (design `.pi-detail-map`), shared by both seams so the box geometry matches. */ -export const MINIMAP_HEIGHT = 150 -/** The default mini-map camera zoom (the design's static detail map). */ -export const MINIMAP_ZOOM = 15 diff --git a/packages/ui/src/map/MiniMap.web.tsx b/packages/ui/src/map/MiniMap.web.tsx deleted file mode 100644 index a433fe1f..00000000 --- a/packages/ui/src/map/MiniMap.web.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * MiniMap (web seam) - the small non-interactive detail-location map on web (maplibre-gl). - * - * The web counterpart of the mobile MiniMap: a locked CARTO Voyager raster map centered once on lat/lng - * with the focused teardrop planted on the point. Like Map.web, the marker is NOT a bespoke DOM element - * - it is a maplibre `Marker({ element })` whose element is an empty
into which the SHARED - * react-native-svg pin (TeardropPin / EventPin) is mounted via react-dom/client `createRoot` (RNW - * renders the SVG pin to a real ), so the marker is pixel-identical to the native seam + the home - * map. ALL interaction handlers are disabled (interactive: false) so the embed stays inert; an optional - * top-left glass tag pill + a faint CARTO/OSM credit overlay the canvas as plain DOM. - * - * maplibre-gl + react-dom/client are allowed here (this is the *.web.* map seam). The maplibre-gl CSS is - * imported by the web app (globals.css), as for Map.web. - */ -import * as React from "react" -import maplibregl from "maplibre-gl" -import { createRoot, type Root } from "react-dom/client" -import { useTheme, ThemeProvider, type Theme } from "../theme" -import { useT } from "../i18n" -import { useCartoApiKey } from "../data" -import { rasterMapStyle, DEFAULT_ATTRIBUTION } from "./mapStyle" -import { TeardropPin, EventPin } from "./pins" -import { MINIMAP_HEIGHT, MINIMAP_ZOOM, type MiniMapProps } from "./MiniMap.types" - -export function MiniMap({ lat, lng, category, label, height = MINIMAP_HEIGHT, aspectRatio, zoom = MINIMAP_ZOOM }: MiniMapProps) { - const { t } = useT("map-ui") - const th = useTheme() - const styles = React.useMemo(() => makeStyles(th), [th]) - const schemeRef = React.useRef(th.scheme) - schemeRef.current = th.scheme - const cartoApiKey = useCartoApiKey() - const cartoApiKeyRef = React.useRef(cartoApiKey) - cartoApiKeyRef.current = cartoApiKey - const containerRef = React.useRef(null) - const mapRef = React.useRef(null) - const rootRef = React.useRef(null) - - // The pin node (category teardrop, else the gold cleanup teardrop). Kept in a ref so the init effect - // mounts it without re-running, and the sync effect below re-renders it on a category change. - const pinNode = ( - - {category != null ? : } - - ) - const pinNodeRef = React.useRef(pinNode) - pinNodeRef.current = pinNode - - // ---- map init (once) ---- - React.useEffect(() => { - if (mapRef.current || !containerRef.current) return - const map = new maplibregl.Map({ - container: containerRef.current, - style: rasterMapStyle(DEFAULT_ATTRIBUTION, { - cartoApiKey: cartoApiKeyRef.current, - scheme: schemeRef.current, - }) as maplibregl.StyleSpecification, - center: [lng, lat], - zoom, - // Locked: no gestures (the detail body owns the scroll), no controls, no rotation. - interactive: false, - attributionControl: false, - dragRotate: false, - pitchWithRotate: false, - }) - - // The single focused teardrop: an empty div hosting the shared SVG pin via createRoot, anchored at - // its tip on the coordinate. - const el = document.createElement("div") - el.style.lineHeight = "0" - const root = createRoot(el) - root.render(pinNodeRef.current) - rootRef.current = root - new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat([lng, lat]).addTo(map) - - mapRef.current = map - return () => { - map.remove() - mapRef.current = null - rootRef.current = null - // map.remove() tears down the marker's DOM; unmount the React root so the SVG pin is freed. Defer - // the unmount to a microtask: calling root.unmount() synchronously inside an effect cleanup can run - // while React is still rendering (e.g. rapid HMR remounts), which React warns about - deferring it - // lets the current render commit first. The map/marker DOM is already gone, so the timing is safe. - queueMicrotask(() => root.unmount()) - } - // Seeded once from the initial lat/lng/zoom; a parent that changes them remounts via the key it sets. - // The current pin node is read via pinNodeRef, so it is not a dep (the map is created once). - }, []) - - // ---- re-render the pin if the category (its color/glyph) changes without a remount ---- - // pinNode is recomputed each render from `category`; on a category change re-render the mounted root - // with the latest pin (pinNode itself is not a dep - it is a fresh element identity every render). - React.useEffect(() => { - rootRef.current?.render(pinNodeRef.current) - }, [category, th.scheme]) - - return ( -
-
- {label ?
{label}
: null} -
{t("a11y.attribution")}
-
- ) -} - -// Plain DOM styles (the web seam renders DOM, not RN primitives - mirrors Map.web's container). -function makeStyles(t: Theme): Record { - return { - // .pi-detail-map: radius lg, overflow hidden, paper-2 while tiles load. - wrap: { - position: "relative", - width: "100%", - borderRadius: t.radius.lg, - overflow: "hidden", - backgroundColor: t.colors.bgAlt, - border: `1px solid ${t.colors.border}`, - }, - canvas: { - width: "100%", - height: "100%", - }, - // .pi-detail-tag: top-left glass pill. - tag: { - position: "absolute", - top: t.space["3"], - left: t.space["3"], - padding: "5px 10px", - borderRadius: t.radius.pill, - backgroundColor: t.glass.button.fill, - border: `1px solid ${t.glass.button.border}`, - font: `800 11px/1 ${t.fontFamily.bodyExtraBold}, system-ui, sans-serif`, - letterSpacing: 0.5, - color: t.colors.text, - pointerEvents: "none", - }, - credit: { - position: "absolute", - bottom: 4, - right: 6, - font: "400 9px/1 system-ui, sans-serif", - color: t.colors.textSubtle, - pointerEvents: "none", - }, - } -} diff --git a/packages/ui/src/map/PortraitMapPickStep.tsx b/packages/ui/src/map/PortraitMapPickStep.tsx index 692db763..fb8155f5 100644 --- a/packages/ui/src/map/PortraitMapPickStep.tsx +++ b/packages/ui/src/map/PortraitMapPickStep.tsx @@ -11,7 +11,7 @@ * re-exports the web seam so tooling gets a concrete implementation + types. Re-exporting the explicit * `./PortraitMapPickStep.web` specifier (not the bare path) avoids resolving back into this module. (No * web-only import here - it only re-binds the web seam's export - so the shared-source import-guard is met; - * mirrors the LocationPicker / MiniMap seam selectors.) + * mirrors the LocationPicker seam selector.) */ export { PortraitMapPickStep } from "./PortraitMapPickStep.web" export type { PortraitMapPickStepProps } from "./PortraitMapPickStep.types" diff --git a/packages/ui/src/map/__tests__/mapCameraContract.test.ts b/packages/ui/src/map/__tests__/mapCameraContract.test.ts index 3fb04ea6..f2b9e766 100644 --- a/packages/ui/src/map/__tests__/mapCameraContract.test.ts +++ b/packages/ui/src/map/__tests__/mapCameraContract.test.ts @@ -5,6 +5,7 @@ const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8" const strip = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "") const mapNative = strip(read("../Map.native.tsx")) +const mapWeb = strip(read("../Map.web.tsx")) describe("MapHandle.flyTo on the native seam", () => { it("treats DEFAULT_ZOOM as a FLOOR, not a target, when the caller passes no zoom", () => { @@ -19,3 +20,85 @@ describe("MapHandle.flyTo on the native seam", () => { expect(mapNative).toContain("Math.max(lastRegionRef.current?.zoom ?? DEFAULT_ZOOM, DEFAULT_ZOOM)") }) }) + +describe("the focus ease on both seams", () => { + it("keys on the focus object, so re-publishing the same pin eases back to it", () => { + expect(mapWeb).toContain("}, [mapReady, mode, focus])") + expect(mapNative).toContain("}, [focus])") + expect(mapWeb).not.toContain("focus?.id, focus?.lat") + expect(mapNative).not.toContain("focus?.id, focus?.lat") + }) +}) + +describe("the Show on map fly-to on both seams", () => { + it("never gates the marker tree on the fly-to request: only a focus narrows the map to one pin", () => { + expect(mapNative).toContain("{focus ? (") + expect(mapNative).not.toMatch(/flyTo(Request|Highlight) \?/) + expect(mapNative).toContain("if (focus) return [{ lat: focus.lat, lng: focus.lng }]") + expect(mapWeb).toContain("const focused = useMapFocus.getState().focus\n if (focused) {") + expect(mapWeb).not.toMatch(/useMapFlyTo\.getState\(\)\.(request|highlight)\) \{/) + expect(mapWeb).toContain("const nodes = query(mapBoundsToBBox(map), map.getZoom())\n for (const node of nodes) {") + }) + + it("lights the fly-to pin through the per-node active path", () => { + expect(mapNative).toContain("activeMarkerIds(focusedPinId, focusedCleanupId, flyToHighlight)") + expect(mapNative).toContain("active={markerNodeIsActive(node, activeIds.pinId, activeIds.cleanupId)}") + expect(mapWeb).toContain("activeMarkerIds(\n focusedPinId,\n focusedCleanupId,\n flyToHighlight,\n )") + expect(mapWeb).toContain("const active = activePinId === node.id") + expect(mapWeb).toContain("}, [runner, mapReady, index, points, activePinId, activeCleanupId, flyToHighlight, focus, th.scheme])") + }) + + it("eases to the request at the focus zoom once the map is ready, then consumes it", () => { + expect(mapNative).toContain("if (!flyToRequest || !mapLoaded) return") + expect(mapNative).toContain("useMapFlyTo.getState().consume(flyToRequest.generation)") + expect(mapNative).toContain("}, [flyToRequest, mapLoaded])") + expect(mapWeb).toContain("if (!map || !mapReady || !flyToRequest) return") + expect(mapWeb).toContain("map.easeTo({ center: [lng, flyToRequest.lat], zoom: FOCUS_ZOOM, duration: 600 })") + expect(mapWeb).toContain("useMapFlyTo.getState().consume(flyToRequest.generation)") + }) + + it("ends the highlight on a user camera gesture or a marker tap", () => { + expect(mapNative).toMatch(/if \(!event\.nativeEvent\.userInteraction\) return\n\s+useMapFlyTo\.getState\(\)\.clear\(\)/) + expect(mapNative.match(/markerPressedAtRef\.current = Date\.now\(\)\n\s+useMapFlyTo\.getState\(\)\.clear\(\)/g)).toHaveLength(4) + expect(mapWeb).toMatch(/if \(!e\.originalEvent\) return\n\s+useMapFlyTo\.getState\(\)\.clear\(\)\n\s+onUserCameraMoveRef\.current\?\.\(\)/) + expect(mapWeb).toContain('map.on("movestart", endFlyToOnUserGesture)') + expect(mapWeb).toMatch(/e\.stopPropagation\(\)\n\s+useMapFlyTo\.getState\(\)\.clear\(\)\n\s+onClick\.fn\?\.\(\)/) + }) +}) + +describe("a Show on map target the map has not loaded", () => { + it("draws one standalone target marker only when the id is not among the rendered nodes", () => { + expect(mapNative).toContain("() => (focus ? null : flyToTargetOffMap(nodes, flyToHighlight))") + expect(mapNative).toMatch(/\{offMapTarget \? \(\n\s+ { + const nativeTree = /<>\n[\s\S]*?<\/>/.exec(mapNative)?.[0] ?? "" + expect(nativeTree).toContain("markerNodes.rendered.map(") + expect(nativeTree).toContain("{offMapTarget ? (") + const webElse = /\} else \{\n\s+const nodes = query[\s\S]*?if \(offMapTarget\) putTargetMarker\(offMapTarget\)/.exec(mapWeb)?.[0] ?? "" + expect(webElse).toContain("for (const node of nodes) {") + }) + + it("draws it with the focus marker, so it opens the detail like any pin", () => { + expect(mapNative.match(/ onPressCleanupRef.current?.(target.id),") + expect(mapWeb).toContain("onClick: () => onPressPinRef.current?.(target.id),") + }) + + it("keeps a native long press on the standalone marker from dropping a pin", () => { + expect(mapNative).toContain( + 'if (offMapTarget) markers.push({ lat: offMapTarget.lat, lng: offMapTarget.lng, anchor: "bottom" })', + ) + }) +}) + +describe("the web user-gesture signal", () => { + it("also treats a wheel zoom as a user gesture, since a single-notch scroll zoom starts with no originalEvent", () => { + expect(mapWeb).toContain('map.on("wheel", endFlyToOnUserGesture)') + expect(mapWeb).toContain("const endFlyToOnUserGesture = (e: { originalEvent?: unknown }) => {\n if (!e.originalEvent) return") + }) +}) diff --git a/packages/ui/src/map/__tests__/mapCenterModel.test.ts b/packages/ui/src/map/__tests__/mapCenterModel.test.ts index 1dcf8b07..a5216e2e 100644 --- a/packages/ui/src/map/__tests__/mapCenterModel.test.ts +++ b/packages/ui/src/map/__tests__/mapCenterModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { APPROX_ZOOM, PRECISE_ZOOM, + holdsRememberedCamera, isRememberedCenter, resolveMapCenter, shouldAdoptCenter, @@ -146,3 +147,16 @@ describe("the remembered-centre validator both hosts persist through", () => { } }) }) + +describe("holdsRememberedCamera", () => { + it("holds a remembered boot camera against a silent grant, and releases it for a prompted one", () => { + expect(holdsRememberedCamera("remembered", false)).toBe(true) + expect(holdsRememberedCamera("remembered", true)).toBe(false) + }) + + it("never holds a camera that did not boot from the snapshot", () => { + expect(holdsRememberedCamera("approximate", false)).toBe(false) + expect(holdsRememberedCamera("precise", false)).toBe(false) + expect(holdsRememberedCamera(null, false)).toBe(false) + }) +}) diff --git a/packages/ui/src/map/__tests__/mapFlyToStore.test.ts b/packages/ui/src/map/__tests__/mapFlyToStore.test.ts new file mode 100644 index 00000000..490e014d --- /dev/null +++ b/packages/ui/src/map/__tests__/mapFlyToStore.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { useMapFlyTo } from "../mapFlyToStore" + +const target = { kind: "cleanup" as const, id: "e1", lat: 34.05, lng: -118.24, eventKind: "cleanup" as const } +const reportTarget = { kind: "report" as const, id: "r1", lat: 34.06, lng: -118.25, category: "hazard" as const } + +beforeEach(() => { + useMapFlyTo.setState({ request: null, highlight: null }) +}) + +describe("mapFlyToStore", () => { + it("starts idle", () => { + expect(useMapFlyTo.getState().request).toBeNull() + expect(useMapFlyTo.getState().highlight).toBeNull() + }) + + it("publishes a request and a highlight for the same pin", () => { + useMapFlyTo.getState().requestFlyTo(target) + expect(useMapFlyTo.getState().request).toMatchObject(target) + expect(useMapFlyTo.getState().highlight).toEqual(target) + }) + + it("carries the pin styling on both, so the map can draw a target it has not loaded", () => { + useMapFlyTo.getState().requestFlyTo(target) + expect(useMapFlyTo.getState().request).toMatchObject({ eventKind: "cleanup" }) + expect(useMapFlyTo.getState().highlight).toMatchObject({ eventKind: "cleanup", lat: 34.05, lng: -118.24 }) + useMapFlyTo.getState().requestFlyTo(reportTarget) + expect(useMapFlyTo.getState().request).toMatchObject({ category: "hazard" }) + expect(useMapFlyTo.getState().highlight).toEqual(reportTarget) + }) + + it("consume drops the request once and keeps the highlight, so the camera never replays", () => { + useMapFlyTo.getState().requestFlyTo(target) + const generation = useMapFlyTo.getState().request!.generation + useMapFlyTo.getState().consume(generation) + expect(useMapFlyTo.getState().request).toBeNull() + expect(useMapFlyTo.getState().highlight).toEqual(target) + let notifications = 0 + const unsubscribe = useMapFlyTo.subscribe(() => { + notifications += 1 + }) + useMapFlyTo.getState().consume(generation) + expect(notifications).toBe(0) + unsubscribe() + }) + + it("a stale consume cannot swallow a newer request", () => { + useMapFlyTo.getState().requestFlyTo(target) + const first = useMapFlyTo.getState().request!.generation + useMapFlyTo.getState().requestFlyTo({ ...target, id: "e2" }) + useMapFlyTo.getState().consume(first) + expect(useMapFlyTo.getState().request).toMatchObject({ id: "e2" }) + expect(useMapFlyTo.getState().request!.generation).toBeGreaterThan(first) + }) + + it("re-requesting the same pin is a fresh request", () => { + useMapFlyTo.getState().requestFlyTo(target) + const first = useMapFlyTo.getState().request + useMapFlyTo.getState().consume(first!.generation) + useMapFlyTo.getState().requestFlyTo(target) + expect(useMapFlyTo.getState().request).not.toBeNull() + expect(useMapFlyTo.getState().request).not.toBe(first) + }) + + it("clear ends the highlight and any pending request, and is silent when idle", () => { + useMapFlyTo.getState().requestFlyTo(target) + useMapFlyTo.getState().clear() + expect(useMapFlyTo.getState().request).toBeNull() + expect(useMapFlyTo.getState().highlight).toBeNull() + let notifications = 0 + const unsubscribe = useMapFlyTo.subscribe(() => { + notifications += 1 + }) + useMapFlyTo.getState().clear() + expect(notifications).toBe(0) + unsubscribe() + }) +}) diff --git a/packages/ui/src/map/__tests__/mapFocusStore.test.ts b/packages/ui/src/map/__tests__/mapFocusStore.test.ts index 40a7a0c2..7eb06b21 100644 --- a/packages/ui/src/map/__tests__/mapFocusStore.test.ts +++ b/packages/ui/src/map/__tests__/mapFocusStore.test.ts @@ -158,3 +158,15 @@ describe("mapFocusStore: the retained-layer contract, at its two call sites", () expect(src).toContain("useMapFocus.getState().clear()") }) }) + +describe("mapFocusStore: re-publish", () => { + it("every publish is a fresh object, so a re-publish of the same pin still notifies the map", () => { + const input = { id: "r1", lat: 34.05, lng: -118.24, category: "hazard" as const } + useMapFocus.getState().setReport(input) + const a = useMapFocus.getState().focus + useMapFocus.getState().setReport(input) + const b = useMapFocus.getState().focus + expect(a).not.toBe(b) + expect(a).toEqual(b) + }) +}) diff --git a/packages/ui/src/map/__tests__/markerFocus.test.ts b/packages/ui/src/map/__tests__/markerFocus.test.ts index e07cfdb3..5ae6a078 100644 --- a/packages/ui/src/map/__tests__/markerFocus.test.ts +++ b/packages/ui/src/map/__tests__/markerFocus.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs" import { describe, expect, it } from "vitest" -import { markerNodeIsActive } from "../markerFocus" +import { activeMarkerIds, flyToTargetOffMap, markerNodeIsActive } from "../markerFocus" import type { ClusterNode } from "../clusterer" const mapNative = readFileSync(new URL("../Map.native.tsx", import.meta.url), "utf8") @@ -9,6 +9,8 @@ const reportNode = { type: "report", id: "r1", key: "r:r1", lat: 0, lng: 0 } as const eventNode = { type: "event", id: "c1", key: "e:c1", lat: 0, lng: 0 } as unknown as ClusterNode const blendNode = { type: "blend", id: "c1", key: "b:c1", lat: 0, lng: 0 } as unknown as ClusterNode const clusterNode = { type: "cluster", key: "cl:1", lat: 0, lng: 0 } as unknown as ClusterNode +const reportTarget = { kind: "report" as const, id: "r1", lat: 1, lng: 2, category: "hazard" as const } +const eventTarget = { kind: "cleanup" as const, id: "c1", lat: 3, lng: 4, eventKind: "cleanup" as const } describe("markerNodeIsActive", () => { it("lights the focused report and nothing else", () => { @@ -33,12 +35,51 @@ describe("markerNodeIsActive", () => { }) }) +describe("activeMarkerIds", () => { + it("lights the fly-to pin when the host has no focused marker", () => { + expect(activeMarkerIds(null, null, reportTarget)).toEqual({ pinId: "r1", cleanupId: null }) + expect(activeMarkerIds(null, null, eventTarget)).toEqual({ pinId: null, cleanupId: "c1" }) + }) + + it("lets the host's own focused marker win", () => { + expect(activeMarkerIds("r2", null, reportTarget)).toEqual({ pinId: "r2", cleanupId: null }) + expect(activeMarkerIds(null, "c2", eventTarget)).toEqual({ pinId: null, cleanupId: "c2" }) + }) + + it("lights nothing without a highlight or a focus", () => { + expect(activeMarkerIds(null, null, null)).toEqual({ pinId: null, cleanupId: null }) + }) +}) + +describe("flyToTargetOffMap", () => { + it("returns the target when no rendered node carries it, so the seam draws it standalone", () => { + expect(flyToTargetOffMap([], eventTarget)).toBe(eventTarget) + expect(flyToTargetOffMap([reportNode, clusterNode], eventTarget)).toBe(eventTarget) + expect(flyToTargetOffMap([eventNode], reportTarget)).toBe(reportTarget) + }) + + it("returns null when the node is rendered, so the per-node highlight is the only marker", () => { + expect(flyToTargetOffMap([reportNode], reportTarget)).toBeNull() + expect(flyToTargetOffMap([clusterNode, eventNode], eventTarget)).toBeNull() + expect(flyToTargetOffMap([blendNode], eventTarget)).toBeNull() + }) + + it("does not match across kinds on a shared id", () => { + const sameIdEvent = { ...eventTarget, id: "r1" } + expect(flyToTargetOffMap([reportNode], sameIdEvent)).toBe(sameIdEvent) + }) + + it("draws nothing without a highlight", () => { + expect(flyToTargetOffMap([reportNode], null)).toBeNull() + }) +}) + describe("a focus change must not re-render every marker", () => { it("renders each marker through one memoized component, not inline JSX", () => { expect(mapNative).toContain("const MarkerNode = memo(function MarkerNode(") expect(mapNative).toContain(" 0 ? view : null, + }) +} + +beforeEach(() => { + disarmMapLeaveRelease() + useMapFocus.setState({ focus: null }) + useMapFlyTo.setState({ request: null, highlight: null }) +}) + +afterEach(() => { + disarmMapLeaveRelease() +}) + +describe("showOnMap: compact", () => { + beforeEach(() => { + seedNav("home", [{ kind: "cleanup", id: "e1" }], "compact") + }) + + it("publishes a fly-to request, not a focus, and switches to the Map tab", () => { + showOnMap("compact", event) + expect(useMapFlyTo.getState().request).toMatchObject(event) + expect(useMapFlyTo.getState().highlight).toEqual(event) + expect(useMapFocus.getState().focus).toBeNull() + const nav = useNavStore.getState() + expect(nav.view).toBe("map") + expect(nav.stack).toEqual([]) + }) + + it("publishes the request BEFORE the view flips, so a map mounting on the switch sees it", () => { + let requestAtSwitch: unknown = "unset" + const unsubscribe = useNavStore.subscribe((state, previous) => { + if (state.view === "map" && previous.view !== "map") requestAtSwitch = useMapFlyTo.getState().request + }) + showOnMap("compact", report) + unsubscribe() + expect(requestAtSwitch).toMatchObject({ kind: "report", id: "r1" }) + }) + + it("the departing page's scoped release cannot touch the handoff", () => { + showOnMap("compact", event) + useMapFocus.getState().clearFor("e1") + expect(useMapFlyTo.getState().highlight).toEqual(event) + }) + + it("leaving the Map tab ends the highlight", () => { + showOnMap("compact", event) + useNavStore.getState().openDetail({ kind: "pin", id: "other" }) + expect(useMapFlyTo.getState().highlight).toEqual(event) + useNavStore.getState().selectView("home") + expect(useMapFlyTo.getState().highlight).toBeNull() + expect(useMapFlyTo.getState().request).toBeNull() + }) + + it("the leave release fires once, then a later visit keeps its own highlight", () => { + showOnMap("compact", event) + useNavStore.getState().selectView("home") + useMapFlyTo.getState().requestFlyTo(report) + useNavStore.getState().selectView("events") + expect(useMapFlyTo.getState().highlight).toEqual(report) + }) +}) + +describe("showOnMap: expanded", () => { + beforeEach(() => { + seedNav("home", [{ kind: "pin", id: "r1" }], "expanded") + }) + + it("keeps the rail open with a page-owned focus and no fly-to request", () => { + showOnMap("expanded", report) + expect(useMapFocus.getState().focus).toEqual(report) + expect(useMapFlyTo.getState().request).toBeNull() + const nav = useNavStore.getState() + expect(nav.view).toBe("home") + expect(nav.stack).toEqual([{ kind: "pin", id: "r1" }]) + expect(nav.snap).toBe(1) + }) + + it("the page's own release still clears its focus", () => { + showOnMap("expanded", report) + useMapFocus.getState().clearFor("r1") + expect(useMapFocus.getState().focus).toBeNull() + }) +}) diff --git a/packages/ui/src/map/index.ts b/packages/ui/src/map/index.ts index 7cefebd4..4e6217e2 100644 --- a/packages/ui/src/map/index.ts +++ b/packages/ui/src/map/index.ts @@ -50,8 +50,6 @@ export type { LayersPopoverProps } from "./LayersPopover" export { useReportFilterStore, enabledCategoriesArray, FILTER_CATEGORIES } from "./filterStore" export type { ReportFilterState } from "./filterStore" -export { MiniMap } from "./MiniMap" -export type { MiniMapProps } from "./MiniMap.types" export { LocationPicker } from "./LocationPicker" export type { LocationPickerProps } from "./LocationPicker.types" @@ -75,6 +73,10 @@ export type { LocationPickState, PickDraft } from "./locationPickStore" export { useMapFocus } from "./mapFocusStore" export type { MapFocusState, FocusedReport, FocusedEvent, FocusedEntity } from "./mapFocusStore" +export { useMapFlyTo } from "./mapFlyToStore" +export type { MapFlyToState, MapFlyToTarget, MapFlyToRequest, MapFlyToHighlight } from "./mapFlyToStore" +export { showOnMap } from "./showOnMapFlow" + export { useMapViewport } from "./mapViewportStore" export type { MapViewportState, MapViewport } from "./mapViewportStore" @@ -108,6 +110,7 @@ export type { DropPinCameraRestorer } from "./dropPinFlow" export { resolveMapCenter, shouldAdoptCenter, + holdsRememberedCamera, isRememberedCenter, zoomForSource, PRECISE_ZOOM, diff --git a/packages/ui/src/map/mapCenterModel.ts b/packages/ui/src/map/mapCenterModel.ts index a7577258..0054a656 100644 --- a/packages/ui/src/map/mapCenterModel.ts +++ b/packages/ui/src/map/mapCenterModel.ts @@ -91,6 +91,13 @@ export function shouldAdoptCenter( return SOURCE_RANK[next] > SOURCE_RANK[current] } +export function holdsRememberedCamera( + seedSource: MapCenterSource | null, + grantedByPrompt: boolean, +): boolean { + return seedSource === "remembered" && !grantedByPrompt +} + export function zoomForSource(source: MapCenterSource): number { return source === "precise" ? PRECISE_ZOOM : APPROX_ZOOM } diff --git a/packages/ui/src/map/mapFlyToStore.ts b/packages/ui/src/map/mapFlyToStore.ts new file mode 100644 index 00000000..373f5b45 --- /dev/null +++ b/packages/ui/src/map/mapFlyToStore.ts @@ -0,0 +1,37 @@ +import { create } from "zustand" +import type { FocusedEntity } from "./mapFocusStore" + +export type MapFlyToTarget = FocusedEntity + +export type MapFlyToRequest = MapFlyToTarget & { generation: number } + +export type MapFlyToHighlight = MapFlyToTarget + +export interface MapFlyToState { + request: MapFlyToRequest | null + highlight: MapFlyToHighlight | null + requestFlyTo: (target: MapFlyToTarget) => void + consume: (generation: number) => void + clear: () => void +} + +let lastGeneration = 0 + +export const useMapFlyTo = create((set, get) => ({ + request: null, + highlight: null, + requestFlyTo: (target) => { + lastGeneration += 1 + set({ + request: { ...target, generation: lastGeneration }, + highlight: target, + }) + }, + consume: (generation) => { + if (get().request?.generation === generation) set({ request: null }) + }, + clear: () => { + const state = get() + if (state.request !== null || state.highlight !== null) set({ request: null, highlight: null }) + }, +})) diff --git a/packages/ui/src/map/markerFocus.ts b/packages/ui/src/map/markerFocus.ts index 92fdf09d..c52b95f6 100644 --- a/packages/ui/src/map/markerFocus.ts +++ b/packages/ui/src/map/markerFocus.ts @@ -1,4 +1,5 @@ import type { ClusterNode } from "./clusterer" +import type { MapFlyToHighlight } from "./mapFlyToStore" export function markerNodeIsActive( node: ClusterNode, @@ -9,3 +10,24 @@ export function markerNodeIsActive( if (node.type === "report") return focusedPinId !== null && focusedPinId === node.id return focusedCleanupId !== null && focusedCleanupId === node.id } + +export function activeMarkerIds( + focusedPinId: string | null, + focusedCleanupId: string | null, + highlight: MapFlyToHighlight | null, +): { pinId: string | null; cleanupId: string | null } { + return { + pinId: focusedPinId ?? (highlight?.kind === "report" ? highlight.id : null), + cleanupId: focusedCleanupId ?? (highlight?.kind === "cleanup" ? highlight.id : null), + } +} + +export function flyToTargetOffMap( + nodes: readonly ClusterNode[], + highlight: MapFlyToHighlight | null, +): MapFlyToHighlight | null { + if (!highlight) return null + const pinId = highlight.kind === "report" ? highlight.id : null + const cleanupId = highlight.kind === "cleanup" ? highlight.id : null + return nodes.some((node) => markerNodeIsActive(node, pinId, cleanupId)) ? null : highlight +} diff --git a/packages/ui/src/map/showOnMapFlow.ts b/packages/ui/src/map/showOnMapFlow.ts new file mode 100644 index 00000000..a3269d9f --- /dev/null +++ b/packages/ui/src/map/showOnMapFlow.ts @@ -0,0 +1,37 @@ +import type { LayoutMode } from "../theme" +import { useNavStore } from "../nav" +import { useMapFocus, type FocusedEntity } from "./mapFocusStore" +import { useMapFlyTo } from "./mapFlyToStore" + +let unsubscribeMapLeave: (() => void) | null = null + +function armMapLeaveRelease(): void { + if (unsubscribeMapLeave) return + unsubscribeMapLeave = useNavStore.subscribe((state) => { + if (state.view === "map") return + disarmMapLeaveRelease() + useMapFlyTo.getState().clear() + }) +} + +export function disarmMapLeaveRelease(): void { + const unsubscribe = unsubscribeMapLeave + unsubscribeMapLeave = null + unsubscribe?.() +} + +export function showOnMap(mode: LayoutMode, target: FocusedEntity): void { + if (mode === "expanded") { + const focus = useMapFocus.getState() + if (target.kind === "report") { + focus.setReport({ id: target.id, lat: target.lat, lng: target.lng, category: target.category }) + } else { + focus.setEvent({ id: target.id, lat: target.lat, lng: target.lng, eventKind: target.eventKind }) + } + useNavStore.getState().setSnap(1) + return + } + useMapFlyTo.getState().requestFlyTo(target) + useNavStore.getState().selectView("map") + armMapLeaveRelease() +} diff --git a/packages/ui/src/map/types.ts b/packages/ui/src/map/types.ts index 7d4c0aaf..0cccbd92 100644 --- a/packages/ui/src/map/types.ts +++ b/packages/ui/src/map/types.ts @@ -68,6 +68,7 @@ export interface MapProps { * the Map reclusters locally on zoom with no network, which is what keeps zoom/merge smooth. */ onRegionChange?: (bbox: BBox, zoom: number) => void + onUserCameraMove?: () => void /** A report pin was tapped (the app opens the full report detail). */ onPressPin?: (id: string) => void /** diff --git a/packages/ui/src/nav/useNavStore.ts b/packages/ui/src/nav/useNavStore.ts index 54d0d11b..293625d8 100644 --- a/packages/ui/src/nav/useNavStore.ts +++ b/packages/ui/src/nav/useNavStore.ts @@ -12,7 +12,12 @@ import { type NavMode = "compact" | "expanded" -const PAGE_SEED_KINDS: ReadonlySet = new Set(["cleanup", "pin", "post"]) +const PAGE_SEED_KINDS: ReadonlySet = new Set([ + "cleanup", + "pin", + "post", + "post-thread", +]) export interface NavStore extends NavState { mode: NavMode diff --git a/packages/ui/src/share/__tests__/sharePostWiring.test.ts b/packages/ui/src/share/__tests__/sharePostWiring.test.ts index 42439834..27eda1d9 100644 --- a/packages/ui/src/share/__tests__/sharePostWiring.test.ts +++ b/packages/ui/src/share/__tests__/sharePostWiring.test.ts @@ -20,6 +20,7 @@ const SHELL = code(read("../../shell/AppShell.tsx")) const BUBBLE = code(read("../../bodies/conversation/MessageBubble.tsx")) const MOBILE_LAYOUT = code(read("../../../../../apps/community-mobile/app/_layout.tsx")) const WEB_PROVIDERS = code(read("../../../../../apps/community-web/src/components/providers.tsx")) +const WEB_NAV_ADAPTER = code(read("../../../../../apps/community-web/src/components/home/use-web-nav-adapter.ts")) describe("the share glyph opens the sheet, and the primitives layer stays independent of it", () => { it("routes PostActionBar's onShare through useSharePost, not straight at shareLink", () => { @@ -349,11 +350,13 @@ describe("both hosts inject openInternalHref", () => { expect(MOBILE_LAYOUT).toContain("") }) - it("web pushes the parsed entry, because its shell persists across a push", () => { - expect(WEB_PROVIDERS).toMatch(/openInternalHref: \{\s*open: \(path: string\): boolean => \{/) - expect(WEB_PROVIDERS).toContain("const entry = entryFromPath(path)") - expect(WEB_PROVIDERS).toContain("useNavStore.getState().push(entry)") - expect(WEB_PROVIDERS).toContain("if (!entry) return false") + it("web pushes the entry its own address map parses, because its shell persists across a push", () => { + expect(WEB_PROVIDERS).toContain("openInternalHref: webOpenInternalHref,") + expect(WEB_NAV_ADAPTER).toMatch(/export const webOpenInternalHref: OpenInternalHrefCapability = \{/) + expect(WEB_NAV_ADAPTER).toContain("entryFor: entryFromWebPath,") + expect(WEB_NAV_ADAPTER).toContain("const entry = entryFromWebPath(path)") + expect(WEB_NAV_ADAPTER).toContain("useNavStore.getState().push(entry)") + expect(WEB_NAV_ADAPTER).toContain("if (!entry) return false") }) }) diff --git a/packages/ui/src/shell/PageStack.types.ts b/packages/ui/src/shell/PageStack.types.ts index 796302e4..52953b3d 100644 --- a/packages/ui/src/shell/PageStack.types.ts +++ b/packages/ui/src/shell/PageStack.types.ts @@ -1,23 +1,14 @@ /** * Shared props for the portrait shell's OVERLAY (full-page) host. * - * ONE CHOKE POINT, TWO PRESENTATIONS. Every shell-owned page on native - all ~24 converted details plus - * the four bodies that draw their own header - is mounted by this host and by nothing else, which is why - * swipe-back and push/pop animation land here once instead of per body. The two seams are genuinely - * different components, not one component with a flag: + * ONE CHOKE POINT, TWO PRESENTATIONS. Every shell-owned page - all ~24 converted details plus the four + * bodies that draw their own header - is mounted by this host and by nothing else, which is why push/pop + * animation lands here once instead of per body. Both seams keep N retained, keyed layers from + * `entries`/`layerKeys` and slide whole pages on push/pop; they differ in the driver: * - * PageStack.web ONE body, no gesture, no layering: the exact markup PortraitShell.shared carried - * before the seam existed (KeyboardAvoidingView -> shell DetailHeader -> ScrollHost -> - * BodyTransition), consuming `entry`/`transitionKey`/`direction`. Web presents details - * as a pull-up sheet and reaches this layer only for the handful of table-"full" kinds, - * so there is no page stack to animate and nothing to change. - * PageStack.native N retained, keyed layers from `entries`/`layerKeys`, an interactive left-edge back - * swipe, and full-width push/pop slides. - * - * BOTH SHAPES ARE CARRIED because both are correct for their seam - `entry` is not a derived convenience - * that native could recompute, it is what `portraitFramePlan` already published and what web still needs - * unchanged. `entry === entries[entries.length - 1]` by construction (`portraitFramePlan` derives both - * from one `fullEntryStack` scan). + * PageStack.web CSS transitions on transform/opacity, direction from `useStackDirection`, no + * gesture (the browser owns back-swipe), instant on a history restore. + * PageStack.native reanimated shared values plus an interactive left-edge back swipe. */ import type { DetailEntry, View as NavView } from "../nav" import type { BodyTransitionDirection } from "./BodyTransition.types" @@ -27,18 +18,12 @@ import type { ScrollHostValue } from "./ScrollHost" export type PageStackRenderBody = (entry: DetailEntry | null, view: NavView) => React.ReactNode export interface PageStackProps { - /** WEB: the single overlay body (`frame.overlay.entry`). Null when no page is up. */ - entry: DetailEntry | null - /** WEB: `frame.overlay.transitionKey` - the BodyTransition identity. */ - transitionKey: string - /** WEB: the BodyTransition direction (`useStackDirection(stack.length)`). */ + /** WEB: the push/pop/replace direction of the latest change (`useStackDirection(stack.length)`). */ direction: BodyTransitionDirection - /** NATIVE: the whole page stack, BOTTOM first (`frame.overlay.entries`). */ + /** The whole page stack, BOTTOM first (`frame.overlay.entries`). */ entries: readonly DetailEntry[] - /** NATIVE: `frame.overlay.layerKeys` - the stable React key of each layer, parallel to `entries`. */ + /** `frame.overlay.layerKeys` - the stable React key of each layer, parallel to `entries`. */ layerKeys: readonly string[] - /** `frame.overlay.bodyMounted` - is there a page at all? */ - bodyMounted: boolean /** `frame.overlay.interactive` - false while a sheet rides above a still-mounted page. */ interactive: boolean /** `frame.overlay.keyboardAvoidance` - the composer's shell-level keyboard inset. */ @@ -46,11 +31,11 @@ export interface PageStackProps { /** WEB only: the measured soft-keyboard reserve, or null. Native gets it from KeyboardAvoidingView. */ webKeyboardInset: { paddingBottom: number } | null /** - * The safe-area padding a page wears. Applied INSIDE this host (per layer on native, on the host's own - * box on web) rather than on the shell's overlay wrapper: a native layer must cover the full screen - + * The safe-area padding a page wears. Applied INSIDE this host, per layer, rather than on the shell's + * overlay wrapper: a layer must cover the full screen - * background and all - while it slides, and an absolutely-positioned child of a PADDED box is laid out - * against the padding edge, which would leave the status-bar strip showing whatever is behind. On - * native the BOTTOM half is routed per page (`pageBottomReserve`): a page whose body owns its scroll + * against the padding edge, which would leave the status-bar strip showing whatever is behind. The + * BOTTOM half is routed per page (`pageBottomReserve`): a page whose body owns its scroll * wears it as scroll-CONTENT padding, so its list runs under the home indicator instead of being cut * off above a strip of layer background; only a footer-pinning page keeps it on the box. */ diff --git a/packages/ui/src/shell/PageStack.web.tsx b/packages/ui/src/shell/PageStack.web.tsx index 34b6109a..b4f477ad 100644 --- a/packages/ui/src/shell/PageStack.web.tsx +++ b/packages/ui/src/shell/PageStack.web.tsx @@ -1,86 +1,311 @@ -/** - * PageStack (WEB seam) - the portrait overlay layer's host, VERBATIM as PortraitShell.shared carried it. - * - * This file is a MOVE, not a rewrite, and it is meant to stay one. Web presents details as the pull-up - * sheet (`detailPresentationPlatform` -> DETAILS_ARE_FULL_PAGE false), so this layer only ever holds the - * handful of table-"full" kinds - one at a time, with nothing beneath to reveal and no edge-swipe - * vocabulary on a desktop pointer. There is no page stack here to animate, so the correct web change was - * none: same two-box padding structure (so the overlay inset and the keyboard inset still SUM as nested - * boxes rather than overriding each other), same `hasDetailHeader` gate, same `dismissGesture={false}`, - * same ScrollHostProvider, same BodyTransition. - * - * `entries`/`layerKeys` are accepted and ignored: they are PageStack.native's inputs, and the seam splits - * on the file extension rather than on a runtime flag so the web bundle never pulls in reanimated or - * react-native-gesture-handler. - */ -import React, { useMemo } from "react" -import { StyleSheet, View } from "react-native" -import { BodyTransition } from "./BodyTransition" +import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react" +import { StyleSheet, useWindowDimensions, View, type ViewStyle } from "react-native" +import { useNavStore, type DetailEntry, type View as NavView } from "../nav" +import { makeThemedStyles, motion } from "../theme" +import type { BodyTransitionDirection } from "./BodyTransition.types" +import { pageBottomReserve } from "./bodyLayout" +import { ContentBottomReserveProvider, contentBottomReserveScrollHost } from "./ContentBottomReserve" import { IosKeyboardAvoidingView } from "./IosKeyboardAvoidingView" -import type { PageStackProps } from "./PageStack.types" -import { ScrollHostProvider } from "./ScrollHost" +import { PageActiveProvider } from "./pageActive" +import type { PageStackProps, PageStackRenderBody } from "./PageStack.types" +import { + pageLayerPointerEvents, + pageLayerTokens, + type PageLayerPointerEvents, + type PageMotionTokens, + type PageTransitionPlan, + type PageTransitionTiming, +} from "./pageStackModel" +import { + isInstantPagePlan, + pagePlanDuration, + restingLayerProgress, + webLayerCss, + webLayerProgress, + webLayerTransition, + webPageTransitionPlan, +} from "./pageStackWebModel" +import { ScrollHostProvider, type ScrollHostValue } from "./ScrollHost" import { DetailHeader, hasDetailHeader } from "./SheetHeader.shared" +import { isCoarsePointer, prefersReducedMotion } from "./webMedia" + +const TIMING: PageTransitionTiming = { + pushDuration: motion.pagePush.duration, + popDuration: motion.pagePop.duration, + fadeDuration: motion.bodyReplace.duration, +} +const PAGE_MOTION: PageMotionTokens = { + travelRatio: motion.pageTravelRatio, + parallaxRatio: motion.pageParallaxRatio, + scrimOpacity: motion.pageScrimOpacity, +} +const ANIMATED_TOKENS = pageLayerTokens(PAGE_MOTION, false, false) +const REDUCED_TOKENS = pageLayerTokens(PAGE_MOTION, true, false) +const SETTLE_SLACK_MS = 60 + +interface StackLayer { + key: string + entry: DetailEntry + stack: readonly DetailEntry[] +} + +interface Phase { + nav: number + direction: BodyTransitionDirection + plan: PageTransitionPlan + leaving: StackLayer | null + flipped: boolean +} + +interface StackState { + signature: string + nav: number + phase: Phase | null +} + +function restoredByHistory(): boolean { + const type = useNavStore.getState().lastTransition?.type + return type === "restore" || type === "seed" +} export function PageStack({ - bodyMounted, direction, - entry, + entries, insets, keyboardAvoidance, + layerKeys, renderBody, scrollHost, stack, - transitionKey, view, webKeyboardInset, }: PageStackProps) { - // The overlay body was memoized on exactly these four inputs before this seam existed, and it stays - // memoized here: PortraitShellFrame re-renders on every keyboard inset change, tab-bar measurement and - // sheet presence flip, and rebuilding the element on each would re-render the whole page tree. - const body = useMemo( - () => (bodyMounted ? renderBody(entry, view) : null), - [bodyMounted, entry, renderBody, view], + const styles = useStyles() + const width = useWindowDimensions().width + const hostRef = useRef(null) + const signature = layerKeys.join("|") + const stackSlices = useMemo(() => stack.map((_, index) => stack.slice(0, index + 1)), [stack]) + const layers = useMemo( + () => + entries.map((entry, depth) => ({ + key: layerKeys[depth] ?? `${depth}`, + entry, + stack: stackSlices[stack.indexOf(entry)] ?? stack, + })), + [entries, layerKeys, stack, stackSlices], ) + const committedRef = useRef(layers) + const [state, setState] = useState(() => ({ signature, nav: 0, phase: null })) + + if (state.signature !== signature) { + const plan = webPageTransitionPlan( + direction, + { + restored: restoredByHistory(), + reduceMotion: prefersReducedMotion(), + keyboardBound: keyboardAvoidance, + coarsePointer: isCoarsePointer(), + }, + TIMING, + ) + const nav = state.nav + 1 + const committed = committedRef.current + const gone = committed[committed.length - 1] + const leaving = plan.retainLeaving && gone && !layerKeys.includes(gone.key) ? gone : null + setState({ + signature, + nav, + phase: isInstantPagePlan(plan) ? null : { nav, direction, plan, leaving, flipped: false }, + }) + } + + useLayoutEffect(() => { + committedRef.current = layers + }, [layers]) + + const phase = state.phase + const settle = useCallback((nav: number) => { + setState((cur) => (cur.phase && cur.phase.nav === nav ? { ...cur, phase: null } : cur)) + }, []) + + useLayoutEffect(() => { + if (!phase || phase.flipped) return + const host = hostRef.current as unknown as { offsetHeight?: number } | null + void host?.offsetHeight + const nav = phase.nav + setState((cur) => + cur.phase && cur.phase.nav === nav && !cur.phase.flipped + ? { ...cur, phase: { ...cur.phase, flipped: true } } + : cur, + ) + const fallback = setTimeout(() => settle(nav), pagePlanDuration(phase.plan) + SETTLE_SLACK_MS) + return () => clearTimeout(fallback) + }, [phase?.nav]) + + const rendered = phase?.leaving ? [...layers, phase.leaving] : layers + const topIndex = rendered.length - 1 + const realTopIndex = layers.length - 1 + if (topIndex < 0) return null + const tokens = prefersReducedMotion() ? REDUCED_TOKENS : ANIMATED_TOKENS + const hasLeaving = phase?.leaving != null + return ( - - - {/* THE SHELL-LEVEL PAGE HEADER. It is what lets a "scroll" body become a full page with NO edit - of its own: the sheet gave it a DetailBar (title + leading chip) from CompactShell, and the - overlay layer used to give it nothing at all - a page with no title and, worse, no way off. - Same component, same i18n key, same ONE affordance gate, only `dismissGesture` differs (a - page has no grab handle to drag, so the chip may never be traded away for one). - The bodies that own their header return the " " sentinel and render nothing here - which is - why the HOST is gated on `hasDetailHeader` too: an empty padded wrapper would open a phantom - 12pt gap above every one of them. */} - {bodyMounted && hasDetailHeader(entry) ? ( - - - - ) : null} - - - {body} - - - + + {rendered.map((layer, index) => { + const progress = phase + ? webLayerProgress({ + index, + topIndex, + hasLeaving, + direction: phase.direction, + flipped: phase.flipped, + slide: phase.plan.slide, + }) + : restingLayerProgress(index, topIndex) + const css = webLayerCss(progress, width, tokens) + const active = index === realTopIndex + const topmost = index === topIndex + return ( + + ) + })} ) } -const styles = StyleSheet.create({ - // The padded outer box. It was the shell's own overlay (an absolute fill carrying - // `overlayInsets`); as a flex:1 child of that same absolute fill it describes the identical rect, and - // keeping it SEPARATE from the KeyboardAvoidingView below is what preserves the nesting the web - // keyboard reserve depends on - `paddingBottom: safeArea` and `paddingBottom: keyboardInset` on one - // node would override, on two they add. +interface WebPageLayerProps { + active: boolean + entry: DetailEntry + keyboardAvoidance: boolean + onSettle: (nav: number) => void + opacity: number + paddingBottom: number + paddingTop: number + pointer: PageLayerPointerEvents + renderBody: PageStackRenderBody + scrimOpacity: number | null + scrollHost: ScrollHostValue + settleNav: number | null + settleProperty: "transform" | "opacity" + stack: readonly DetailEntry[] + transform: string + transition: string + view: NavView + webKeyboardInset: { paddingBottom: number } | null +} + +const WebPageLayer = memo(function WebPageLayer({ + active, + entry, + keyboardAvoidance, + onSettle, + opacity, + paddingBottom, + paddingTop, + pointer, + renderBody, + scrimOpacity, + scrollHost, + settleNav, + settleProperty, + stack, + transform, + transition, + view, + webKeyboardInset, +}: WebPageLayerProps) { + const styles = useStyles() + const layerRef = useRef(null) + const body = useMemo(() => renderBody(entry, view), [entry, renderBody, view]) + const reserve = pageBottomReserve(entry.kind) + const boxReserve = reserve === "box" ? paddingBottom : 0 + const contentReserve = reserve === "content" ? paddingBottom : 0 + const bodyScrollHost = + reserve === "content" ? contentBottomReserveScrollHost(scrollHost) : scrollHost + const motionStyle = { transform, opacity, transition } as unknown as ViewStyle + const scrimStyle = { opacity: scrimOpacity ?? 0, transition } as unknown as ViewStyle + + useLayoutEffect(() => { + const node = layerRef.current as unknown as HTMLElement | null + if (!node) return + if (active) node.removeAttribute("inert") + else node.setAttribute("inert", "") + }, [active]) + + const onTransitionEnd = (event: any) => { + if (settleNav === null) return + if (event?.target !== event?.currentTarget) return + if (event?.propertyName && event.propertyName !== settleProperty) return + onSettle(settleNav) + } + + return ( + + + + {hasDetailHeader(entry) ? ( + + + + ) : null} + + + {body} + + + + + {scrimOpacity === null ? null : ( + + )} + + ) +}) + +const useStyles = makeThemedStyles((t) => ({ host: { flex: 1 }, - hostContent: { flex: 1 }, - // The page header's host. Its geometry is CompactShell.native's `headerHost` + `headerVPad` at a - // non-peeked snap (paddingHorizontal 14, paddingTop 0, paddingBottom 12), so the bar a body wears as a - // page reads the same as the bar it wore in the sheet. `flexShrink: 0` so a tall body cannot squeeze it. + layer: { + ...StyleSheet.absoluteFillObject, + backgroundColor: t.colors.bg, + }, + layerContent: { flex: 1 }, header: { flexShrink: 0, paddingHorizontal: 14, paddingBottom: 12, }, -}) + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: t.colors.shadowColor, + }, +})) diff --git a/packages/ui/src/shell/PortraitShell.shared.tsx b/packages/ui/src/shell/PortraitShell.shared.tsx index fedf8615..caeeecc7 100644 --- a/packages/ui/src/shell/PortraitShell.shared.tsx +++ b/packages/ui/src/shell/PortraitShell.shared.tsx @@ -265,10 +265,8 @@ export function PortraitShellFrame({ ]} > diff --git a/packages/ui/src/shell/PortraitShell.web.tsx b/packages/ui/src/shell/PortraitShell.web.tsx index fbf2f9b3..bd60c1d4 100644 --- a/packages/ui/src/shell/PortraitShell.web.tsx +++ b/packages/ui/src/shell/PortraitShell.web.tsx @@ -1,18 +1,22 @@ -import React from "react" +import React, { useContext } from "react" +import { SafeAreaInsetsContext } from "react-native-safe-area-context" +import { useAppPromoStore } from "../promo/appPromoStore" import { PortraitShellFrame, type PortraitShellProps } from "./PortraitShell.shared" import { initialTabBarFootprint } from "./tabBarLogic" import { useKeyboardInset } from "./useKeyboardInset.web" +const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 } + export function PortraitShell(props: PortraitShellProps) { const keyboardInset = useKeyboardInset() + const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS + const bannerHeight = useAppPromoStore((state) => state.bannerHeight) return ( ) diff --git a/packages/ui/src/shell/TabBar.web.tsx b/packages/ui/src/shell/TabBar.web.tsx index 8218a896..b8435594 100644 --- a/packages/ui/src/shell/TabBar.web.tsx +++ b/packages/ui/src/shell/TabBar.web.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from "react" +import React, { useContext, useEffect, useState } from "react" import { View, type ViewStyle } from "react-native" +import { SafeAreaInsetsContext } from "react-native-safe-area-context" import { space, useTheme } from "../theme" import { useTabBarStore } from "./tabBarStore" -import { compactBottomChrome, tabPillTransition } from "./tabBarLogic" +import { compactBottomChrome, dockBottomGap, tabPillTransition } from "./tabBarLogic" import { SearchHeader } from "./SearchHeader.web" import { TABS, @@ -39,7 +40,11 @@ export function TabBar() { useEffect(() => () => setTabBarHeight(0), [setTabBarHeight]) - const containerStyle = [styles.container, { paddingBottom: space["3"] }] + const insets = useContext(SafeAreaInsetsContext) + const containerStyle = [ + styles.container, + { paddingBottom: Math.max(space["3"], dockBottomGap(insets?.bottom ?? 0)) }, + ] if (compactBottomChrome(view) === "docked-search") { return ( diff --git a/packages/ui/src/shell/__tests__/bodyLayout.fullPage.test.ts b/packages/ui/src/shell/__tests__/bodyLayout.fullPage.test.ts index d08b0ac5..34c40fcd 100644 --- a/packages/ui/src/shell/__tests__/bodyLayout.fullPage.test.ts +++ b/packages/ui/src/shell/__tests__/bodyLayout.fullPage.test.ts @@ -1,11 +1,9 @@ /** * The mobile sheet -> PAGE presentation seam (`resolveBodyLayout` + `SHEET_ONLY_KINDS`). * - * WHY THIS FILE EXISTS AT ALL. `bodyLayout` is shared by civfix-web and civfix-mobile, and this package's - * vitest run resolves the EXTENSION-LESS modules - i.e. the WEB seam, where `DETAILS_ARE_FULL_PAGE` is - * false. So every other assertion in this directory exercises the sheet path and the page path would ship - * completely untested. The flag is a plain parameter precisely so it can be passed as `true` here without - * a bundler, a renderer or a mock. + * WHY THIS FILE EXISTS AT ALL. `bodyLayout` is shared by civfix-web and civfix-mobile, and + * `DETAILS_ARE_FULL_PAGE` is now true on both. The flag is a plain parameter precisely so both values can + * be passed here without a bundler, a renderer or a mock. * * The two halves are deliberately different in kind: * - the INVARIANTS (drop-pin is never a page, the dock never shows over a detail, `home-view` never @@ -37,9 +35,7 @@ const SCROLL_KINDS = ALL_DETAIL_KINDS.filter((kind) => BODY_LAYOUT[kind] === "sc const FULL_KINDS = ALL_DETAIL_KINDS.filter((kind) => BODY_LAYOUT[kind] === "full") describe("resolveBodyLayout - the sheet/page seam", () => { - it("is the strict IDENTITY of BODY_LAYOUT at fullPageDetails=false (the web guarantee)", () => { - // The whole web-regression argument in one assertion: with the flag false nothing this seam adds can - // change what a kind presents as, for ANY kind, so civfix-web is byte-identical by construction. + it("is the strict IDENTITY of BODY_LAYOUT at fullPageDetails=false", () => { for (const kind of ALL_DETAIL_KINDS) { expect(resolveBodyLayout(kind, false), kind).toBe(BODY_LAYOUT[kind]) } diff --git a/packages/ui/src/shell/__tests__/keyboardOwnership.test.ts b/packages/ui/src/shell/__tests__/keyboardOwnership.test.ts index 81aae5f9..d088658e 100644 --- a/packages/ui/src/shell/__tests__/keyboardOwnership.test.ts +++ b/packages/ui/src/shell/__tests__/keyboardOwnership.test.ts @@ -285,9 +285,12 @@ describe("I6 the composer carve-out is matched by a reserve on BOTH page stacks" }) it("reserves for it on web with the visual-viewport inset", () => { - expect(read("../PageStack.web.tsx")).toMatch( - //, + const web = read("../PageStack.web.tsx") + expect(web).toMatch( + //, ) + expect(web).toMatch(/keyboardAvoidance=\{keyboardAvoidance && active\}/) + expect(web).toMatch(/webKeyboardInset=\{active \? webKeyboardInset : null\}/) }) it("reserves for it on Android with the layer's own keyboard reserve", () => { diff --git a/packages/ui/src/shell/__tests__/pageStack.test.ts b/packages/ui/src/shell/__tests__/pageStack.test.ts index fff35dd5..c062366a 100644 --- a/packages/ui/src/shell/__tests__/pageStack.test.ts +++ b/packages/ui/src/shell/__tests__/pageStack.test.ts @@ -474,23 +474,37 @@ describe("PageStack.native: the gesture stays UI-thread safe and correctly scope }) }) -describe("PageStack.web: the web page host did not move", () => { +describe("PageStack.web: retained whole-page layers, no gesture", () => { const web = readFileSync(new URL("../PageStack.web.tsx", import.meta.url), "utf8") - it("stays a SINGLE-body host with no gesture and no layering", () => { + it("stays worklet-free and gesture-free, and animates whole layers rather than a body transition", () => { expect(web).not.toMatch(/^import[^\n]*react-native-reanimated/m) expect(web).not.toMatch(/^import[^\n]*react-native-gesture-handler/m) expect(web).not.toMatch(/Gesture\.\w/) expect(web).not.toMatch(/useAnimatedStyle|useSharedValue/) - expect(web).toContain("") + expect(web).not.toMatch(//) }) - it("keeps the safe-area inset and the keyboard inset on TWO nested boxes", () => { - expect(web).toMatch(//) - expect(web).toMatch(/style=\{\[styles\.hostContent, webKeyboardInset\]\}/) + it("keeps the safe-area inset and the keyboard inset on TWO nested boxes per layer", () => { + expect(web).toMatch(/style=\{\[styles\.layerContent, \{ paddingTop, paddingBottom: boxReserve \}\]\}/) + expect(web).toMatch(/style=\{\[styles\.layerContent, webKeyboardInset\]\}/) }) it("keeps the header gated on hasDetailHeader, so an own-header body gets no phantom gap", () => { - expect(web).toMatch(/bodyMounted && hasDetailHeader\(entry\)/) + expect(web).toMatch(/hasDetailHeader\(entry\) \?/) + }) + + it("tells every retained body whether it is the page on top, and hides the rest from assistive tech", () => { + expect(web).toMatch(/\{body\}<\/PageActiveProvider>/) + expect(web).toMatch(/aria-hidden=\{!active\}/) + }) + + it("makes every retained layer below the top inert and gives each layer a focus fallback", () => { + expect(web).toMatch(/if \(active\) node\.removeAttribute\("inert"\)\s*else node\.setAttribute\("inert", ""\)/) + expect(web).toMatch(/\}, \[active\]\)/) + expect(web).toMatch(/ref=\{layerRef\}/) + expect(web).toMatch(/tabIndex=\{-1\}/) + expect(web).toMatch(/dataSet: \{ civfixPageLayer: "" \}/) }) }) diff --git a/packages/ui/src/shell/__tests__/pageStackWebModel.test.ts b/packages/ui/src/shell/__tests__/pageStackWebModel.test.ts new file mode 100644 index 00000000..ccec6069 --- /dev/null +++ b/packages/ui/src/shell/__tests__/pageStackWebModel.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest" +import { MOTION } from "../../theme/motion" +import { + pageLayerTokens, + pageTransitionPlan, + type PageMotionTokens, + type PageTransitionTiming, +} from "../pageStackModel" +import { + INSTANT_PAGE_PLAN, + isInstantPagePlan, + pagePlanDuration, + restingLayerProgress, + webLayerCss, + webLayerProgress, + webLayerTransition, + webPageTransitionPlan, + type WebPageTransitionContext, +} from "../pageStackWebModel" + +const WIDTH = 390 +const px = (value: number) => `translateX(${Math.round(value * 100) / 100}px)` +const PAGE_MOTION: PageMotionTokens = { + travelRatio: MOTION.pageTravelRatio, + parallaxRatio: MOTION.pageParallaxRatio, + scrimOpacity: MOTION.pageScrimOpacity, +} +const ANIMATED = pageLayerTokens(PAGE_MOTION, false, false) +const REDUCED = pageLayerTokens(PAGE_MOTION, true, false) +const TIMING: PageTransitionTiming = { + pushDuration: MOTION.pagePush.duration, + popDuration: MOTION.pagePop.duration, + fadeDuration: MOTION.bodyReplace.duration, +} +const LIVE: WebPageTransitionContext = { + restored: false, + reduceMotion: false, + keyboardBound: false, + coarsePointer: false, +} +const DIRECTIONS = ["push", "pop", "replace"] as const + +describe("webPageTransitionPlan - when a web page change animates at all", () => { + it("is instant for a history restore in every direction, so the browser's own swipe never plays twice", () => { + for (const direction of DIRECTIONS) { + expect(webPageTransitionPlan(direction, { ...LIVE, restored: true }, TIMING)).toBe(INSTANT_PAGE_PLAN) + } + }) + + it("is instant for a keyboard-bound page on touch, and animates it on a fine pointer", () => { + expect( + webPageTransitionPlan("push", { ...LIVE, keyboardBound: true, coarsePointer: true }, TIMING), + ).toBe(INSTANT_PAGE_PLAN) + expect(webPageTransitionPlan("push", { ...LIVE, keyboardBound: true }, TIMING)).toEqual( + pageTransitionPlan("push", false, TIMING), + ) + }) + + it("slides an ordinary page on touch", () => { + expect(webPageTransitionPlan("push", { ...LIVE, coarsePointer: true }, TIMING).slide).toBe(true) + }) + + it("otherwise is exactly the native plan for every direction and motion preference", () => { + for (const direction of DIRECTIONS) { + for (const reduceMotion of [false, true]) { + expect(webPageTransitionPlan(direction, { ...LIVE, reduceMotion }, TIMING)).toEqual( + pageTransitionPlan(direction, reduceMotion, TIMING), + ) + } + } + }) +}) + +describe("isInstantPagePlan / pagePlanDuration", () => { + it("calls a plan instant only when neither a slide nor a fade has any duration", () => { + expect(isInstantPagePlan(INSTANT_PAGE_PLAN)).toBe(true) + for (const direction of DIRECTIONS) { + expect(isInstantPagePlan(pageTransitionPlan(direction, false, TIMING))).toBe(false) + expect(isInstantPagePlan(pageTransitionPlan(direction, true, TIMING))).toBe(false) + } + }) + + it("times a slide by its slide duration and a cross-fade by its fade duration", () => { + expect(pagePlanDuration(pageTransitionPlan("push", false, TIMING))).toBe(MOTION.pagePush.duration) + expect(pagePlanDuration(pageTransitionPlan("pop", false, TIMING))).toBe(MOTION.pagePop.duration) + expect(pagePlanDuration(pageTransitionPlan("replace", false, TIMING))).toBe( + MOTION.bodyReplace.duration, + ) + expect(pagePlanDuration(INSTANT_PAGE_PLAN)).toBe(0) + }) +}) + +describe("webLayerProgress - the two poses every layer moves between", () => { + const push = (index: number, flipped: boolean) => + webLayerProgress({ index, topIndex: 2, hasLeaving: false, direction: "push", flipped, slide: true }) + const pop = (index: number, flipped: boolean) => + webLayerProgress({ index, topIndex: 2, hasLeaving: true, direction: "pop", flipped, slide: true }) + const fade = (index: number, flipped: boolean) => + webLayerProgress({ index, topIndex: 2, hasLeaving: false, direction: "replace", flipped, slide: false }) + + it("starts a push with the incoming page off to the right and the page beneath uncovered", () => { + expect(push(2, false)).toEqual({ own: 1, above: 1, fade: 1 }) + expect(push(1, false)).toEqual({ own: 0, above: 1, fade: 1 }) + expect(push(0, false)).toEqual({ own: 0, above: 0, fade: 1 }) + }) + + it("ends a push with the incoming page home and the page beneath covered", () => { + expect(push(2, true)).toEqual({ own: 0, above: 1, fade: 1 }) + expect(push(1, true)).toEqual({ own: 0, above: 0, fade: 1 }) + expect(push(0, true)).toEqual({ own: 0, above: 0, fade: 1 }) + }) + + it("starts a pop exactly where the stack rested, and ends with the leaving page out and its parent uncovered", () => { + expect(pop(2, false)).toEqual({ own: 0, above: 1, fade: 1 }) + expect(pop(1, false)).toEqual({ own: 0, above: 0, fade: 1 }) + expect(pop(2, true)).toEqual({ own: 1, above: 1, fade: 1 }) + expect(pop(1, true)).toEqual({ own: 0, above: 1, fade: 1 }) + expect(pop(0, true)).toEqual({ own: 0, above: 0, fade: 1 }) + }) + + it("never moves the top page on a pop that retained nothing", () => { + const top = webLayerProgress({ + index: 1, + topIndex: 1, + hasLeaving: false, + direction: "pop", + flipped: true, + slide: true, + }) + expect(top.own).toBe(0) + }) + + it("cross-fades only the top page when there is no slide", () => { + expect(fade(2, false).fade).toBe(0) + expect(fade(2, true).fade).toBe(1) + expect(fade(1, false).fade).toBe(1) + expect(fade(1, true)).toEqual({ own: 0, above: 0, fade: 1 }) + }) + + it("rests every layer but the top one covered", () => { + expect(restingLayerProgress(2, 2)).toEqual({ own: 0, above: 1, fade: 1 }) + expect(restingLayerProgress(1, 2)).toEqual({ own: 0, above: 0, fade: 1 }) + }) + + it("matches the end pose of a push to the resting pose, so settling never jumps", () => { + for (const index of [0, 1, 2]) { + expect(push(index, true)).toEqual(restingLayerProgress(index, 2)) + } + }) +}) + +describe("webLayerCss", () => { + it("parks an incoming page at the travel ratio, fully faded", () => { + const css = webLayerCss({ own: 1, above: 1, fade: 1 }, WIDTH, ANIMATED) + expect(css.transform).toBe(px(WIDTH * MOTION.pageTravelRatio)) + expect(css.opacity).toBe(0) + expect(css.scrimOpacity).toBe(0) + }) + + it("parallaxes a covered page left under the scrim", () => { + const css = webLayerCss({ own: 0, above: 0, fade: 1 }, WIDTH, ANIMATED) + expect(css.transform).toBe(px(-WIDTH * MOTION.pageParallaxRatio)) + expect(css.opacity).toBe(1) + expect(css.scrimOpacity).toBe(MOTION.pageScrimOpacity) + }) + + it("moves nothing under reduced motion", () => { + expect(webLayerCss({ own: 1, above: 1, fade: 1 }, WIDTH, REDUCED).transform).toBe("translateX(0px)") + expect(webLayerCss({ own: 0, above: 0, fade: 1 }, WIDTH, REDUCED).transform).toBe("translateX(0px)") + }) + + it("multiplies the cross-fade into the layer opacity", () => { + expect(webLayerCss({ own: 0, above: 1, fade: 0 }, WIDTH, ANIMATED).opacity).toBe(0) + }) +}) + +describe("webLayerTransition", () => { + it("draws the start pose with no transition, and an instant plan never transitions", () => { + expect(webLayerTransition(pageTransitionPlan("push", false, TIMING), false)).toBe("none") + expect(webLayerTransition(INSTANT_PAGE_PLAN, true)).toBe("none") + }) + + it("animates transform and opacity over the plan's duration once flipped", () => { + const transition = webLayerTransition(pageTransitionPlan("push", false, TIMING), true) + expect(transition).toContain(`transform ${MOTION.pagePush.duration}ms`) + expect(transition).toContain(`opacity ${MOTION.pagePush.duration}ms`) + expect(webLayerTransition(pageTransitionPlan("replace", false, TIMING), true)).toContain( + `opacity ${MOTION.bodyReplace.duration}ms`, + ) + }) +}) diff --git a/packages/ui/src/shell/__tests__/platformSelectors.test.ts b/packages/ui/src/shell/__tests__/platformSelectors.test.ts index 71cd5d5a..f57bd076 100644 --- a/packages/ui/src/shell/__tests__/platformSelectors.test.ts +++ b/packages/ui/src/shell/__tests__/platformSelectors.test.ts @@ -1,4 +1,4 @@ -import { readdirSync } from "node:fs" +import { readdirSync, readFileSync } from "node:fs" import { join, relative } from "node:path" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" @@ -71,3 +71,14 @@ describe("I7 a platform seam is never shadowed by its own selector", () => { expect(selector.extension).toBe(platformExtensions[0]) }) }) + +describe("details present as full pages on every platform", () => { + it.each(["detailPresentationPlatform.ts", "detailPresentationPlatform.web.ts", "detailPresentationPlatform.native.ts"])( + "%s", + (file) => { + expect(readFileSync(new URL(`../${file}`, import.meta.url), "utf8")).toContain( + "DETAILS_ARE_FULL_PAGE = true", + ) + }, + ) +}) diff --git a/packages/ui/src/shell/__tests__/portrait-shell.test.ts b/packages/ui/src/shell/__tests__/portrait-shell.test.ts index 446efa1d..2ac20d5d 100644 --- a/packages/ui/src/shell/__tests__/portrait-shell.test.ts +++ b/packages/ui/src/shell/__tests__/portrait-shell.test.ts @@ -516,7 +516,7 @@ describe("every shell host navigates in the SAME motion language (source-pinned) it("derives push vs pop from the STACK LENGTH, through the one shared derivation", () => { for (const [name, src] of Object.entries(hosts)) { if (name === "PageStack.web.tsx") { - expect(src, name).toMatch(/direction=\{direction\}/) + expect(src, name).toMatch(/webPageTransitionPlan\(\s*direction,/) continue } expect(src, name).toContain("useStackDirection") @@ -526,10 +526,15 @@ describe("every shell host navigates in the SAME motion language (source-pinned) it("hands that direction to the body transition, never a hardcoded one", () => { for (const [name, src] of Object.entries(hosts)) { - if (name === "PortraitShell.shared.tsx" || name === "PageStack.web.tsx") { + if (name === "PortraitShell.shared.tsx") { expect(src, name).toMatch(/direction=\{direction\}/) continue } + if (name === "PageStack.web.tsx") { + expect(src, name).toMatch(/webPageTransitionPlan\(\s*direction,/) + expect(src, name).not.toMatch(//) } for (const [name, src] of Object.entries(hosts)) { @@ -542,3 +547,25 @@ describe("every shell host navigates in the SAME motion language (source-pinned) expect(hosts["PortraitShell.shared.tsx"]).toMatch(/ { + const src = readFileSync(new URL("../PortraitShell.web.tsx", import.meta.url), "utf8") + + it("takes the larger of the safe-area top and a banner that already pads for it", () => { + expect(src).toContain("topInset={Math.max(insets.top, bannerHeight)}") + expect(src).not.toMatch(/insets\.top \+ bannerHeight/) + }) +}) + +describe("web bottom safe area: the page box reserve owns it, the reply dock does not add it again", () => { + const dock = readFileSync(new URL("../../bodies/thread/useReplyDockInset.ts", import.meta.url), "utf8") + const shell = readFileSync(new URL("../PortraitShell.web.tsx", import.meta.url), "utf8") + const pages = readFileSync(new URL("../PageStack.web.tsx", import.meta.url), "utf8") + + it("reserves the inset on the pinned-footer page box and rests the dock at zero on web", () => { + expect(shell).toContain("bottomSafeArea={insets.bottom}") + expect(pages).toContain('const boxReserve = reserve === "box" ? paddingBottom : 0') + expect(dock).toContain('const restingSafeArea = Platform.OS === "web" ? 0 : (insets?.bottom ?? 0)') + expect(dock).toContain("restPad: inset > 0 ? 0 : restingSafeArea") + }) +}) diff --git a/packages/ui/src/shell/__tests__/tabBar.test.ts b/packages/ui/src/shell/__tests__/tabBar.test.ts index a6b0f907..09dfaf7e 100644 --- a/packages/ui/src/shell/__tests__/tabBar.test.ts +++ b/packages/ui/src/shell/__tests__/tabBar.test.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs" import { fileURLToPath } from "node:url" import { beforeEach, describe, expect, it } from "vitest" import { MOTION } from "../../theme/motion" +import { tokens } from "@civfix/shared/tokens" import { DOCK_OCCLUSION_SINK, DOCK_SHEET_CLEAR, @@ -392,10 +393,12 @@ describe("dockBottomGap on Android: the system nav bar is reserved IN FULL", () expect(logic).toMatch(/platform: DockPlatform = "other"/) }) - it("leaves the WEB dock structurally immune — it never consults an inset at all", () => { + it("keeps the WEB dock off the Android branch: the measured safe area through the iOS rule, floored at today's gap", () => { const web = readFileSync(new URL("../TabBar.web.tsx", import.meta.url), "utf8") - expect(web).toMatch(/paddingBottom: space\["3"\]/) - expect(web).not.toMatch(/dockBottomGap|dockKeyboardRestOffset|useSafeAreaInsets|Platform\.OS/) + expect(web).toMatch(/paddingBottom: Math\.max\(space\["3"\], dockBottomGap\(insets\?\.bottom \?\? 0\)\)/) + expect(web).not.toMatch(/dockKeyboardRestOffset|useSafeAreaInsets|Platform\.OS|"android"/) + expect(Math.max(tokens.space["3"], dockBottomGap(0))).toBe(tokens.space["3"]) + expect(dockBottomGap(34)).toBeGreaterThan(tokens.space["3"]) }) }) diff --git a/packages/ui/src/shell/detailPresentationPlatform.ts b/packages/ui/src/shell/detailPresentationPlatform.ts index 9bf6c241..030fd128 100644 --- a/packages/ui/src/shell/detailPresentationPlatform.ts +++ b/packages/ui/src/shell/detailPresentationPlatform.ts @@ -1,14 +1,6 @@ /** - * Whether a pushed DETAIL presents as a full PAGE on the shell's overlay layer (native) or as the compact - * pull-up SHEET (web). This extension-less module is the web/tsc/vitest default (`false`); Metro resolves - * the sibling `.native` variant to `true`. - * - * The seam exists because `BODY_LAYOUT` is ONE shared, platform-blind table: editing it flips web AND - * native together, and web deliberately keeps its sheet. So the table stays the platform-free source of - * truth and `resolveBodyLayout(kind, fullPageDetails)` applies this flag on top of it - which is what keeps - * `bodyLayout.ts` pure and unit-testable at BOTH flag values from a plain node vitest run. - * - * Mirrors `searchRevealPlatform.ts` exactly, including the belt-and-braces `.web` sibling: the bare file is - * ALREADY `false`, so a bundler that resolves neither extension still lands on web behaviour. + * Whether a pushed DETAIL presents as a full PAGE on the shell's overlay layer. It is `true` on every + * platform; the flag survives only so `resolveBodyLayout(kind, fullPageDetails)` stays unit-testable at + * both values from a plain node vitest run. */ -export const DETAILS_ARE_FULL_PAGE = false +export const DETAILS_ARE_FULL_PAGE = true diff --git a/packages/ui/src/shell/detailPresentationPlatform.web.ts b/packages/ui/src/shell/detailPresentationPlatform.web.ts index a5f2d062..4a571137 100644 --- a/packages/ui/src/shell/detailPresentationPlatform.web.ts +++ b/packages/ui/src/shell/detailPresentationPlatform.web.ts @@ -1,2 +1,2 @@ -/** Web: a pushed detail keeps the compact pull-up sheet (CompactShell.web), so it is never a page. */ -export const DETAILS_ARE_FULL_PAGE = false +/** Web: a pushed detail is a full PAGE on the overlay layer, exactly as on native. */ +export const DETAILS_ARE_FULL_PAGE = true diff --git a/packages/ui/src/shell/pageActive.tsx b/packages/ui/src/shell/pageActive.tsx index a760d9fc..860bac28 100644 --- a/packages/ui/src/shell/pageActive.tsx +++ b/packages/ui/src/shell/pageActive.tsx @@ -1,7 +1,7 @@ /** * "Am I the page the user is actually looking at?" - the one signal a RETAINED page layer needs. * - * THE PROBLEM IT SOLVES. `shell/PageStack.native` keeps every page on the stack MOUNTED so a pop can + * THE PROBLEM IT SOLVES. `shell/PageStack` (both seams) keeps every page on the stack MOUNTED so a pop can * reveal its parent instead of rebuilding it (see `fullEntryStack`). A mounted body runs its effects, so * any body that writes a PROCESS-GLOBAL store from a mount effect now writes it on behalf of a screen that * is buried two layers down - and its unmount/cleanup writes on behalf of one that is already gone. The @@ -16,9 +16,9 @@ * so a page returning to the top re-asserts its focus on the pop. * * DEFAULT TRUE, deliberately. Every other host in this repo mounts exactly one body at a time (the compact - * sheet, the expanded panel, the web overlay, the /bodies gallery, a unit test), and for all of them "the - * body I mounted is the active one" is simply correct. So only PageStack.native provides a value, and - * nothing else in the tree changes behaviour by a byte. + * sheet, the expanded panel, the /bodies gallery, a unit test), and for all of them "the body I mounted is + * the active one" is simply correct. So only the two PageStack seams (and the retained tab slots) provide + * a value, and nothing else in the tree changes behaviour by a byte. * * React + no RN: shared bodies import this module DIRECTLY (`../shell/pageActive`, the ScrollHost * pattern) rather than through the shell barrel, which would close an import cycle through BodyRouter. diff --git a/packages/ui/src/shell/pageStackWebModel.ts b/packages/ui/src/shell/pageStackWebModel.ts new file mode 100644 index 00000000..e5e0312f --- /dev/null +++ b/packages/ui/src/shell/pageStackWebModel.ts @@ -0,0 +1,107 @@ +import type { BodyTransitionDirection } from "./BodyTransition.types" +import { cssTransitionParts } from "./motionCss" +import { + pageLayerStyle, + pageTransitionPlan, + type PageLayerTokens, + type PageTransitionPlan, + type PageTransitionTiming, +} from "./pageStackModel" + +export interface WebPageTransitionContext { + restored: boolean + reduceMotion: boolean + keyboardBound: boolean + coarsePointer: boolean +} + +export const INSTANT_PAGE_PLAN: PageTransitionPlan = { + slide: false, + retainLeaving: false, + fromFront: 0, + duration: 0, + fadeDuration: 0, +} + +export function webPageTransitionPlan( + direction: BodyTransitionDirection, + context: WebPageTransitionContext, + timing: PageTransitionTiming, +): PageTransitionPlan { + if (context.restored) return INSTANT_PAGE_PLAN + if (context.keyboardBound && context.coarsePointer) return INSTANT_PAGE_PLAN + return pageTransitionPlan(direction, context.reduceMotion, timing) +} + +export function isInstantPagePlan(plan: PageTransitionPlan): boolean { + return plan.duration === 0 && plan.fadeDuration === 0 +} + +export function pagePlanDuration(plan: PageTransitionPlan): number { + return plan.slide ? plan.duration : plan.fadeDuration +} + +export interface WebLayerProgressInput { + index: number + topIndex: number + hasLeaving: boolean + direction: BodyTransitionDirection + flipped: boolean + slide: boolean +} + +export interface WebLayerProgress { + own: number + above: number + fade: number +} + +export function restingLayerProgress(index: number, topIndex: number): WebLayerProgress { + return { own: 0, above: index === topIndex ? 1 : 0, fade: 1 } +} + +export function webLayerProgress({ + index, + topIndex, + hasLeaving, + direction, + flipped, + slide, +}: WebLayerProgressInput): WebLayerProgress { + const settled = flipped ? 1 : 0 + const moving = index === topIndex + const under = index === topIndex - 1 + if (!slide) return { own: 0, above: moving ? 1 : 0, fade: moving ? settled : 1 } + if (direction === "push") { + return { own: moving ? 1 - settled : 0, above: moving ? 1 : under ? 1 - settled : 0, fade: 1 } + } + return { own: moving && hasLeaving ? settled : 0, above: moving ? 1 : under ? settled : 0, fade: 1 } +} + +export interface WebLayerCss { + transform: string + opacity: number + scrimOpacity: number +} + +export function webLayerCss( + progress: WebLayerProgress, + width: number, + tokens: PageLayerTokens, +): WebLayerCss { + const layer = pageLayerStyle(progress.own, progress.above, width, tokens) + return { + transform: `translateX(${Math.round(layer.translateX * 100) / 100}px)`, + opacity: layer.opacity * progress.fade, + scrimOpacity: layer.scrimOpacity, + } +} + +export function webLayerTransition(plan: PageTransitionPlan, flipped: boolean): string { + const duration = pagePlanDuration(plan) + if (!flipped || duration === 0) return "none" + return cssTransitionParts([ + ["transform", duration], + ["opacity", duration], + ]) +}