Compare commits

..

37 Commits

Author SHA1 Message Date
Maurice c42bbc4239 chore(native): re-trigger CI on the formatted tree 2026-11-05 17:22:51 +01:00
Maurice a9ffcbc134 style: prettier-format the native and i18n changes
The feature-branch CI does not run the formatting check, so the conflict-resolved
files (AboutTab, useAccountSettings, useTripExport), the native shell and iap
module, and the i18n files touched by the translation scripts were never run
through Prettier. Format them all; no behaviour changes.
2026-11-05 17:11:47 +01:00
Maurice 91defd40fd fix(native): a flat redeem result, so the claim flow typechecks
The discriminated union did not narrow under the native-shell tsconfig, so the
failure code read as absent on the whole type. Use a flat result with optional
instanceUrl and code instead: both are always present on the type, no narrowing
needed, and the ok plus instanceUrl guard still routes success from refusal.
2026-11-05 16:53:04 +01:00
Maurice bde3bb4a57 fix(native): typecheck, lint and knip on the native code; wire he's native bundle
The full suite, which the feature-branch CI does not run, catches four things the
native work still had: a non-narrowed union in the claim flow (tsc), a helper
named useFake that the react-hooks rule read as a hook (eslint), two iap types
exported but used only inside the file (knip), and the Hebrew native strings
present as a file but never spread into he's merged bundle (the aggregated key
parity test). All four fixed.
2026-11-05 16:44:31 +01:00
Maurice cc01f7df32 i18n(native): excuse the TREK Cloud brand, mark Email same-as-en, exempt the quota counts
The strict i18n checks, which the feature-branch CI does not run, catch three
things now that the native strings exist in every locale: TREK Cloud read as an
untranslated English phrase (it is a product name, like Vacay and Atlas, so add
it to the brand list); Email left identical in Italian, Indonesian and
Vietnamese, where it is the local word too (mark same-as-en) and in Greek (give
it a Greek term); and the GB and AI-document quota strings carrying {n} without
being plural groups (both are figures that never show a singular, like the other
unit-symbol counts in NOT_PLURAL).
2026-11-05 16:38:37 +01:00
Maurice 80de282e1a fix(i18n): add the Hebrew native strings, and correct the LoginPage size baseline
The he locale had every domain but native.ts, which broke i18n parity, the shared
coverage specs and the client parity test once the native strings existed. Add
he/native.ts with all native keys translated. Also correct the LoginPage size
baseline to its current line count.
2026-11-05 16:22:08 +01:00
Maurice 870b03c6c8 i18n(native): translate the app into the remaining 20 languages
The onboarding, plan picker, claim and provisioning strings were English
placeholders outside en, de and the five largest European languages. They are
now translated in full across the other 20 locales (ar, az, br, ca, cs, et, gr,
hu, id, ja, ko, ru, sk, sv, th, tr, uk, vi, zh, zh-TW), placeholders and brand
kept. The app now speaks every language TREK does.
2026-11-05 15:09:02 +01:00
Maurice 037e739807 feat(native): the in-app TREK Cloud subscription flow
After picking a plan, the app now carries the whole purchase itself when the build
can take one: choose your address (checked for free as you type, against the same
endpoint the website uses), your email, and subscribe. The receipt goes to
/api/iap/redeem, and a Setting up your TREK screen waits on the new address and
loads it the moment it answers.

The purchase sits behind an IapClient seam (src/native/iap.ts): a fake drives the
whole flow in development and preview, the native client reports itself
unavailable until the store plugin and products exist, and until then the plan
picker still finishes on the web, so nothing half-wired faces a customer. New
claim and provisioning strings across all locales (en, de and the five largest
European languages translated).
2026-11-05 14:36:21 +01:00
Maurice d02e58387d i18n(native): translate the onboarding into es, fr, it, nl and pl
The welcome, TREK Cloud and plan-picker strings were English placeholders in
every locale but en and de. The five largest remaining European languages are
now translated in full; the others still carry the English text and are a
follow-up for native speakers.
2026-11-05 14:15:23 +01:00
Maurice a6796765a2 feat(native): light status bar and a Go key on the address screen
The address screen sits on the near-black surface, so set the status bar to draw
its clock and icons in light while it is up; the server's own UI takes over the
style once it loads. The address field's on-screen return key now reads Go. Both
fail quietly where there is no native status bar (web, the simulator).
2026-11-05 14:00:36 +01:00
Maurice 5a62aa4541 feat(native): a plan picker for TREK Cloud, with live prices
The Get TREK Cloud path now leads to a plan picker that reads the live plans
from the site (/api/plans), so the prices and quotas always match the website.
Monthly and yearly in a segmented toggle, the year marked with its saving, each
plan a card with its price and what it includes. Subscribe is wired to finish on
the web order for now; it becomes an Apple/Google in-app purchase once the store
products exist. New strings across all locales (en and de translated).
2026-11-05 13:34:47 +01:00
Maurice 9790ad28e1 feat(native): a welcoming onboarding for the address screen
The app's own screen now opens on a welcome: the TREK mark, a line about what
TREK is, and two clear paths — connect the TREK you already open in the browser,
or get TREK Cloud. Both lead to polished screens: the address field with live
validation, a shake and haptic on a bad address and a success tick before the
server takes over; and a TREK Cloud intro with what it offers and a way in.
Screens slide and stagger in, buttons give a tap, and it all respects the safe
areas, light and dark, and reduced motion. Self-hosters are unaffected: cloud is
its own path, never in the way. New native strings are added across all locales
(English placeholders outside en/de, to translate).
2026-11-05 13:23:48 +01:00
Maurice 2ce44a12c5 Merge remote-tracking branch 'liketrek/dev' into resolve/native-apps
# Conflicts:
#	client/scripts/size-baseline.json
#	client/src/components/Planner/useTripExport.ts
#	client/src/components/Settings/AboutTab.tsx
#	client/src/components/Settings/useAccountSettings.ts
2026-11-05 13:07:08 +01:00
Maurice cb6149f8ba fix(native): read a notice button's href without a weak type
An action button carries no href, so a parameter typed as only an optional href refused the notice DTO; the button type now names its kind as well.
2026-10-25 18:58:27 +01:00
Maurice 8963180006 feat(native): everything the App Store and Google Play submissions need
- iOS: microphone usage text for "Take Video", no-export-compliance flag,
  ExportOptions.plist for App Store Connect uploads, the unused push
  forwarders gone; CI builds with Xcode 26 and lints the property lists.
- Android: version and signing from Gradle properties (unsigned without
  them), R8 with keep rules for TREK's and Capacitor's reflective classes,
  no Auto Backup of the session cookie, no external file provider path,
  template and Firebase leftovers removed, keystores and service accounts
  ignored, full-size adaptive icon layers with a monochrome layer; CI also
  builds the release APK.
- native-release.yml: signed bundle to a Play testing track and archive to
  TestFlight, by hand or on a v* tag, behind the store environment.
- Version handshake: the server reports nativeApi 1, the app refuses a
  server below its minimum and tolerates an app older than the offline copy.
- Address screen: privacy policy link, "Try the demo" and "What is a TREK
  server?"; the privacy link also in the app's server settings. Three new
  native strings in all 27 locales.
- Donation links and the notices asking for one are hidden in the app.
- Store listings for "TREK Travel Planner" in client/fastlane: texts, review
  notes with placeholders, privacy and account deletion page drafts, the
  feature graphic and icon, a length and image check in CI, and a Playwright
  store project with the scripts that turn its captures into screenshots.
- Docs: wiki/Native-Apps.md, README no longer says there is no store app.
2026-10-25 18:37:31 +01:00
Maurice 5c37dd43c5 fix(native): hide passkeys in the app through usePasskeys
The flag in useAccountSettings grew the identical destructuring in both account views past the duplication threshold; the shared passkeys hook already decides when its card is hidden, so the app check lives there now.
2026-10-25 18:02:45 +01:00
Maurice 6f3c051c7c fix(native): Prettier on the native client files, rate-limit e2e requests in sequence
Formats the files the client formatting gate listed, and NATIVE-E2E-005 sends its twenty exchange attempts one after another: all at once they reset the test server's connections.
2026-10-25 17:41:32 +01:00
Maurice 99c1a2d6c7 fix(native): strict types in the address screen, Prettier on the native-auth e2e suite, file-local exports
The address screen's element helper returns the plain element instead of Object.assign's intersection, which strict mode could not represent; the haptics test reads an optional argument as optional; three exports only their own file used are local now.
2026-10-25 17:26:03 +01:00
Maurice a3b04887a1 fix(native): new lint warnings and the MapLibre protocol type after the dev merge
Awaits that sat in loops in the native-auth tests run together, the hand-off hook marks its navigations as ignored, the native tests drop their non-null assertions, and the offline protocol answers with the data type MapLibre 6.15 expects.
2026-10-25 17:16:02 +01:00
Maurice 8b6a65023f Merge dev into feat/native-apps
Brings the native apps branch up to dev (NestJS-only server, MikroORM
repositories, tracked sessions, per-domain locales, the lint ratchets) and
ports its new code onto that architecture:

- server: /api/auth/native moves onto injected services. The session is
  minted by the auth domain's new LoginSessionService (login bookkeeping and
  SessionsService.issue in one transaction), the one-time codes live behind a
  NativeHandoffStore port with a capped in-memory implementation that drops
  expired codes on write instead of running a timer, both routes declare
  response contracts, and the exchange answers { success: true }. Unit and
  e2e suites run on createTestModule and the ORM factories.
- shared: native strings for az, et, sk and th, native wired into every
  locale, the address placeholder as a full URL.
- client: the account and export changes go into dev's shared hooks
  (useAccountSettings, useTripExport), the native server card is a
  desktop/phone pair on useNativeServer, the hand-off call moves out of the
  page hook into native/, best-effort native failures go through
  reportError instead of the console, and the address screen is known to
  knip and lint:i18n-keys.
- package-lock.json regenerated on Linux.
2026-10-25 17:04:15 +01:00
Maurice 7572c610a6 feat(native): TREK's own app icon and launch screen, named just TREK
The shells still carried Capacitor's placeholder icon and splash, the blue
X on a grid. They now show the white TREK mark on a dark, slightly glassy
tile: a soft light from the top left and a sheen over the upper half, in
the app's launch colour #09090b. Android 12+ draws its launch screen on the
same colour instead of light grey.

scripts/generate-app-icons.mjs renders the sources into assets/ from
public/icons/icon-white.svg, and `npm run icons:app` turns them into every
size both platforms ask for through @capacitor/assets. The Android
foreground mark is sized for the adaptive icon's round mask.

The home screen label is TREK: the old "TREK - Plan your trips!" was cut
off under the icon, and the longer title belongs to the store listings.
2026-09-24 14:50:25 +02:00
Maurice f567e15a99 fix(native): Android shows "server not reachable" instead of a blank page
Capacitor injects its bridge on Android only into the server's origin once
server.url is set, so the local error page it switches to when the server
does not load had no bridge. It could neither retry nor change the server
and stayed blank.

TrekWebViewClient now catches a failed page load (a network error, or a 5xx
from a proxy while TREK restarts) and the app starts over on the bundled
address screen, where the bridge is present and the screen offers exactly
those two choices. The server config no longer carries errorPath.

Two traps on the way, both found in the emulator: a restart during launch
has to be a fresh task, since recreate() leaves the system splash lying over
the new page; and a restart shows no launch splash at all, because the
splash plugin cannot take it down again when the page hides it early.
2026-09-24 13:59:08 +02:00
Maurice b1479d939a fix(native): no overscroll stretch on Android
The UI asks for no overscroll effect with overscroll-behavior: none on html,
and Chrome honours it. The Android WebView draws the stretch on its root
scroller natively, though, and it scales the whole view, so the top bar and
the bottom dock were pulled along with the page. Found in the emulator.
2026-09-24 13:36:27 +02:00
Maurice 71913eb6e2 fix(native): remember the login by default inside the app
With the switch off the app got a session cookie, which dies with the app
process, so every time the phone unloaded TREK the user had to sign in
again. Found while running the Android build in the emulator.
2026-09-24 13:30:37 +02:00
Maurice 601ed66a38 test(mobile): the backup-code download revokes its URL after the shared delay
The mobile twin of the AccountTab test still expected the object URL to be
revoked in the same tick. Since both screens download through downloadBlob,
the revoke happens after its 100 ms grace period.
2026-09-24 13:22:49 +02:00
Maurice 9a69b092e8 feat(native): the small things that give a WebView away
- A short haptic tap on success, warning and error toasts, which is where
  every save and delete already reports. Info toasts stay silent.
- No "‹ › Done" strip above the iOS keyboard.
- No Safari preview when a link is long-pressed.
- The swipe from the left edge goes back on iOS, through the same history
  Android's back button walks.
2026-09-24 13:11:25 +02:00
Maurice be211c22ed fix(native): keep the iOS offline copy inside the app
loadSimulatedRequest went to the server URL without its trailing slash, and
Capacitor keeps a navigation in the WebView only when it starts with the
bridge's serverURL, slash included. The offline start page would have
opened in Safari instead. Also covers the platform helpers and the
hand-off API module.
2026-09-24 12:48:12 +02:00
Maurice 293b25466e feat(native): offline vector maps in the iOS app
In a browser the service worker answers MapLibre from the gl-map-offline
cache that glPrefetcher fills for trips marked for offline. The iOS app has
no service worker, so nothing read that cache there and the map went blank
without network.

A trek-offline:// protocol takes the service worker's place. Inside the iOS
app the MapLibre style URL is rewritten to it, the handler rewrites every
https URL inside the style and its TileJSON the same way, and then serves
documents network first and tiles, glyphs and sprites cache first. Outside
the iOS app nothing changes. The default OpenFreeMap basemap is covered;
Mapbox GL has no protocol hook and stays online-only in the app, as does a
custom raster template, whose opaque responses only a service worker can
store.
2026-09-24 12:47:07 +02:00
Maurice 1ba58e8b89 ci(native): build the Android and iOS shells on every change to them
Compiles the Swift side on a macOS runner so nobody needs a Mac to know it
still builds, and leaves a debug APK on every run to install on a phone.
Unsigned: signing and store uploads need secrets fork PRs do not get.
2026-09-24 12:43:06 +02:00
Maurice ae0d4e4520 feat(native): the UI knows when it runs inside the app
Everything the server's UI does differently inside the iOS and Android app
lives under src/native and is inert in a browser.

Sign-in: SSO and the passkey button go through the system browser
(signInThroughBrowser), because identity providers refuse embedded
browsers and WebAuthn needs a domain association no build can have for
every server. The browser end is the new /native-handoff page: it signs
the user in if needed and hands the session over only after an explicit
tap, so a link carrying somebody else's challenge cannot pass a session
on silently. The verifier is hashed with @noble/hashes because
crypto.subtle is missing on the plain-http servers many home networks
use. An OIDC-only install no longer bounces to the IdP inside the
WebView, and passkey management is hidden in the app.

Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile
and openFile write to the app cache and open the share sheet. The eight
places that built their own download anchor now use downloadBlob, which
they should have anyway.

The rest: "Change server" on the login page and in the account settings,
the splash hides after the first frame, Android's back button walks
history before it leaves the app, the status bar follows the scheme, and
on iOS the start page is refreshed and every chunk of the build (listed
in the new asset-manifest.json) is pulled into WebKit's cache so the app
can start without network.
2026-09-24 12:42:34 +02:00
Maurice c5765bbf15 feat(native): the apps load the server's own UI
The apps ship only an address screen now. Once a server is picked, the
native side points the WebView at it: TrekBridgeViewController on iOS and a
config copy with server.url on Android. The UI then comes from that server,
in the version it runs and on its own origin, so the session cookie,
uploads, /ws and plugins work exactly as in a browser and no app release
has to keep pace with the servers out there.

The address screen (native-shell/, built into native-dist/) probes
/api/health/features through native HTTP, tries https before http when no
scheme was typed, and refuses servers without the nativeApp flag. It also
serves as the error page when the remembered server does not load: retry,
change server, or on iOS the stored copy of the server's start page.

TrekShell is the app's own plugin for all of that: remember, change and
reload the server, sign in through ASWebAuthenticationSession or a Custom
Tab, and on iOS keep and open the offline copy of the start page, because
WKWebView runs no service worker for a domain the app does not list.

Plugins the frame does not use are gone (push, camera, geolocation,
calendar, file transfer, in-app browser and more): the WebView covers the
camera and location itself, and push needs a relay before it is worth
shipping. With them go the push entitlement, the calendar usage strings
and the Firebase bits. The iOS scheme falls back to capacitor://, since
Capacitor drops an iosScheme of https, and the arm64 capability replaces
armv7.
2026-09-24 12:30:32 +02:00
Maurice 9832d12751 feat(auth): hand a browser sign-in over to the native app
The native apps show the server's own UI in a WebView, where neither SSO nor
passkeys can run. They sign in through the system browser instead and need a
way to carry that session back.

The app keeps a random verifier and sends only its sha256 into the browser.
Once signed in there, the browser trades that challenge for a one-time code
(cookie session only, so an API or MCP token cannot mint itself a browser
session), and the WebView redeems the code together with the verifier. The
code rides through a URL scheme any app could claim, so on its own it is
worthless; it is single use and good for a minute.

Also: /api/health/features reports nativeApp so the apps can refuse servers
that predate this, and hashed files under assets/ are served immutable,
which the iOS app needs to start offline without a service worker.
2026-09-24 12:19:10 +02:00
Maurice 14825df993 Merge remote-tracking branch 'origin/dev' into native-apps
# Conflicts:
#	client/package.json
#	package-lock.json
2026-09-24 11:53:50 +02:00
jubnl e12be0a185 Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-25 23:47:41 +02:00
jubnl 9f1f33213b chore(capacitor): base plugins 2026-08-24 00:23:51 +02:00
jubnl b2c029f02a Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-23 15:27:42 +02:00
jubnl 915a1f64e6 chore(capacitor): base files for capacitor 2026-08-22 22:52:25 +02:00
537 changed files with 2924 additions and 13477 deletions
+35 -25
View File
@@ -34,10 +34,7 @@ concurrency:
jobs:
android:
# Gated on a repository variable so the release can ship iOS alone while the
# Google Play account and its signing secrets are not yet in place. Set the
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 40
environment: store
@@ -151,31 +148,44 @@ jobs:
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
- name: Distribution certificate
- name: Archive
working-directory: client/ios/App
env:
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: >-
xcodebuild archive
-project App.xcodeproj
-scheme App
-configuration Release
-destination 'generic/platform=iOS'
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
MARKETING_VERSION=${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
# run on a device-less CI runner (it signs for development and then needs a
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
- name: Build and upload to TestFlight
working-directory: client
# ExportOptions.plist says destination=upload, so this step sends the build
# to App Store Connect, where it shows up in TestFlight after processing.
- name: Export and upload to App Store Connect
working-directory: client/ios/App
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MARKETING_VERSION: ${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: fastlane ios beta
run: >-
xcodebuild -exportArchive
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
-exportOptionsPlist ../ExportOptions.plist
-exportPath "$RUNNER_TEMP/export"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
- name: Remove secrets
- name: Remove the API key
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
+2 -2
View File
@@ -80,7 +80,7 @@ jobs:
# unsigned: the upload key only exists in native-release.yml.
- name: Assemble debug and release APKs
working-directory: client/android
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon
- uses: actions/upload-artifact@v6
with:
@@ -103,7 +103,7 @@ jobs:
xcodebuild -version
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist
- uses: actions/setup-node@v6
with:
+1 -1
View File
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
-4
View File
@@ -56,10 +56,6 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "androidx.biometric:biometric:1.1.0"
// Push through liketrek's relay. No google-services plugin: Firebase reads
// its project from res/values/firebase.xml, the strings the plugin would generate.
implementation "com.google.firebase:firebase-messaging:24.1.2"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
@@ -23,16 +23,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- "Open in TREK" / share a booking document into the import. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="text/calendar" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<!-- Deep links.
@@ -60,15 +50,6 @@
</activity>
<!-- Push: the relay's data messages, decrypted and shown here. -->
<service
android:name=".TrekPushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -95,7 +76,5 @@
Play hide the app from tablets and other GPS-less devices. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13 and later ask before an app may show notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -26,13 +26,6 @@ public class MainActivity extends BridgeActivity {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
TrekSharedFiles.receive(this, getIntent());
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
TrekSharedFiles.receive(this, intent);
}
private boolean leavingServer;
@@ -1,139 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
* encrypts every notification for these, and TrekPushService decrypts with
* them. They are kept in the app's preferences, sealed with an AES key that
* never leaves the Android Keystore, so a copy of the preferences alone opens
* nothing.
*/
final class TrekPushKeys {
private static final String PREFS = "trek_push";
private static final String WRAP_ALIAS = "trek_push_wrap";
private static final String PRIVATE = "private";
private static final String PUBLIC = "public";
private static final String AUTH = "auth";
final ECPrivateKey privateKey;
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
final byte[] publicKey;
final byte[] auth;
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
this.privateKey = privateKey;
this.publicKey = publicKey;
this.auth = auth;
}
/** The stored keys, or null when the app never subscribed. */
static synchronized TrekPushKeys load(Context context) throws Exception {
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String sealedPrivate = prefs.getString(PRIVATE, null);
String publicKey = prefs.getString(PUBLIC, null);
String sealedAuth = prefs.getString(AUTH, null);
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
byte[] pkcs8 = unseal(sealedPrivate);
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
}
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
TrekPushKeys existing;
try {
existing = load(context);
} catch (Exception unreadable) {
// The Keystore key is gone (backup restored to another device, data
// cleared): start over, the next subscription carries the new keys.
existing = null;
}
if (existing != null) return existing;
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair pair = generator.generateKeyPair();
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
byte[] auth = new byte[16];
new SecureRandom().nextBytes(auth);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
.putString(AUTH, seal(auth))
.apply();
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
}
static String base64url(byte[] data) {
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
static byte[] uncompressed(ECPublicKey key) {
byte[] out = new byte[65];
out[0] = 0x04;
copyFixed(key.getW().getAffineX(), out, 1);
copyFixed(key.getW().getAffineY(), out, 33);
return out;
}
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
private static void copyFixed(BigInteger value, byte[] out, int offset) {
byte[] raw = value.toByteArray();
int start = Math.max(0, raw.length - 32);
int length = raw.length - start;
System.arraycopy(raw, start, out, offset + 32 - length, length);
}
private static SecretKey wrapKey() throws Exception {
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
store.load(null);
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
);
return generator.generateKey();
}
private static String seal(byte[] plain) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
byte[] iv = cipher.getIV();
byte[] sealed = cipher.doFinal(plain);
byte[] out = new byte[iv.length + sealed.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
return Base64.encodeToString(out, Base64.NO_WRAP);
}
private static byte[] unseal(String value) throws Exception {
byte[] in = Base64.decode(value, Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
return cipher.doFinal(in, 12, in.length - 12);
}
}
@@ -1,101 +0,0 @@
package com.liketrek.trek;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;
/**
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
* record), decrypts them with the device's keys and shows the notification.
* Without the keys, or when the record does not open, it shows the same
* neutral text the iOS extension falls back to, never the ciphertext.
*/
public class TrekPushService extends FirebaseMessagingService {
static final String CHANNEL = "trek";
static final String EXTRA_URL = "trek.pushUrl";
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
String enc = message.getData().get("enc");
if (enc == null) return;
String title = getString(R.string.push_fallback_title);
String body = getString(R.string.push_fallback_body);
String url = null;
String tag = null;
try {
TrekPushKeys keys = TrekPushKeys.load(this);
if (keys != null) {
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
body = payload.optString("body", body);
url = payload.optString("url", null);
tag = payload.optString("tag", null);
}
} catch (Exception unreadable) {
// Shown with the neutral text: the tap still opens the app.
}
show(this, title, body, url, tag);
}
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
@Override
public void onNewToken(@NonNull String token) {}
static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager == null || manager.getNotificationChannel(CHANNEL) != null) return;
NotificationChannel channel = new NotificationChannel(
CHANNEL,
context.getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
);
manager.createNotificationChannel(channel);
}
private static void show(Context context, String title, String body, String url, String tag) {
ensureChannel(context);
Intent open = new Intent(context, MainActivity.class)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (url != null) open.putExtra(EXTRA_URL, url);
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
PendingIntent tap = PendingIntent.getActivity(
context,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_trek)
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(tap);
if (Build.VERSION.SDK_INT >= 33
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return;
}
try {
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
} catch (SecurityException notAllowed) {
// Notifications switched off for the app since it subscribed.
}
}
}
@@ -1,76 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Base64;
import com.getcapacitor.JSObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
/**
* Booking documents shared to TREK (open with / share), held until the UI takes
* them (TrekShell.takeSharedFile) and announced if it is already running.
*/
final class TrekSharedFiles {
/** Above this the file is not a booking confirmation. */
private static final int MAX_BYTES = 15 * 1024 * 1024;
interface Listener {
void onFile(JSObject file);
}
private static JSObject pending;
private static Listener listener;
private TrekSharedFiles() {}
static void setListener(Listener l) {
listener = l;
}
static JSObject take() {
JSObject file = pending;
pending = null;
return file;
}
static void receive(Context context, Intent intent) {
if (intent == null) return;
Uri uri = null;
if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData();
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri == null) return;
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
if (out.size() > MAX_BYTES) return;
}
JSObject file = new JSObject();
file.put("name", displayName(context, uri));
String type = context.getContentResolver().getType(uri);
file.put("mimeType", type == null ? intent.getType() : type);
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
pending = file;
if (listener != null) listener.onFile(file);
} catch (Exception ignored) {
// Unreadable or gone: nothing to import.
}
}
private static String displayName(Context context, Uri uri) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
} catch (Exception ignored) {
// Fall through to the path.
}
String last = uri.getLastPathSegment();
return last == null ? "booking" : last;
}
}
@@ -1,29 +1,19 @@
package com.liketrek.trek;
import android.Manifest;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@CapacitorPlugin(
name = "TrekShell",
permissions = { @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications") }
)
@CapacitorPlugin(name = "TrekShell")
public class TrekShellPlugin extends Plugin {
/** How long to wait after returning from the browser for the redirect to land. */
@@ -34,190 +24,6 @@ public class TrekShellPlugin extends Plugin {
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
/**
* What this binary offers the server's UI, raised with every native feature
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
* and a server can require a minimum (minNativeShellApi in /api/health/features).
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = { "capabilities", "biometricLock", "sharedFiles", "nativePush" };
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
/** The notification the app was started from, until the UI asks for it. */
private String openedPushUrl;
@PluginMethod
public void getCapabilities(PluginCall call) {
JSObject result = new JSObject();
result.put("shellApi", SHELL_API);
result.put("platform", "android");
String version = "";
String build = "";
try {
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
version = info.versionName == null ? "" : info.versionName;
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
// Our own package is always there; the fields simply stay empty.
}
result.put("appVersion", version);
result.put("build", build);
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
for (String feature : FEATURES) features.put(feature);
result.put("features", features);
call.resolve(result);
}
// ---- Shared booking documents ----
@Override
public void load() {
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
if (getActivity() != null && getActivity().getIntent() != null) {
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
}
}
// ---- Push ----
/**
* Permission, the FCM token and the device keys, as the Web Push subscription
* the server stores (native/push.ts). Rejects with DENIED when the user
* refuses notifications, as on iOS.
*/
@PluginMethod
public void registerPush(PluginCall call) {
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
return;
}
subscribePush(call);
}
@PermissionCallback
private void pushPermissionAnswered(PluginCall call) {
if (getPermissionState("notifications") != PermissionState.GRANTED) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
subscribePush(call);
}
private void subscribePush(PluginCall call) {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
TrekPushService.ensureChannel(getContext());
final TrekPushKeys keys;
try {
keys = TrekPushKeys.loadOrCreate(getContext());
} catch (Exception e) {
call.reject("Could not create the push keys", e);
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (!task.isSuccessful() || task.getResult() == null) {
call.reject("Could not get a push token", task.getException());
return;
}
JSObject subscription = new JSObject();
subscription.put("endpoint", PUSH_RELAY + task.getResult());
JSObject keyObject = new JSObject();
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
subscription.put("keys", keyObject);
call.resolve(subscription);
});
}
@PluginMethod
public void takeOpenedPush(PluginCall call) {
JSObject result = new JSObject();
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
openedPushUrl = null;
call.resolve(result);
}
/** The document shared to TREK before the UI was there to hear of it. */
@PluginMethod
public void takeSharedFile(PluginCall call) {
JSObject result = new JSObject();
JSObject file = TrekSharedFiles.take();
result.put("file", file == null ? JSONObject.NULL : file);
call.resolve(result);
}
// ---- App lock ----
private static final int AUTHENTICATORS =
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
/** Which biometry the device offers for the app lock, if any. */
@PluginMethod
public void biometryInfo(PluginCall call) {
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
JSObject result = new JSObject();
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
result.put("type", "biometric");
call.resolve(result);
}
/** The system biometric prompt with the screen lock as the way out. */
@PluginMethod
public void authenticateBiometric(PluginCall call) {
String reason = call.getString("reason", "Unlock TREK");
getActivity().runOnUiThread(() -> {
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
activity,
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
JSObject ok = new JSObject();
ok.put("ok", true);
call.resolve(ok);
}
@Override
public void onAuthenticationError(int code, CharSequence message) {
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
}
}
);
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
.setTitle(reason)
.setAllowedAuthenticators(AUTHENTICATORS)
.build();
prompt.authenticate(info);
});
}
/** While on, the recent-apps screen shows no snapshot of the app. */
@PluginMethod
public void setPrivacyCover(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
getActivity().runOnUiThread(() -> {
if (android.os.Build.VERSION.SDK_INT >= 33) {
getActivity().setRecentsScreenshotEnabled(!enabled);
} else if (enabled) {
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
} else {
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
});
call.resolve();
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
@@ -281,14 +87,6 @@ public class TrekShellPlugin extends Plugin {
@Override
protected void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
if (pushUrl != null) {
intent.removeExtra(TrekPushService.EXTRA_URL);
JSObject opened = new JSObject();
opened.put("url", pushUrl);
notifyListeners("pushOpened", opened);
return;
}
Uri data = intent.getData();
PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
@@ -1,84 +0,0 @@
package com.liketrek.trek;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
* the same thing a browser does, and the counterpart of the iOS notification
* extension's WebPushRecord. One record, as TREK's server always sends.
*/
final class TrekWebPush {
private TrekWebPush() {}
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
if (record.length < 21) throw new IllegalArgumentException("record too short");
byte[] salt = Arrays.copyOfRange(record, 0, 16);
int idLength = record[20] & 0xff;
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
agreement.init(keys.privateKey);
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
byte[] shared = agreement.generateSecret();
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
byte[] padded = cipher.doFinal(ciphertext);
// Padding: the content, one delimiter (2 for the last record), then zeros.
int end = padded.length - 1;
while (end >= 0 && padded[end] == 0) end--;
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
return Arrays.copyOf(padded, end);
}
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
}
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte) 1);
return Arrays.copyOf(mac.doFinal(), length);
}
private static byte[] concat(byte[]... parts) {
int length = 0;
for (byte[] part : parts) length += part.length;
byte[] out = new byte[length];
int offset = 0;
for (byte[] part : parts) {
System.arraycopy(part, 0, out, offset, part.length);
offset += part.length;
}
return out;
}
}
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status bar icon for notifications: a single-colour map pin, as Android
tints it and shows only its shape. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Firebase project the app receives push through (liketrek push relay).
These are the strings the google-services Gradle plugin would generate from
google-services.json; Firebase reads them at start. None of them is a secret:
they identify the project, sending needs the relay's service account. -->
<resources>
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="project_id" translatable="false">trek-9392e</string>
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
</resources>
@@ -4,7 +4,4 @@
<string name="title_activity_main">TREK</string>
<string name="package_name">com.liketrek.trek</string>
<string name="custom_url_scheme">com.liketrek.trek</string>
<string name="push_channel_name">Notifications</string>
<string name="push_fallback_title">TREK</string>
<string name="push_fallback_body">New notification</string>
</resources>
@@ -1,63 +0,0 @@
package com.liketrek.trek;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECPrivateKeySpec;
import java.util.Base64;
import org.junit.Test;
/** The decryption against the worked example in RFC 8291, section 5. */
public class TrekWebPushTest {
private static byte[] b64(String value) {
return Base64.getUrlDecoder().decode(value);
}
private static TrekPushKeys rfcKeys() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
return new TrekPushKeys(
privateKey,
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
b64("BTBZMqHH6r4Tts7J_aSIgg")
);
}
private static final String RECORD =
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
@Test
public void decryptsTheRfcExample() throws Exception {
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
}
@Test
public void refusesATamperedRecord() throws Exception {
byte[] record = b64(RECORD);
record[record.length - 1] ^= 1;
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
}
@Test
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
for (int i = 0; i < 20; i++) {
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
assertEquals(65, point.length);
assertEquals(4, point[0]);
}
}
}
@@ -1,104 +0,0 @@
import { expect, test, type Locator } from '@playwright/test';
// #2981: labels, category and status took the whole row, so a place with two or
// three long labels showed one letter of its name or none, and the status pill
// ran out of the card. Measured in a real browser because the layout is the bug.
const PLACES = [
{ name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] },
{ name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] },
{ name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] },
{ name: 'Torre de Belem', labels: [] as string[] },
];
async function expectInside(row: Locator) {
const escapes = await row.evaluate((el) => {
const box = el.getBoundingClientRect();
return Array.from(el.querySelectorAll('*')).filter((child) => {
// The strip's hidden measuring copy is meant to run past the bar.
if (child.closest('[aria-hidden="true"]')) return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5);
}).length;
});
expect(escapes).toBe(0);
}
for (const viewport of [
{ width: 1920, height: 1000 },
{ width: 1400, height: 900 },
{ width: 1024, height: 800 },
]) {
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
test.setTimeout(90_000);
await page.setViewportSize(viewport);
const addonsResponse = await page.request.get('/api/admin/addons');
expect(addonsResponse.ok()).toBeTruthy();
const { addons } = await addonsResponse.json();
const original = addons.find((addon: { id: string }) => addon.id === 'collections');
expect(original).toBeDefined();
let collectionId: number | undefined;
try {
expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy();
const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } });
expect(created.ok()).toBeTruthy();
const body = await created.json();
collectionId = (body.collection ?? body).id as number;
const labelIds = new Map<string, number>();
for (const name of new Set(PLACES.flatMap((p) => p.labels))) {
const res = await page.request.post('/api/addons/collections/labels', {
data: { collection_id: collectionId, name, color: '#2563eb' },
});
expect(res.ok(), `label ${name}`).toBeTruthy();
const label = await res.json();
labelIds.set(name, (label.label ?? label).id);
}
for (const place of PLACES) {
const res = await page.request.post('/api/addons/collections/places', {
data: {
collection_id: collectionId,
name: place.name,
address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal',
lat: 38.71,
lng: -9.14,
},
});
expect(res.ok(), `place ${place.name}`).toBeTruthy();
const saved = await res.json();
if (!place.labels.length) continue;
const assign = await page.request.post('/api/addons/collections/labels/assign', {
data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] },
});
expect(assign.ok()).toBeTruthy();
}
await page.goto(`/collections/${collectionId}`);
const rows = page.locator('.col-lrow');
await expect(rows).toHaveCount(PLACES.length);
for (const place of PLACES) {
const row = rows.filter({ hasText: place.name });
const name = row.locator('.li .t');
await expect(name).toHaveText(place.name);
// The whole name fits: it is not cut to an ellipsis.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expectInside(row);
// Every label is either on the row or counted by its "+N" chip.
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
// Read without waiting: a row whose labels all fit has no "+N".
const [more] = await row.locator('.col-lrow-more').allTextContents();
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
}
// The map's label strip stays inside the map: whole, or folded into its button.
const topbar = page.locator('.col-map-topbar');
if (await topbar.count()) await expectInside(topbar);
} finally {
if (collectionId !== undefined) {
expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy();
}
expect(
(await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok()
).toBeTruthy();
}
});
}
-112
View File
@@ -1,112 +0,0 @@
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
#
# Automatic signing cannot work on a CI runner with no registered devices: it signs
# the archive for development and then needs a development provisioning profile, which
# requires a device. So this signs manually for distribution instead. The distribution
# certificate and the App Store provisioning profile are created (or reused) through the
# App Store Connect API key, which means no certificate or profile has to be stored as a
# secret: the key the workflow already holds is enough.
#
# The lane expects these in the environment (the workflow sets them):
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
# APPLE_TEAM_ID — the developer team
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
# RUNNER_TEMP — a writable scratch directory
default_platform(:ios)
APP_IDENTIFIER = "com.liketrek.trek"
PROJECT = "ios/App/App.xcodeproj"
# Every signed target with its bundle identifier: the app and its extensions
# (ios/scripts/add-extension.rb put them in the project). Each gets its own App
# Store profile, created or reused through the API key like the app's.
SIGNED_TARGETS = {
"App" => APP_IDENTIFIER,
"NotificationService" => "com.liketrek.trek.notifications",
"TREKWidgets" => "com.liketrek.trek.widgets",
}
platform :ios do
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
lane :beta do
key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
in_house: false,
)
# A throwaway keychain with a known password, so fastlane can unlock the imported
# private key for code signing (without the password it cannot set the key's access
# list and the certificate is never usable).
keychain_name = "trek_ci.keychain"
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false,
)
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
# Import the one distribution certificate from its stored .p12, rather than
# minting a new one each run — Apple caps distribution certificates at two, so
# a fresh one per build would quickly hit the limit. The matching App Store
# profile is created (or reused) through the API key.
import_certificate(
certificate_path: ENV.fetch("CERT_P12_PATH"),
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
keychain_name: keychain_name,
keychain_path: keychain_path,
keychain_password: keychain_password,
)
profiles = {}
SIGNED_TARGETS.each do |target, identifier|
get_provisioning_profile(
api_key: key,
app_identifier: identifier,
development: false,
)
profiles[identifier] = lane_context[SharedValues::SIGH_NAME]
# Pin each target to its profile for this build; automatic signing stays the
# default a developer sees when they open the project in Xcode.
update_code_signing_settings(
path: PROJECT,
use_automatic_signing: false,
team_id: ENV.fetch("APPLE_TEAM_ID"),
code_sign_identity: "Apple Distribution",
bundle_identifier: identifier,
profile_name: profiles[identifier],
targets: [target],
)
end
build_app(
project: PROJECT,
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
xcargs: [
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
].join(" "),
export_options: {
signingStyle: "manual",
provisioningProfiles: profiles,
},
)
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
# Internal testing needs no App Review, so nothing here submits the app for review.
upload_to_testflight(
api_key: key,
skip_waiting_for_build_processing: true,
skip_submission: true,
)
end
end
+3 -341
View File
@@ -7,14 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -22,58 +15,14 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9480DFBC2C2A112973DA468A;
remoteInfo = NotificationService;
};
B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = A3BE5653156025C97CA62C68;
remoteInfo = TREKWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */,
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -82,35 +31,15 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
0A579FBACE2CF30B89370F8E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */,
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */,
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -119,33 +48,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0FC8613857BD976EB58ECE0B /* iOS */ = {
isa = PBXGroup;
children = (
);
name = iOS;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
5198C9768EB9762A60701C92 /* Frameworks */,
74C1FC6022E562372A71EEC9 /* NotificationService */,
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
);
sourceTree = "<group>";
};
@@ -153,8 +64,6 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -179,44 +88,6 @@
path = App;
sourceTree = "<group>";
};
5198C9768EB9762A60701C92 /* Frameworks */ = {
isa = PBXGroup;
children = (
0FC8613857BD976EB58ECE0B /* iOS */,
2EE43D236E661AA975612A4B /* UserNotifications.framework */,
BC64330AC19625949E8DC05A /* WidgetKit.framework */,
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
192E69C7E66646BC39566925 /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
74C1FC6022E562372A71EEC9 /* NotificationService */ = {
isa = PBXGroup;
children = (
1E7522315D47F086145DC4B8 /* Info.plist */,
3DF4F4305878F949DD09193F /* NotificationService.entitlements */,
FFA8802204810ED580DE46F3 /* NotificationService.swift */,
);
name = NotificationService;
path = NotificationService;
sourceTree = "<group>";
};
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
isa = PBXGroup;
children = (
BE41D7212DCB0C78E00A8462 /* Info.plist */,
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
);
name = TREKWidgets;
path = TREKWidgets;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -227,13 +98,10 @@
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -243,40 +111,6 @@
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
9480DFBC2C2A112973DA468A /* NotificationService */ = {
isa = PBXNativeTarget;
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
buildPhases = (
1EA0B85A2E70AC56F83C09C9 /* Sources */,
C9AA4ADC0D397C620A35B6A3 /* Frameworks */,
9706E0B1D8DD6E0542723B5D /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationService;
productName = NotificationService;
productReference = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */;
productType = "com.apple.product-type.app-extension";
};
A3BE5653156025C97CA62C68 /* TREKWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */;
buildPhases = (
92D923C146596E50655B451D /* Sources */,
0A579FBACE2CF30B89370F8E /* Frameworks */,
04A03741C8AD60E63D4B11E7 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = TREKWidgets;
productName = TREKWidgets;
productReference = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -315,20 +149,11 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
9480DFBC2C2A112973DA468A /* NotificationService */,
A3BE5653156025C97CA62C68 /* TREKWidgets */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
04A03741C8AD60E63D4B11E7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -343,24 +168,9 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
9706E0B1D8DD6E0542723B5D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -369,39 +179,11 @@
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
92D923C146596E50655B451D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */,
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */,
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TREKWidgets;
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
};
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationService;
target = 9480DFBC2C2A112973DA468A /* NotificationService */;
targetProxy = 3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -422,31 +204,6 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
3ECF3537A139DB5CAC23E502 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
@@ -602,95 +359,9 @@
};
name = Release;
};
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.notifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A475CD70019910612C4D523B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.notifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
C5621F5D75100AAEBB1BD22A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A475CD70019910612C4D523B /* Release */,
686FA31AEC6FE5BA3EF95E99 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -709,15 +380,6 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C5621F5D75100AAEBB1BD22A /* Release */,
3ECF3537A139DB5CAC23E502 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
-6
View File
@@ -2,11 +2,5 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
+2 -27
View File
@@ -1,41 +1,16 @@
import UIKit
import Capacitor
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Taps on a TREK notification, and banners while the app is open.
UNUserNotificationCenter.current().delegate = self
// Override point for customization after application launch.
return true
}
// MARK: - Push (TrekPush in TrekShellPlugin.swift)
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
TrekPush.shared.didRegister(token: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
TrekPush.shared.didFail(error)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .list, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
if let url = response.notification.request.content.userInfo["url"] as? String {
TrekPush.shared.opened(url: url)
}
completionHandler()
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
-25
View File
@@ -71,10 +71,6 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>TREK uses your location to centre the map on you and to find places near you.</string>
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
@@ -86,27 +82,6 @@
apple-app-site-association, and a self-hosted app has no single domain to
verify against. SceneDelegate already forwards openURLContexts to
Capacitor, so no Swift change is needed. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Booking document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
<string>com.apple.ical.ics</string>
<string>com.apple.pkpass</string>
<string>com.apple.mail.email</string>
<string>public.email-message</string>
</array>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
+1 -84
View File
@@ -11,97 +11,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window?.rootViewController = TrekBridgeViewController()
window?.makeKeyAndVisible()
// Launched by "Open in TREK" on a booking document.
for context in connectionOptions.urlContexts where context.url.isFileURL {
TrekSharedFiles.receive(context.url)
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
// The app switcher takes its snapshot as the scene resigns active, so the
// cover goes on there and comes off when the app is back.
func sceneWillResignActive(_ scene: UIScene) {
TrekPrivacyCover.show(in: window)
}
func sceneDidBecomeActive(_ scene: UIScene) {
TrekPrivacyCover.hide()
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
// A booking document shared to TREK goes to the UI's import; anything
// else (the sign-in callback) stays with Capacitor.
let files = URLContexts.filter { $0.url.isFileURL }
for context in files { TrekSharedFiles.receive(context.url) }
let others = URLContexts.subtracting(files)
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
}
/// The blur over the app while it sits in the app switcher, when the app lock
/// is on (TrekShell.setPrivacyCover from the UI's lock setting).
enum TrekPrivacyCover {
private static let key = "TrekPrivacyCover"
private static weak var cover: UIView?
static var enabled: Bool {
get { UserDefaults.standard.bool(forKey: key) }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
static func show(in window: UIWindow?) {
guard enabled, cover == nil, let window else { return }
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
view.frame = window.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(view)
cover = view
}
static func hide() {
cover?.removeFromSuperview()
cover = nil
}
}
/// Booking documents shared to TREK ("Open in TREK"), held until the UI takes
/// them (TrekShell.takeSharedFile) and announced if it is already running.
enum TrekSharedFiles {
/// Above this the file is not a booking confirmation, and base64 over the
/// bridge would only stall the app.
static let maxBytes = 15 * 1024 * 1024
private(set) static var pending: [String: Any]?
static var onReceive: (([String: Any]) -> Void)?
static func receive(_ url: URL) {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url), data.count <= maxBytes else { return }
let file: [String: Any] = [
"name": url.lastPathComponent,
"mimeType": mimeType(for: url.pathExtension.lowercased()),
"data": data.base64EncodedString(),
]
pending = file
onReceive?(file)
}
static func take() -> [String: Any]? {
defer { pending = nil }
return pending
}
private static func mimeType(for ext: String) -> String {
switch ext {
case "pdf": return "application/pdf"
case "ics": return "text/calendar"
case "pkpass": return "application/vnd.apple.pkpass"
case "eml": return "message/rfc822"
default: return "application/octet-stream"
}
}
}
@@ -1,7 +1,5 @@
import UIKit
import Capacitor
import CryptoKit
import WebKit
/// Points the WebView at the TREK server the user picked instead of the bundled
/// address screen. The UI then comes from that server, in the version it runs,
@@ -19,24 +17,11 @@ class TrekBridgeViewController: CAPBridgeViewController {
return descriptor
}
/// The map tile store answers trek-tile:// (TrekTileSchemeHandler below). A
/// scheme handler can only be set on the configuration the WebView is built from.
private let tileHandler = TrekTileSchemeHandler()
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let configuration = super.webViewConfiguration(for: instanceConfiguration)
configuration.setURLSchemeHandler(tileHandler, forURLScheme: TrekTileSchemeHandler.scheme)
return configuration
}
override func capacitorDidLoad() {
TrekTileStore.shared.prune()
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
// The swipe from the left edge that goes back in every iOS app. It walks
// the SPA's history, the same entries Android's back button uses.
webView?.allowsBackForwardNavigationGestures = true
}
}
@@ -57,218 +42,3 @@ enum TrekServerStore {
return support.appendingPathComponent("offline-start-page.html")
}
}
// MARK: - Tile store (trek-tile://)
/// Map tiles the user looked at, on disk, for the WebView's maps
/// (client/src/native/tileCache.ts). WKWebView runs no service worker for a
/// self-hosted server, so without this nothing kept a tile once the app closed.
///
/// Each tile lives as `<sha256>.bin` with its content type in `<sha256>.type`.
/// A tile older than 30 days is never served again (Mapbox's limit for a device
/// cache, applied to every provider) and is fetched anew. The store keeps under
/// 250 MB: past that, the tiles used least recently go first. It sits in Caches,
/// which iOS may empty under storage pressure and never backs up.
final class TrekTileStore {
static let shared = TrekTileStore()
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
static let maxBytes: Int64 = 250 * 1024 * 1024
/// Pruning brings the store down to this share of the limit, so it does not
/// run again on the very next tile.
static let pruneTarget = 0.8
let directory: URL
let queue = DispatchQueue(label: "com.liketrek.trek.tiles", qos: .utility)
private var writesSincePrune = 0
private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appendingPathComponent("trek-tiles", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
/// The tile's identity without what changes per user or session: tokens and
/// keys come out, the rest of the query is sorted.
static func key(for url: URL) -> String {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let dropped: Set<String> = ["access_token", "sku", "key", "api_key", "apikey"]
let kept = components?.queryItems?
.filter { !dropped.contains($0.name.lowercased()) }
.sorted { $0.name < $1.name }
components?.queryItems = (kept?.isEmpty ?? true) ? nil : kept
let identity = components?.string ?? url.absoluteString
return SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
}
private func dataURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".bin") }
private func typeURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".type") }
/// A stored tile younger than 30 days, marked as just used. Call on `queue`.
func read(_ key: String) -> (data: Data, type: String)? {
let file = dataURL(key)
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path),
let created = attributes[.creationDate] as? Date else { return nil }
if Date().timeIntervalSince(created) > TrekTileStore.maxAge {
remove(key)
return nil
}
guard let data = try? Data(contentsOf: file) else { return nil }
let type = (try? String(contentsOf: typeURL(key), encoding: .utf8)) ?? "application/octet-stream"
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
return (data, type)
}
func write(_ key: String, data: Data, type: String) {
queue.async {
try? data.write(to: self.dataURL(key), options: .atomic)
try? type.write(to: self.typeURL(key), atomically: true, encoding: .utf8)
self.writesSincePrune += 1
if self.writesSincePrune >= 200 { self.pruneNow() }
}
}
private func remove(_ key: String) {
try? FileManager.default.removeItem(at: dataURL(key))
try? FileManager.default.removeItem(at: typeURL(key))
}
private struct Entry {
let key: String
let size: Int64
let used: Date
let created: Date
}
private func entries() -> [Entry] {
let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .creationDateKey]
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? []
return files.filter { $0.pathExtension == "bin" }.compactMap { file in
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
return Entry(
key: file.deletingPathExtension().lastPathComponent,
size: Int64(values.fileSize ?? 0),
used: values.contentModificationDate ?? .distantPast,
created: values.creationDate ?? .distantPast
)
}
}
/// Drops what is past 30 days, then the least recently used tiles while the
/// store is over its limit.
func prune() { queue.async { self.pruneNow() } }
private func pruneNow() {
writesSincePrune = 0
let now = Date()
var live: [Entry] = []
for entry in entries() {
if now.timeIntervalSince(entry.created) > TrekTileStore.maxAge { remove(entry.key) } else { live.append(entry) }
}
var total = live.reduce(Int64(0)) { $0 + $1.size }
guard total > TrekTileStore.maxBytes else { return }
let target = Int64(Double(TrekTileStore.maxBytes) * TrekTileStore.pruneTarget)
for entry in live.sorted(by: { $0.used < $1.used }) {
if total <= target { break }
remove(entry.key)
total -= entry.size
}
}
func usage(_ done: @escaping (Int64) -> Void) {
queue.async { done(self.entries().reduce(Int64(0)) { $0 + $1.size }) }
}
func clear(_ done: @escaping () -> Void) {
queue.async {
try? FileManager.default.removeItem(at: self.directory)
try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true)
done()
}
}
}
/// Serves trek-tile://host/path from the store, or from https://host/path when
/// it has no fresh copy, storing what comes back. Offline and not stored, the
/// map gets an error and draws nothing there, as it would for a missing tile.
final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
static let scheme = "trek-tile"
/// Tasks WebKit still wants an answer for. Touched on the main thread only:
/// start and stop arrive there, and every answer is posted back there.
private var active = Set<ObjectIdentifier>()
private let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 20
configuration.httpMaximumConnectionsPerHost = 4
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
// A unique app User-Agent, as OpenFreeMap and OpenStreetMap ask of apps.
configuration.httpAdditionalHeaders = ["User-Agent": "TREK-App/\(version) (iOS; +https://liketrek.com)"]
return URLSession(configuration: configuration)
}()
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let id = ObjectIdentifier(urlSchemeTask)
guard let url = urlSchemeTask.request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
urlSchemeTask.didFailWithError(URLError(.badURL))
return
}
components.scheme = "https"
guard let remote = components.url else {
urlSchemeTask.didFailWithError(URLError(.badURL))
return
}
active.insert(id)
let key = TrekTileStore.key(for: remote)
let store = TrekTileStore.shared
let accept = urlSchemeTask.request.value(forHTTPHeaderField: "Accept")
store.queue.async {
if let hit = store.read(key) {
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: 200, type: hit.type, data: hit.data)
}
return
}
var request = URLRequest(url: remote)
if let accept { request.setValue(accept, forHTTPHeaderField: "Accept") }
self.session.dataTask(with: request) { data, response, _ in
guard let data, let http = response as? HTTPURLResponse else {
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: 504, type: "text/plain", data: Data())
}
return
}
let type = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
if http.statusCode == 200, !data.isEmpty { store.write(key, data: data, type: type) }
DispatchQueue.main.async {
self.respond(urlSchemeTask, id: id, url: url, status: http.statusCode, type: type, data: data)
}
}.resume()
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
active.remove(ObjectIdentifier(urlSchemeTask))
}
/// Answers once, and never after WebKit stopped the task: that would throw.
private func respond(_ task: WKURLSchemeTask, id: ObjectIdentifier, url: URL, status: Int, type: String, data: Data) {
guard active.remove(id) != nil else { return }
let headers = [
"Content-Type": type,
"Content-Length": String(data.count),
// The page's origin is the user's server; WebGL reads tiles cross-origin.
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
]
guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else {
task.didFailWithError(URLError(.badServerResponse))
return
}
task.didReceive(response)
task.didReceive(data)
task.didFinish()
}
}
-444
View File
@@ -1,14 +1,5 @@
import AuthenticationServices
import Capacitor
import CoreLocation
import CryptoKit
import Security
import UserNotifications
import WidgetKit
#if canImport(ActivityKit)
import ActivityKit
#endif
import LocalAuthentication
import UIKit
import WebKit
@@ -25,224 +16,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "biometryInfo", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "authenticateBiometric", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPrivacyCover", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startLocation", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopLocation", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeOpenedPush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
/// an older binary lacks. The UI asks before it calls (native/capabilities.ts),
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
// MARK: - Shared booking documents
override public func load() {
TrekSharedFiles.onReceive = { [weak self] file in
DispatchQueue.main.async { self?.notifyListeners("sharedFile", data: file) }
}
TrekPush.shared.onOpen = { [weak self] url in
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
}
}
// MARK: - Widgets and the travel-day Live Activity
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
/// already translated) where the widget extension reads it, and redraws it.
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
call.reject("json is required")
return
}
TrekSharedKeychain.write("widget.snapshot", data)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
/// Starts the travel day's Live Activity, or updates the one running.
@objc func syncTripDay(_ call: CAPPluginCall) {
guard let title = call.getString("tripTitle"), let path = call.getString("path"),
let dayLabel = call.getString("dayLabel"), let nextName = call.getString("nextName") else {
call.reject("tripTitle, path, dayLabel and nextName are required")
return
}
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
call.resolve(["running": false])
return
}
let state = TripDayAttributes.ContentState(
dayLabel: dayLabel,
nextName: nextName,
nextTime: call.getString("nextTime"),
done: call.getInt("done") ?? 0,
total: call.getInt("total") ?? 0
)
// Stale at the end of the day, so a forgotten one fades out on its own.
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
let content = ActivityContent(state: state, staleDate: stale)
if let running = Activity<TripDayAttributes>.activities.first(where: { $0.attributes.path == path }) {
Task { await running.update(content) }
} else {
for other in Activity<TripDayAttributes>.activities {
Task { await other.end(nil, dismissalPolicy: .immediate) }
}
_ = try? Activity.request(attributes: TripDayAttributes(tripTitle: title, path: path), content: content)
}
call.resolve(["running": true])
return
}
#endif
call.resolve(["running": false])
}
@objc func endTripDay(_ call: CAPPluginCall) {
#if canImport(ActivityKit)
if #available(iOS 16.2, *) {
for activity in Activity<TripDayAttributes>.activities {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
}
#endif
call.resolve()
}
// MARK: - Push
/// Asks for permission, registers with APNs and answers with a Web Push
/// subscription the server stores like a browser's (native/push.ts): the
/// relay endpoint for this device's token, and this device's keys, which the
/// notification extension uses to decrypt.
@objc func registerPush(_ call: CAPPluginCall) {
TrekPush.shared.register { result in
switch result {
case .success(let subscription): call.resolve(subscription)
case .failure(let error):
let code = (error as? TrekPush.PushError) == .denied ? "DENIED" : "FAILED"
call.reject(error.localizedDescription, code)
}
}
}
/// The notification the app was opened from, before the UI was there to hear of it.
@objc func takeOpenedPush(_ call: CAPPluginCall) {
if let url = TrekPush.shared.takeOpened() {
call.resolve(["url": url])
} else {
call.resolve(["url": NSNull()])
}
}
/// The document shared to TREK before the UI was there to hear of it.
@objc func takeSharedFile(_ call: CAPPluginCall) {
if let file = TrekSharedFiles.take() {
call.resolve(["file": file])
} else {
call.resolve(["file": NSNull()])
}
}
// MARK: - Location
/// One CoreLocation stream for the UI's navigator.geolocation (native/geolocation.ts).
/// The WebView's own asks for every server origin, and again and again; this
/// is the one system prompt the app's location string describes.
private lazy var location = TrekLocation { [weak self] payload in
self?.notifyListeners("location", data: payload)
}
@objc func startLocation(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.location.start(highAccuracy: call.getBool("highAccuracy") ?? false)
call.resolve()
}
}
@objc func stopLocation(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.location.stop()
call.resolve()
}
}
// MARK: - App lock
/// Which biometry the device offers for the app lock, if any.
@objc func biometryInfo(_ call: CAPPluginCall) {
let context = LAContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
let type: String
switch context.biometryType {
case .faceID: type = "faceID"
case .touchID: type = "touchID"
default: type = "none"
}
call.resolve(["available": available, "type": type])
}
/// Face ID / Touch ID with the passcode as the way out, the way system apps
/// lock. Rejects with CANCELLED when the user backs out.
@objc func authenticateBiometric(_ call: CAPPluginCall) {
let reason = call.getString("reason") ?? "Unlock TREK"
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { ok, error in
if ok {
call.resolve(["ok": true])
} else if let laError = error as? LAError, [.userCancel, .appCancel, .systemCancel].contains(laError.code) {
call.reject("Unlock cancelled", "CANCELLED")
} else {
call.reject(error?.localizedDescription ?? "Unlock failed", "FAILED")
}
}
}
/// While on, the app switcher shows a blurred cover instead of the screen
/// (SceneDelegate), so a locked app does not show its content there.
@objc func setPrivacyCover(_ call: CAPPluginCall) {
TrekPrivacyCover.enabled = call.getBool("enabled") ?? false
call.resolve()
}
// MARK: - Map tile store
/// How much the stored map tiles take on disk, for the offline settings.
@objc func getTileCacheUsage(_ call: CAPPluginCall) {
TrekTileStore.shared.usage { bytes in
call.resolve(["bytes": Int(bytes), "limit": Int(TrekTileStore.maxBytes)])
}
}
@objc func clearTileCache(_ call: CAPPluginCall) {
TrekTileStore.shared.clear { call.resolve() }
}
@objc func getCapabilities(_ call: CAPPluginCall) {
let info = Bundle.main.infoDictionary
call.resolve([
"shellApi": TrekShellPlugin.shellApi,
"platform": "ios",
"appVersion": info?["CFBundleShortVersionString"] as? String ?? "",
"build": info?["CFBundleVersion"] as? String ?? "",
"features": TrekShellPlugin.features,
])
}
private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) {
@@ -365,222 +140,3 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
}
}
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
final class TrekLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private let emit: ([String: Any]) -> Void
private var running = false
init(emit: @escaping ([String: Any]) -> Void) {
self.emit = emit
super.init()
manager.delegate = self
}
func start(highAccuracy: Bool) {
manager.desiredAccuracy = highAccuracy ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
running = true
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .denied, .restricted:
emitError(code: 1, message: "Location permission denied")
default:
manager.startUpdatingLocation()
manager.startUpdatingHeading()
}
}
func stop() {
running = false
manager.stopUpdatingLocation()
manager.stopUpdatingHeading()
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard running else { return }
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
manager.startUpdatingHeading()
case .denied, .restricted:
emitError(code: 1, message: "Location permission denied")
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let last = locations.last else { return }
emit([
"latitude": last.coordinate.latitude,
"longitude": last.coordinate.longitude,
"accuracy": last.horizontalAccuracy,
"altitude": last.verticalAccuracy >= 0 ? last.altitude as Any : NSNull(),
"speed": last.speed >= 0 ? last.speed as Any : NSNull(),
"heading": last.course >= 0 ? last.course as Any : NSNull(),
"timestamp": last.timestamp.timeIntervalSince1970 * 1000,
])
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let code = (error as? CLError)?.code == .denied ? 1 : 2
emitError(code: code, message: error.localizedDescription)
}
private func emitError(code: Int, message: String) {
emit(["error": ["code": code, "message": message]])
}
}
/// The app's side of TREK push: an APNs token, the device's Web Push keys in the
/// keychain group the notification extension shares, and the relay endpoint
/// that ties them together.
final class TrekPush {
static let shared = TrekPush()
static let relay = "https://push.liketrek.com/v1/apns"
/// The keychain group the notification extension reads the keys from.
static let keychainGroup = "com.liketrek.trek.shared"
enum PushError: Error, Equatable { case denied, failed }
private var pending: [(Result<[String: Any], Error>) -> Void] = []
private var openedURL: String?
var onOpen: ((String) -> Void)?
func register(_ done: @escaping (Result<[String: Any], Error>) -> Void) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
guard granted else {
done(.failure(PushError.denied))
return
}
DispatchQueue.main.async {
self.pending.append(done)
UIApplication.shared.registerForRemoteNotifications()
}
}
}
func didRegister(token: Data) {
let hex = token.map { String(format: "%02x", $0) }.joined()
#if DEBUG
let environment = "sandbox"
#else
let environment = "production"
#endif
let result: Result<[String: Any], Error>
if let keys = TrekPushKeys.loadOrCreate() {
result = .success([
"endpoint": "\(TrekPush.relay)/\(environment)/\(hex)",
"keys": ["p256dh": keys.publicKey, "auth": keys.auth],
])
} else {
result = .failure(PushError.failed)
}
let waiting = pending
pending.removeAll()
waiting.forEach { $0(result) }
}
func didFail(_ error: Error) {
let waiting = pending
pending.removeAll()
waiting.forEach { $0(.failure(error)) }
}
func opened(url: String) {
if let onOpen { onOpen(url) } else { openedURL = url }
}
func takeOpened() -> String? {
defer { openedURL = nil }
return openedURL
}
}
/// The device's Web Push key pair (P-256) and auth secret, kept in the shared
/// keychain group so the notification extension can decrypt what the server
/// encrypted for them (RFC 8291). Base64url without padding, as the server reads them.
enum TrekPushKeys {
private static let privateKeyAccount = "push.privateKey"
private static let authAccount = "push.auth"
static func loadOrCreate() -> (publicKey: String, auth: String)? {
if let raw = TrekSharedKeychain.read(privateKeyAccount), let auth = TrekSharedKeychain.read(authAccount),
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) {
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
let key = P256.KeyAgreement.PrivateKey()
var auth = Data(count: 16)
let status = auth.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
guard status == errSecSuccess,
TrekSharedKeychain.write(privateKeyAccount, key.rawRepresentation),
TrekSharedKeychain.write(authAccount, auth) else { return nil }
return (base64url(key.publicKey.x963Representation), base64url(auth))
}
static func base64url(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
/// Items the app shares with its extensions (the notification extension, the
/// widgets) through one keychain group, so no App Group is needed: the push keys
/// and the widget snapshot.
enum TrekSharedKeychain {
static let service = "com.liketrek.trek.push"
private static func query(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
]
}
/// The team-prefixed shared group, read off a probe item in the default group.
static var accessGroup: String = {
let probe: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "push.groupProbe",
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
]
var result: CFTypeRef?
var status = SecItemCopyMatching(probe as CFDictionary, &result)
if status == errSecItemNotFound {
var add = probe
add.removeValue(forKey: kSecReturnAttributes as String)
add[kSecValueData as String] = Data()
SecItemAdd(add as CFDictionary, nil)
status = SecItemCopyMatching(probe as CFDictionary, &result)
}
// The default group is the first one in the entitlement: "TEAMID.com.liketrek.trek.shared".
if status == errSecSuccess, let attributes = result as? [String: Any],
let group = attributes[kSecAttrAccessGroup as String] as? String,
let prefix = group.split(separator: ".").first {
return "\(prefix).\(TrekPush.keychainGroup)"
}
return TrekPush.keychainGroup
}()
static func read(_ account: String) -> Data? {
var q = query(account)
q[kSecReturnData as String] = true
var result: CFTypeRef?
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
}
@discardableResult
static func write(_ account: String, _ data: Data) -> Bool {
SecItemDelete(query(account) as CFDictionary)
var q = query(account)
q[kSecValueData as String] = data
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
}
}
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK Notifications</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
@@ -1,109 +0,0 @@
import CryptoKit
import Security
import UserNotifications
/// Decrypts a TREK push before iOS shows it.
///
/// The user's server encrypts each notification for this device exactly as Web
/// Push does for a browser (RFC 8291, aes128gcm), and liketrek's relay hands the
/// ciphertext to APNs as `enc`, with a placeholder text and mutable-content set.
/// Here the device's keys (the app made them, TrekPushKeys in the app target)
/// open it, and the real title, text and link replace the placeholder. Without
/// the keys, or if anything does not add up, the placeholder stays: a
/// notification arrives either way, and nothing on the way could read it.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var content: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
self.content = content
if let encoded = request.content.userInfo["enc"] as? String,
let record = Data(base64Encoded: encoded),
let keys = PushKeys.load(),
let plaintext = WebPushRecord.open(record, keys: keys),
let payload = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
if let title = payload["title"] as? String, !title.isEmpty { content.title = title }
if let body = payload["body"] as? String { content.body = body }
if let url = payload["url"] as? String { content.userInfo["url"] = url }
if let tag = payload["tag"] as? String { content.threadIdentifier = tag }
}
contentHandler(content)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let content { contentHandler(content) }
}
}
/// The device's Web Push keys, from the keychain group it shares with the app.
struct PushKeys {
let privateKey: P256.KeyAgreement.PrivateKey
let auth: Data
static func load() -> PushKeys? {
guard let raw = read("push.privateKey"), let auth = read("push.auth"),
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) else { return nil }
return PushKeys(privateKey: key, auth: auth)
}
private static func read(_ account: String) -> Data? {
// No access group named: the extension's first keychain group is the
// shared one (its entitlements list only that), where the app stored them.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: CFTypeRef?
return SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
}
}
/// One aes128gcm record (RFC 8188) with Web Push keys (RFC 8291), opened.
enum WebPushRecord {
static func open(_ record: Data, keys: PushKeys) -> Data? {
let bytes = [UInt8](record)
// salt (16) | record size (4) | key id length (1) | key id (the sender's public key)
guard bytes.count > 21 else { return nil }
let salt = Data(bytes[0..<16])
let idLength = Int(bytes[20])
guard bytes.count > 21 + idLength + 16 else { return nil }
let senderKeyBytes = Data(bytes[21..<(21 + idLength)])
let ciphertext = Data(bytes[(21 + idLength)...])
guard let senderKey = try? P256.KeyAgreement.PublicKey(x963Representation: senderKeyBytes),
let shared = try? keys.privateKey.sharedSecretFromKeyAgreement(with: senderKey) else { return nil }
let sharedBytes = shared.withUnsafeBytes { Data($0) }
let receiverKey = keys.privateKey.publicKey.x963Representation
// IKM = HKDF(auth, ecdh, "WebPush: info" 0 receiver sender, 32)
var keyInfo = Data("WebPush: info".utf8)
keyInfo.append(0)
keyInfo.append(receiverKey)
keyInfo.append(senderKeyBytes)
let prkKey = HKDF<SHA256>.extract(inputKeyMaterial: SymmetricKey(data: sharedBytes), salt: keys.auth)
let ikm = HKDF<SHA256>.expand(pseudoRandomKey: prkKey, info: keyInfo, outputByteCount: 32)
let prk = HKDF<SHA256>.extract(inputKeyMaterial: ikm, salt: salt)
var cekInfo = Data("Content-Encoding: aes128gcm".utf8)
cekInfo.append(0)
var nonceInfo = Data("Content-Encoding: nonce".utf8)
nonceInfo.append(0)
let cek = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: cekInfo, outputByteCount: 16)
let nonceKey = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: nonceInfo, outputByteCount: 12)
let nonceBytes = nonceKey.withUnsafeBytes { Data($0) }
guard let nonce = try? AES.GCM.Nonce(data: nonceBytes),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext.dropLast(16), tag: ciphertext.suffix(16)),
let padded = try? AES.GCM.open(box, using: cek) else { return nil }
// The last record ends in 0x02 and optional zero padding.
var end = padded.count
while end > 0 && padded[padded.startIndex + end - 1] == 0 { end -= 1 }
guard end > 0, padded[padded.startIndex + end - 1] == 2 else { return nil }
return padded.prefix(end - 1)
}
}
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TREK</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -1,118 +0,0 @@
import SwiftUI
import WidgetKit
/// "Next trip" on the home screen: the trip and how far off it is, or while it
/// runs, today's day and the next stops (medium size).
struct NextTripEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct NextTripProvider: TimelineProvider {
func placeholder(in context: Context) -> NextTripEntry {
NextTripEntry(date: Date(), snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
}
/// The app reloads the timeline whenever it writes a new snapshot; past
/// midnight the countdown moves on by itself.
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
let now = Date()
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
completion(Timeline(entries: [entry], policy: .after(midnight)))
}
}
struct NextTripWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: NextTripEntry
var body: some View {
content
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
.modifier(WidgetBackground())
}
@ViewBuilder
private var content: some View {
if let snapshot = entry.snapshot, let trip = snapshot.trip {
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(trip.title)
.font(.headline)
.lineLimit(2)
if let today = snapshot.today {
Text(today.dayLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if family != .systemSmall {
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
}
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
Spacer(minLength: 0)
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
.font(.title3.weight(.bold))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("TREK").font(.headline)
Text(entry.snapshot?.labels.noTrip ?? "")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
if stops.isEmpty {
Text(nothing).font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
HStack(spacing: 6) {
Text(stop.time ?? "")
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .leading)
Text(stop.name).font(.caption).lineLimit(1)
}
}
}
}
}
}
/// The system background on iOS 17 and later, the plain one before.
struct WidgetBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(.fill.tertiary, for: .widget)
} else {
content.padding()
}
}
}
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
NextTripWidgetView(entry: entry)
}
.configurationDisplayName("TREK")
.description("Your next trip, and today's plan while you travel.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
@@ -1,12 +0,0 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the next trip on the home screen, and the travel day as a
/// Live Activity.
@main
struct TREKWidgetsBundle: WidgetBundle {
var body: some Widget {
NextTripWidget()
TripDayLiveActivity()
}
}
@@ -1,24 +0,0 @@
import ActivityKit
import Foundation
/// The Live Activity for a travel day: which trip and day, and the next stop.
/// Compiled into both the app (which starts and updates it, TrekShellPlugin)
/// and the widget extension (which draws it, TripDayLiveActivity).
@available(iOS 16.1, *)
struct TripDayAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
/// "Day 2 of 5", already in the user's language.
var dayLabel: String
/// The next stop, or the label for "all done" once the day's stops are behind.
var nextName: String
/// The next stop's time as the user formats it, if it has one.
var nextTime: String?
/// Stops behind, out of the day's stops.
var done: Int
var total: Int
}
var tripTitle: String
/// The app path a tap opens.
var path: String
}
@@ -1,55 +0,0 @@
import ActivityKit
import SwiftUI
import WidgetKit
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
/// its time, and how much of the day is behind.
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
HStack(alignment: .firstTextBaseline) {
Image(systemName: "mappin.circle.fill")
Text(context.state.nextName).font(.headline).lineLimit(1)
Spacer()
if let time = context.state.nextTime {
Text(time).font(.headline.monospacedDigit())
}
}
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
.padding()
.widgetURL(WidgetSnapshot.link(context.attributes.path))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.state.dayLabel, systemImage: "map").font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
if let time = context.state.nextTime {
Text(time).font(.caption.monospacedDigit())
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.nextName).font(.headline).lineLimit(1)
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
}
}
} compactLeading: {
Image(systemName: "mappin.circle.fill")
} compactTrailing: {
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
.font(.caption2.monospacedDigit())
} minimal: {
Image(systemName: "mappin.circle.fill")
}
.widgetURL(WidgetSnapshot.link(context.attributes.path))
}
}
}
@@ -1,71 +0,0 @@
import Foundation
import Security
/// What the app last told the widgets (native/widgets.ts writes it through
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
/// extensions share). Texts arrive already translated, so the widget needs no
/// localisation of its own and always speaks the language the app does.
struct WidgetSnapshot: Codable {
struct Trip: Codable {
var title: String
var start: String
var end: String
var path: String
}
struct Stop: Codable, Hashable {
var time: String?
var name: String
}
struct Today: Codable {
var dayLabel: String
var stops: [Stop]
}
struct Labels: Codable {
var nextTrip: String
var today: String
var noTrip: String
/// Contains "{n}".
var inDays: String
var tomorrow: String
var nothingPlanned: String
}
var trip: Trip?
var today: Today?
var labels: Labels
static func load() -> WidgetSnapshot? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.liketrek.trek.push",
kSecAttrAccount as String: "widget.snapshot",
kSecReturnData as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
/// Whole days from today to the trip's start, in the device's calendar.
func daysUntilStart(from now: Date = Date()) -> Int? {
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
let calendar = Calendar.current
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
}
static func day(_ iso: String) -> Date? {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: String(iso.prefix(10)))
}
/// The app link a tap opens: com.liketrek.trek://navigate<path>.
static func link(_ path: String) -> URL? {
URL(string: "com.liketrek.trek://navigate\(path.hasPrefix("/") ? path : "/" + path)")
}
}
-74
View File
@@ -1,74 +0,0 @@
# Adds an app extension target to App.xcodeproj and embeds it in the app.
#
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
#
# type is "notification" (Notification Service Extension) or "widget" (WidgetKit,
# which also carries the Live Activities). The extension's sources live in
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
# project file stays checked in, so the targets show in Xcode like any other;
# this script is how they got there, and running it again changes nothing.
require 'xcodeproj'
name, bundle_id, type, *frameworks = ARGV
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget> [frameworks...]' unless name && bundle_id && type
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
project = Xcodeproj::Project.open(project_path)
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
if project.targets.any? { |t| t.name == name }
puts "#{name} is already in the project"
exit 0
end
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
# new_target links Foundation by a path into one SDK version; it is linked
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
ref = f.file_ref
f.remove_from_project
ref&.remove_from_project
end
group = project.main_group.find_subpath(name, true)
group.set_source_tree('<group>')
group.set_path(name)
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
sources.each do |file|
ref = group.new_reference(file)
target.add_file_references([ref]) if file.end_with?('.swift')
end
frameworks.each do |framework|
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
target.frameworks_build_phase.add_file_reference(ref, true)
end
app_settings = app.build_configurations.first.build_settings
target.build_configurations.each do |config|
s = config.build_settings
s['PRODUCT_BUNDLE_IDENTIFIER'] = bundle_id
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
s['INFOPLIST_FILE'] = "#{name}/Info.plist"
s['GENERATE_INFOPLIST_FILE'] = 'NO'
s['CODE_SIGN_ENTITLEMENTS'] = "#{name}/#{name}.entitlements"
s['CODE_SIGN_STYLE'] = 'Automatic'
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
s['SWIFT_VERSION'] = '5.0'
s['TARGETED_DEVICE_FAMILY'] = '1,2'
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
s['SKIP_INSTALL'] = 'YES'
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
end
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
app.new_copy_files_build_phase('Embed Foundation Extensions')
embed.symbol_dst_subfolder_spec = :plug_ins
build_file = embed.add_file_reference(target.product_reference, true)
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
app.add_dependency(target)
project.save
puts "added #{name} (#{bundle_id})"
-24
View File
@@ -1,24 +0,0 @@
# Compiles a source file of an extension into the App target too.
#
# ruby ios/scripts/share-with-app.rb <extension-group> <file.swift>
#
# For types both sides need: the Live Activity's attributes are started by the
# app and drawn by the widget extension, so both must know them. Running it
# again changes nothing.
require 'xcodeproj'
group_name, file = ARGV
abort 'usage: share-with-app.rb <extension-group> <file.swift>' unless group_name && file
project = Xcodeproj::Project.open(File.expand_path('../App/App.xcodeproj', __dir__))
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
group = project.main_group[group_name] or abort "no group #{group_name}"
ref = group.files.find { |f| f.path == file } or abort "no #{file} in #{group_name}"
if app.source_build_phase.files_references.include?(ref)
puts "#{file} is already in App"
else
app.add_file_references([ref])
project.save
puts "#{file} now compiles into App too"
end
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

+23 -62
View File
@@ -3,8 +3,6 @@ import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
@@ -149,17 +147,11 @@ function topbar(onBack: () => void): HTMLElement {
// ── Welcome ─────────────────────────────────────────────────────────────────
function showWelcome(opts: { back?: boolean } = {}): void {
// The full TREK wordmark, in the variant that reads on the current scheme.
const hero = el(
'span',
{ className: 'hero-logo' },
el('img', { src: logoWhite, alt: 'TREK', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' })
);
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' });
const connect = el(
'button',
{ type: 'button', className: 'btn btn-ghost' },
{ type: 'button', className: 'btn' },
el(
'span',
{ className: 'btn-row' },
@@ -175,7 +167,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
const cloud = el(
'button',
{ type: 'button', className: 'btn' },
{ type: 'button', className: 'btn btn-ghost' },
el(
'span',
{ className: 'btn-row' },
@@ -189,7 +181,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
showCloud();
});
const demo = el('button', { type: 'button', className: 'demo-link' }, t('native.connect.tryDemo'));
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo));
show(
@@ -197,10 +189,10 @@ function showWelcome(opts: { back?: boolean } = {}): void {
'div',
{},
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('h1', { className: 'title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
cloud,
connect,
cloud,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
@@ -256,6 +248,9 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
fail(result.reason);
});
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo, error));
show(
el(
'div',
@@ -264,6 +259,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
opts
@@ -299,13 +295,7 @@ function showCloud(opts: { back?: boolean } = {}): void {
'div',
{},
topbar(() => showWelcome({ back: true })),
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el(
'div',
@@ -336,27 +326,15 @@ function money(cents: number): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
// A sample catalogue for the dev preview only: the cloud site is behind a gate
// during development, so its /api/plans is unreachable from a local build. Never
// used in a real build (see the import.meta.env.DEV guard below).
const SAMPLE_PLANS: CloudPlan[] = [
{ id: 'start', name: 'Start', monthlyCents: 599, yearlyCents: 5990, aiDocuments: 0, storageGb: 10 },
{ id: 'plus', name: 'Plus', monthlyCents: 999, yearlyCents: 9990, aiDocuments: 100, storageGb: 50 },
{ id: 'pro', name: 'Pro', monthlyCents: 1599, yearlyCents: 15990, aiDocuments: 500, storageGb: 200 },
{ id: 'studio', name: 'Studio', monthlyCents: 2399, yearlyCents: 23990, aiDocuments: 2000, storageGb: 1000 },
];
async function fetchPlans(): Promise<CloudPlan[] | null> {
try {
const res = await get(`${CLOUD_URL}/api/plans`);
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
const plans = (data as { plans?: CloudPlan[] })?.plans;
if (Array.isArray(plans) && plans.length) return plans;
return Array.isArray(plans) && plans.length ? plans : null;
} catch {
// Fall through to the preview fallback.
return null;
}
if (import.meta.env?.DEV) return SAMPLE_PLANS;
return null;
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
@@ -374,9 +352,6 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
// green tick, so "none" never reads as a perk.
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => subscribe(plan, interval));
return el(
@@ -393,9 +368,7 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
{ className: 'plan-feats' },
feat(t('native.plans.unlimited')),
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
plan.aiDocuments > 0
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
: featOff(t('native.plans.noAi'))
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi'))
),
sub
);
@@ -408,7 +381,12 @@ function showPlans(opts: { back?: boolean } = {}): void {
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
const yearBtn = el(
'button',
{ type: 'button' },
t('native.plans.yearly'),
el('span', { className: 'save' }, t('native.plans.save'))
);
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn);
const paintSeg = () => {
@@ -419,7 +397,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const list = plans;
if (!list) return;
content.className = 'plans';
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)));
};
const choose = (next: 'month' | 'year') => {
if (interval === next) return;
@@ -516,7 +494,6 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
autocapitalize: 'off',
spellcheck: false,
value: '',
placeholder: t('native.claim.addressLabel'),
});
slugInput.setAttribute('inputmode', 'url');
slugInput.setAttribute('autocomplete', 'off');
@@ -530,13 +507,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
);
const slugNote = el('p', { className: 'hint' });
const emailInput = el('input', {
type: 'email',
value: '',
autocapitalize: 'off',
spellcheck: false,
placeholder: t('native.claim.emailLabel'),
});
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false });
emailInput.setAttribute('inputmode', 'email');
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
@@ -811,17 +782,7 @@ async function start(): Promise<void> {
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
let url: string | null;
try {
({ url } = await TrekShell.getServer());
} catch {
// No native shell under us (a web preview, or a simulator without the
// plugin): there is no remembered server, so start at the welcome screen
// rather than leaving a blank page.
showWelcome();
await SplashScreen.hide().catch(() => {});
return;
}
const { url } = await TrekShell.getServer();
if (!url) {
showWelcome();
} else {
+27 -279
View File
@@ -4,49 +4,6 @@
* background and never flashes. Everything past this screen is the UI the
* chosen server delivers.
*/
/*
* TREK's own type, self-hosted (latin subset, bundled — the shell loads offline
* and hotlinks nothing). Poppins is the display face across the app and
* liketrek.com; MuseoModerno draws the "cloud" of the TREK Cloud wordmark.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/poppins-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/poppins-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/poppins-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('./fonts/museomoderno-italic-600.woff2') format('woff2');
}
:root {
--bg: #09090b;
--bg-2: #101013;
@@ -61,13 +18,6 @@
--accent-ink: #09090b;
--danger: #f87171;
--ok: #4ade80;
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
--wash-2: rgba(244, 114, 182, 0.08);
--veil-top: rgba(9, 9, 11, 0.4);
--veil-mid: rgba(9, 9, 11, 0.72);
--font: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
@@ -89,11 +39,6 @@
--accent-ink: #ffffff;
--danger: #dc2626;
--ok: #16a34a;
--grid: rgba(17, 24, 39, 0.075);
--wash-1: rgba(79, 70, 229, 0.1);
--wash-2: rgba(219, 39, 119, 0.07);
--veil-top: rgba(255, 255, 255, 0.12);
--veil-mid: rgba(255, 255, 255, 0.74);
color-scheme: light;
}
.shell-logo,
@@ -111,11 +56,9 @@ html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(90% 55% at 50% -12%, var(--wash-1), transparent 62%),
radial-gradient(80% 50% at 50% 112%, var(--wash-2), transparent 64%), var(--bg);
background: var(--bg);
color: var(--ink);
font-family: var(--font);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@@ -123,47 +66,14 @@ body {
-webkit-user-select: none;
}
/*
* The backdrop, in two fixed layers behind the content. First a soft dusk wash
* at top and bottom — a trace of the colour the app's photos carry, never loud
* enough to read as decoration. Then a quiet grid that fades out through a
* radial mask: depth, the way liketrek.com's hero carries it.
*/
/* The grid, strongest at the top-right and fading left and down — the half the
photo leaves open, so the two meet in a soft seam down the middle. */
/* An ambient glow behind the content, so the flat dark gains a little depth. */
body::before {
content: '';
position: fixed;
inset: 0 0 auto;
height: 440px;
background-image:
linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 54px 54px;
-webkit-mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/*
* A travel photo as a top band (Kyoto at dusk, the same image liketrek.com uses
* in its pricing section), under a dark gradient and faded out at the bottom so
* the headline stays legible. Dark-first; in light mode it steps back to the
* quiet grid instead, which reads better on a white page.
*/
/* The travel photo (Kyoto at dusk, as on liketrek.com), anchored left and faded
out toward the right so it hands over to the grid, and down into the page.
The veil over it takes the scheme's page colour: a dark dusk in dark mode, a
bright morning haze in light mode, so the black wordmark still reads on it. */
body::after {
content: '';
position: fixed;
inset: 0 0 auto;
height: 440px;
inset: 0;
background:
linear-gradient(180deg, var(--veil-top) 0%, var(--veil-mid) 62%, var(--bg) 100%),
url('./img/kyoto.webp') left 28% / cover no-repeat;
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
radial-gradient(60% 50% at 50% -5%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 70%),
radial-gradient(50% 40% at 50% 110%, color-mix(in srgb, var(--ink) 4%, transparent), transparent 70%);
pointer-events: none;
z-index: 0;
}
@@ -303,47 +213,11 @@ body::after {
/* The big hero mark on the welcome screen, with a gentle breathing float. */
.hero-mark {
width: 80px;
height: 80px;
margin-bottom: 20px;
width: 76px;
height: 76px;
margin-bottom: 18px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
display: block;
height: 62px;
width: auto;
}
.hero-logo .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.hero-logo .on-dark {
display: none;
}
.hero-logo .on-light {
display: block;
}
}
@keyframes logoRise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--ink) 18%, transparent));
}
@keyframes floaty {
0%,
@@ -357,69 +231,24 @@ body::after {
/* ── Type ────────────────────────────────────────────────────────────────── */
.title {
font-size: 2.05rem;
line-height: 1.1;
font-size: 1.9rem;
line-height: 1.12;
font-weight: 700;
letter-spacing: -0.03em;
letter-spacing: -0.02em;
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
font-family: 'MuseoModerno', 'Poppins', sans-serif;
font-weight: 700;
text-transform: lowercase;
letter-spacing: -0.01em;
line-height: 1.08;
}
/* The TREK Cloud lockup (cloud intro screen): the wordmark plus the "cloud"
badge in italic MuseoModerno, an inverted pill, exactly as on liketrek.com. */
.cloud-wordmark {
display: inline-flex;
align-items: center;
gap: 9px;
}
.cloud-wordmark img {
display: block;
height: 38px;
width: auto;
}
.cloud-wordmark .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.cloud-wordmark .on-dark {
display: none;
}
.cloud-wordmark .on-light {
display: block;
}
}
.cloud-tag {
font-family: 'MuseoModerno', system-ui, sans-serif;
font-style: italic;
font-weight: 600;
font-size: 0.95rem;
line-height: 1.35;
padding: 2px 11px;
border-radius: 999px;
color: var(--bg);
background: var(--ink);
}
.title-sm {
font-size: 1.5rem;
line-height: 1.18;
font-size: 1.45rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.025em;
letter-spacing: -0.015em;
margin: 0;
}
.sub {
color: var(--muted);
margin: 0;
line-height: 1.55;
font-size: 0.9rem;
font-size: 1.02rem;
overflow-wrap: anywhere;
}
@@ -434,14 +263,10 @@ body::after {
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
box-shadow:
0 8px 22px -14px rgba(0, 0, 0, 0.55),
0 1px 2px rgba(0, 0, 0, 0.25);
transition:
transform 0.14s var(--ease),
opacity 0.2s,
background 0.2s,
box-shadow 0.2s,
border-color 0.2s;
display: inline-flex;
align-items: center;
@@ -459,9 +284,6 @@ body::after {
background: var(--surface);
color: var(--ink);
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -496,34 +318,6 @@ body::after {
align-self: center;
transition: color 0.2s;
}
/* "Try the demo": a tertiary action, but a tidy pill so it reads as a choice, not stray text. */
.demo-link {
font: inherit;
font-weight: 600;
font-size: 0.95rem;
align-self: stretch;
width: 100%;
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s,
transform 0.14s var(--ease);
}
.demo-link:active {
transform: scale(0.97);
background: var(--surface-2);
color: var(--ink);
}
.linkbtn:active {
color: var(--ink);
}
@@ -535,21 +329,14 @@ a.linkbtn {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 22px;
gap: 2px 18px;
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid var(--edge);
font-size: 0.86rem;
}
.foot-links a {
color: var(--muted);
font-weight: 500;
color: var(--faint);
text-decoration: none;
padding: 6px 2px;
transition: color 0.2s;
}
.foot-links a:active {
color: var(--ink);
padding: 8px 2px;
}
/* ── Address form ────────────────────────────────────────────────────────── */
@@ -564,16 +351,13 @@ a.linkbtn {
.field input {
width: 100%;
font: inherit;
font-size: 1rem;
padding: 13px 16px;
font-size: 1.05rem;
padding: 16px 18px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -790,19 +574,10 @@ a.linkbtn {
border-radius: var(--radius);
padding: 16px;
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
transition:
border-color 0.2s,
box-shadow 0.2s,
transform 0.14s var(--ease);
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
transform: scale(0.99);
}
@@ -829,7 +604,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 7px;
margin-left: 3px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -848,10 +623,6 @@ a.linkbtn {
flex: none;
color: var(--ok);
}
.plan-feats .off,
.plan-feats .off svg {
color: var(--faint);
}
.plan .btn {
width: 100%;
min-height: 48px;
@@ -883,44 +654,21 @@ a.linkbtn {
}
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
/* One rounded field holding the address input and its fixed ".liketrek.com"
suffix — the input itself is seamless, the box and its states live here. */
.slug-field {
display: flex;
align-items: center;
padding-right: 16px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
padding-right: 14px;
}
.slug-field .slug-input {
flex: 1;
min-width: 0;
text-align: left;
border: 0;
background: transparent;
box-shadow: none;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
animation: shake 0.3s;
text-align: right;
}
.slug-suffix {
flex: none;
color: var(--muted);
font-size: 0.95rem;
padding-left: 4px;
font-size: 0.98rem;
padding-left: 2px;
user-select: none;
}
.hint {
+3 -3
View File
@@ -143,7 +143,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
@@ -162,8 +162,8 @@
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.5",
"vite": "8.3.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.12"
"vitest": "^4.1.11"
}
}
+3 -3
View File
@@ -8,10 +8,10 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 553,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 115,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
@@ -31,7 +31,7 @@
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 45
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
+3 -3
View File
@@ -5,14 +5,14 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2183,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceInspector.tsx": 1287,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
+1 -1
View File
@@ -38,7 +38,7 @@
"src/components/Journey/journeyGeo.ts": 4,
"src/components/Layout/Navbar.test.tsx": 1,
"src/components/Map/MapView.test.tsx": 2,
"src/components/Map/MapView.tsx": 7,
"src/components/Map/MapView.tsx": 8,
"src/components/Map/MapViewGL.tsx": 1,
"src/components/Map/ReservationOverlay.test.tsx": 1,
"src/components/Map/transitGeometry.ts": 1,
+6 -4
View File
@@ -125,6 +125,7 @@
"components/shared/CustomSelect.tsx": 1,
"components/shared/CustomTimePicker.tsx": 1,
"components/shared/EmptyState.tsx": 1,
"components/shared/ErrorBoundary.tsx": 2,
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
@@ -165,7 +166,7 @@
"mobile/screens/admin/MAdminUi.tsx": 10,
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 5,
"mobile/screens/atlas/MAtlas.tsx": 7,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
@@ -218,10 +219,11 @@
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
@@ -279,7 +281,7 @@
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 17,
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
+1 -2
View File
@@ -19,9 +19,8 @@
"components/Journey/journeyPhotoLayer.ts": 2,
"components/Map/DawarichTrailLayer.tsx": 1,
"components/Map/dawarichTrail.ts": 10,
"components/Map/markerLook.ts": 1,
"components/Map/useDawarichTrailGL.ts": 1,
"components/Packing/PackingBringersCard.tsx": 1,
"components/Packing/PackingListPanelItemRow.tsx": 1,
"components/Planner/DayReorderPopup.tsx": 1,
"components/Planner/bookings/bookingsModel.ts": 1,
"components/Planner/bookings/transitParts.tsx": 1,
+2 -14
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation, useNavigationType } from 'react-router'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
@@ -21,7 +21,6 @@ import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useServerVersionStore } from './store/serverVersionStore'
import { useIsPhone } from './mobile/useIsPhone'
import { useRouteTransition } from './mobile/routeTransition'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
@@ -33,10 +32,6 @@ import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './components/Layout/NativeImportSheet'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -412,9 +407,6 @@ export default function App() {
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone()
// On the phone, pages slide like a native navigation stack (mobile/routeTransition.ts).
const navigationType = useNavigationType()
const routedLocation = useRouteTransition(location, navigationType, isPhone)
const keepingTour = useSaveToCollectionStore(s => s.tour !== null)
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
@@ -450,16 +442,12 @@ export default function App() {
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
a spinner on every jump — the spinner is for the first paint of a deep link. */}
<Suspense fallback={<RouteFallback />}>
<Routes location={routedLocation}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
@@ -1,8 +1,6 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
@@ -73,9 +71,8 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const dateFormat = useDateFormat();
const busy = admin.busyId === request.id;
const date = formatDisplayDate(new Date(request.requested_at), language, { dateStyle: 'medium' }, dateFormat);
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
return (
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
@@ -1,9 +1,7 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { formatDisplayDate } from '../../../utils/dateFormat';
import {
SettingRows,
SETTINGS_BUTTON,
@@ -260,10 +258,9 @@ function RegionNetwork({ region }: { region: PaddleRegion }) {
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const busy = admin.busyId === region.id;
const date = region.source_timestamp
? formatDisplayDate(new Date(region.source_timestamp), language, { dateStyle: 'medium' }, dateFormat)
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
: null;
return (
<div
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-RECLAIM-001 to -009: the reclaim report as both storage panels show it.
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
import { reclaimView } from './reclaimModel';
@@ -137,84 +137,4 @@ describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
});
it('FE-ADMIN-STOR-RECLAIM-008: a category held back by a backup or a failed delete still counts what was judged and not deleted', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report(
{
covers: category({
skipped: 'backup_running',
unreferenced: { objects: 3_000, bytes: 3_000 },
eligible: { objects: 3_000, bytes: 3_000 },
}),
places: category({
skipped: 'backup_running',
unreferenced: { objects: 1_500, bytes: 150 },
eligible: { objects: 1_200, bytes: 120 },
deleted: { objects: 1_000, bytes: 100 },
}),
files: category({
skipped: 'delete_failed',
unreferenced: { objects: 5, bytes: 50 },
eligible: { objects: 4, bytes: 40 },
deleted: { objects: 1, bytes: 10 },
}),
},
'delete'
),
});
const byCategory = Object.fromEntries(view.lines.map((line) => [line.category, line]));
expect(byCategory.covers).toMatchObject({
freeable: { objects: 3_000, bytes: 3_000 },
waiting: none,
skipped: 'backup_running',
});
expect(byCategory.places).toMatchObject({
freeable: { objects: 200, bytes: 20 },
waiting: { objects: 300, bytes: 30 },
skipped: 'backup_running',
});
expect(byCategory.files).toMatchObject({
freeable: { objects: 3, bytes: 30 },
waiting: { objects: 1, bytes: 10 },
skipped: 'delete_failed',
});
expect(view.freeable).toEqual({ objects: 3_203, bytes: 3_050 });
expect(view.waiting).toEqual({ objects: 301, bytes: 40 });
});
it('FE-ADMIN-STOR-RECLAIM-009: a category whose sources or listing failed shows its reason and counts nothing', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({
covers: category({ skipped: 'source_failed', objects: 12 }),
files: category({ skipped: 'list_failed' }),
}),
});
expect(view.lines).toEqual([
{
category: 'files',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 0,
skipped: 'list_failed',
},
{
category: 'covers',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 0,
skipped: 'source_failed',
},
]);
expect(view.freeable).toEqual(none);
expect(view.waiting).toEqual(none);
});
});
@@ -67,23 +67,11 @@ function freedBy(run: StorageReclaimReport | null): ReclaimCount {
);
}
type SkipReason = StorageReclaimCategoryReport['skipped'];
/**
* The reasons the pass gives when it left a category before judging it, so its
* counts mean nothing. Every other reason (a backup running, a failed delete,
* an interrupted pass) comes after the judgement and keeps its figures; a new
* reason counts by default rather than hiding what the pass found.
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
const NEVER_JUDGED: ReadonlySet<SkipReason> = new Set<SkipReason>([
'sources_missing',
'shared_location',
'foreign_install',
'owner_unknown',
'list_failed',
'source_failed',
]);
/**
* One category of the last check as a line, or null when it has nothing to
* say. A category the pass stopped in still counts what it found; one it
@@ -94,7 +82,7 @@ function lineOf(
report: StorageReclaimCategoryReport | undefined
): ReclaimCategoryLine | null {
if (!report || report.skipped === 'not_reclaimed') return null;
const judged = !NEVER_JUDGED.has(report.skipped);
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
// What was deleted on this run no longer waits to be freed.
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
@@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: {
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}${cur.key === options[0]?.key ? ' is-default' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} title={cur.label}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
@@ -1,121 +0,0 @@
// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { TranslationFn } from '../../types';
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter';
const t = ((key: string) => key) as unknown as TranslationFn;
const LABELS = [
{ id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 },
{ id: 2, name: 'Heritage', color: null, count: 0 },
] as unknown as LabelOption[];
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** The labels need `need` px against a slot of `room` px beside the map's controls. */
function room(need: number, room: number) {
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-measure') ? need : 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-slot') ? room : 0;
});
}
function setup(props: Partial<Parameters<typeof CollectionLabelFilter>[0]> = {}) {
const onLabelFilter = vi.fn();
const onManageLabels = vi.fn();
const view = render(
<CollectionLabelFilter
labelOptions={LABELS}
labelFilter={[]}
onLabelFilter={onLabelFilter}
canManageLabels
onManageLabels={onManageLabels}
t={t}
{...props}
/>
);
return { ...view, onLabelFilter, onManageLabels };
}
describe('CollectionLabelFilter', () => {
it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => {
const on = setup();
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(on.onLabelFilter).toHaveBeenCalledWith([1]);
on.unmount();
const off = setup({ labelFilter: [1, 2] });
expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(off.onLabelFilter).toHaveBeenCalledWith([2]);
});
it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => {
const empty = setup({ labelOptions: [] });
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }));
expect(empty.onManageLabels).toHaveBeenCalled();
empty.unmount();
setup();
expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument();
});
it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => {
room(300, 500);
setup({ variant: 'map' });
expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull();
});
it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => {
room(600, 200);
const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
expect(fold).toHaveTextContent('1');
expect(fold).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(fold);
const list = screen.getByRole('group', { name: 'collections.labels.title' });
expect(list).toHaveTextContent('Core shortlist');
expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(onLabelFilter).toHaveBeenCalledWith([2, 1]);
});
it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => {
room(600, 200);
const { onManageLabels } = setup({ variant: 'map' });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
fireEvent.click(fold);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ }));
expect(onManageLabels).toHaveBeenCalled();
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
});
it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => {
room(600, 200);
setup({ variant: 'map' });
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }));
fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' }));
expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
@@ -19,154 +19,54 @@ interface CollectionLabelFilterProps {
t: TranslationFn
}
interface ChipsProps {
labelOptions: LabelOption[]
labelFilter: number[]
onToggle?: (id: number) => void
}
/** The label chips, as buttons, or as plain spans for the copy the map strip measures. */
function LabelChips({ labelOptions, labelFilter, onToggle }: ChipsProps): React.ReactElement {
return (
<>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
const body = (
<>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</>
)
const style = { ['--label' as string]: l.color ?? 'var(--accent)' }
if (!onToggle) return <span key={l.id} className={`col-labelchip${on ? ' on' : ''}`} style={style}>{body}</span>
return (
<button key={l.id} type="button" className={`col-labelchip${on ? ' on' : ''}`} style={style} onClick={() => onToggle(l.id)} aria-pressed={on}>
{body}
</button>
)
})}
</>
)
}
/**
* Whether the map strip has to fold into its button: true once its chips need more room
* than the slot beside the map's other controls. Measured on a hidden copy, so the strip
* unfolds again as soon as the room comes back.
*/
function useFolds(enabled: boolean) {
const slotRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [folded, setFolded] = useState(false)
useLayoutEffect(() => {
const slot = slotRef.current
const measure = measureRef.current
if (!enabled || !slot || !measure) return
const check = () => setFolded(measure.scrollWidth > slot.clientWidth + 1)
check()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(check)
observer.observe(slot)
observer.observe(measure)
return () => observer.disconnect()
}, [enabled])
return { slotRef, measureRef, folded }
}
/** Open state of the folded strip's list: closed by a click beside it or Escape, like the filter row's dropdowns. */
function useFoldList(ref: React.RefObject<HTMLElement | null>) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open, ref])
return { open, setOpen }
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar; when the bar has no room for every
* label it folds into one button that opens them as a list, instead of hiding
* the last ones behind a scroll nobody sees. The filter row keeps it whenever
* no map is on screen, so it never becomes unreachable.
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
const onMap = variant === 'map'
const { slotRef, measureRef, folded } = useFolds(onMap)
const fold = useFoldList(slotRef)
const toggle = (id: number) => onLabelFilter(labelFilter.includes(id) ? labelFilter.filter(x => x !== id) : [...labelFilter, id])
const manageLabel = labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')
const manage = canManageLabels && onManageLabels && (
<button type="button" className="col-filter-addlabel" onClick={onManageLabels} title={manageLabel} aria-label={manageLabel}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)
const tray = (
<div className={`col-labelfilter${onMap ? ' on-map' : ''}`} role="group" aria-label={t('collections.labels.manage')}>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} onToggle={toggle} />
{manage}
</div>
)
if (!onMap) return tray
const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length
return (
<div className="col-labelfilter-slot" ref={slotRef}>
{!folded ? tray : (
<>
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => fold.setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={fold.open}
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<span className="col-filter-lbl">{t('collections.labels.title')}</span>
{active > 0 && <span className="col-labelfilter-active">{active}</span>}
<ChevronDown size={14} className="col-filter-chev" aria-hidden="true" />
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
{fold.open && (
<div className="col-filter-pop col-labelfilter-pop" role="group" aria-label={t('collections.labels.title')}>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button key={l.id} type="button" aria-pressed={on} className={`col-filter-opt${on ? ' on' : ''}`} onClick={() => toggle(l.id)}>
<span className="col-filter-dot" style={{ background: l.color ?? 'var(--accent)' }} />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
{on && <Check size={13} className="col-filter-check" />}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button type="button" className="col-filter-opt col-labelfilter-manage" onClick={() => { fold.setOpen(false); onManageLabels() }}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
<span className="col-filter-lbl">{manageLabel}</span>
</button>
)}
</div>
)}
</>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)}
<div className="col-labelfilter on-map col-labelfilter-measure" ref={measureRef} aria-hidden="true">
<Tags size={13} className="col-labelfilter-lead" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} />
{canManageLabels && onManageLabels && <span className="col-filter-addlabel"><Settings2 size={13} /></span>}
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
import CollectionPlaceTags from './CollectionPlaceTags'
interface CollectionListProps {
places: CollectionPlace[]
@@ -51,7 +51,7 @@ export default function CollectionList({
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
className={`col-lrow${active || selected ? ' sel' : ''}`}
>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
@@ -67,8 +67,24 @@ export default function CollectionList({
</div>
)}
</div>
<CollectionPlaceTags place={place} labels={placeLabels} />
<div className="col-lrow-status">
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
@@ -1,88 +0,0 @@
// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import CollectionPlaceTags from './CollectionPlaceTags';
// jsdom lays nothing out: the observer reports each element once it is observed, and
// the widths below stand in for the browser's.
beforeAll(() => {
globalThis.ResizeObserver = class {
constructor(private readonly cb: ResizeObserverCallback) {}
observe(target: Element) {
this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */
function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('col-lrow-cat')) return 50;
if (this.classList.contains('col-lrow-label')) return 100;
return 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-tags')) return row;
if (this.classList.contains('col-lrow-txt')) return 60;
return 0;
});
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60;
return 0;
});
}
const label = (id: number, name: string): CollectionLabel =>
({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel;
const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) =>
({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace;
const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')];
describe('CollectionPlaceTags', () => {
it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => {
const { container } = render(<CollectionPlaceTags place={place(null)} labels={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => {
layout();
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className);
expect(chips[0]).toBe('col-lrow-cat');
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1);
expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2);
expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => {
layout({ row: 800 });
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3);
expect(container.querySelector('.col-lrow-more')).toBeNull();
});
it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => {
layout();
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('+2'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop');
});
it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => {
layout({ row: 800, cutName: 'Heritage' });
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
await new Promise((r) => setTimeout(r, 350));
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage');
});
});
@@ -1,145 +0,0 @@
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import React, { createElement, useEffect, useRef, useState } from 'react';
import { getCategoryIcon } from '../shared/categoryIcons';
import { Tooltip } from '../shared/Tooltip';
/**
* One observer for every row and chip of a list, so a list of hundreds of places does
* not hold one per element. An element starts with one notification once observed.
*/
const sizeWatch = new Map<Element, () => void>();
let sizeObserver: ResizeObserver | null = null;
function watchSize(el: Element, onResize: () => void): () => void {
if (typeof ResizeObserver === 'undefined') return () => {};
sizeObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) sizeWatch.get(entry.target)?.();
});
sizeWatch.set(el, onResize);
sizeObserver.observe(el);
return () => {
sizeWatch.delete(el);
sizeObserver?.unobserve(el);
};
}
/**
* The tooltip of a label or category chip: only while the chip cuts its name short,
* since one repeating the words on screen says nothing.
*/
function ChipTooltip({
label,
children,
}: {
label: string;
children: React.ReactElement<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
}): React.ReactElement {
const [cut, setCut] = useState(false);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const text = ref.current?.querySelector('.col-lrow-txt');
if (!text) return undefined;
return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1));
}, []);
return (
<Tooltip label={label} placement="top" disabled={!cut}>
{React.cloneElement(children, { ref })}
</Tooltip>
);
}
/** The room a "+N" chip takes, held free while the labels are fitted. */
const MORE_CHIP = 34;
/**
* A chip's own width, also while the row squeezes it: the part its name is cut short by
* is the name's scroll width over its box, up to the widest the chip may grow.
*/
const naturalWidth = (el: HTMLElement): number => {
const text = el.querySelector<HTMLElement>('.col-lrow-txt');
const full = el.offsetWidth + (text ? text.scrollWidth - text.clientWidth : 0);
const cap = parseFloat(getComputedStyle(el).maxWidth);
return Number.isFinite(cap) ? Math.min(full, cap) : full;
};
/**
* How many labels fit beside the category in `row`: as many as it holds with room left
* for the "+N" chip, and at least one, which then shortens its name. Every label is in
* the row, the ones that do not fit kept out of sight, so each can be measured.
*/
function labelsThatFit(row: HTMLElement): number {
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
const labels = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-label'));
const fixed = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-cat, .col-lrow-div'))
.filter((el) => el.offsetWidth > 0)
.reduce((sum, el) => sum + naturalWidth(el) + gap, 0);
let used = fixed;
let fit = 0;
for (let i = 0; i < labels.length; i++) {
const left = labels.length - i - 1;
const need = used + naturalWidth(labels[i]) + (left > 0 ? gap + MORE_CHIP : 0);
if (need > row.clientWidth && i > 0) break;
used += naturalWidth(labels[i]) + gap;
fit = i + 1;
}
return fit;
}
/**
* A place row's category and labels, on one line that never wraps (#2981). The category
* comes first, then as many labels as the row has room for, then a "+N" chip naming the
* rest in its tooltip, so a wide list shows every label and a narrow one the first.
*/
export default function CollectionPlaceTags({
place,
labels,
}: {
place: CollectionPlace;
labels: CollectionLabel[];
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState(labels.length);
const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|');
const categoryName = place.category?.name ?? null;
// Fitted on every change of the row's width and once more whenever its labels change:
// observing anew hands the observer's first notification to the new labels.
useEffect(() => {
const row = ref.current;
if (!row) return undefined;
return watchSize(row, () => setFit(labelsThatFit(row)));
}, [labelKey, categoryName]);
if (!labels.length && !categoryName) return null;
const shown = Math.min(labels.length, Math.max(1, fit));
const hidden = labels.slice(shown);
return (
<div ref={ref} className="col-lrow-tags">
{categoryName && (
<ChipTooltip label={categoryName}>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category?.color || '#6366f1' }}>
{createElement(getCategoryIcon(place.category?.icon ?? undefined), { size: 11 })}{' '}
<span className="col-lrow-txt">{categoryName}</span>
</span>
</ChipTooltip>
)}
{labels.map((l, i) => (
<ChipTooltip key={l.id} label={l.name}>
<span
className={i < shown ? 'col-lrow-label' : 'col-lrow-label is-over'}
style={{ ['--label' as string]: l.color || 'var(--accent)' }}
aria-hidden={i < shown ? undefined : true}
>
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
</span>
</ChipTooltip>
))}
{hidden.length > 0 && (
<Tooltip label={hidden.map((l) => l.name).join(', ')} placement="top">
<span className="col-lrow-more">+{hidden.length}</span>
</Tooltip>
)}
<span className="col-lrow-div" aria-hidden />
</div>
);
}
@@ -1,8 +1,6 @@
import { Clock, FilePen, Loader2, RefreshCw, Route, Users } from 'lucide-react';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { formatClockTime } from '../../utils/formatters';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
@@ -28,7 +26,6 @@ type Props = { state: JourneyDayImportState; dayColor: string; prep?: JourneyRec
*/
function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: JourneyRecordingPrepState }) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
const { review, outcome } = state;
const reason = review ? reasonText(review.error, t) : '';
const day = prep?.dayPrep(state.date);
@@ -46,12 +43,7 @@ function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: Jour
{review.syncedAt
? t('journey.dayImport.stale', {
reason,
date: formatDisplayDateTime(
new Date(review.syncedAt),
locale,
{ dateStyle: 'medium', timeStyle: 'short' },
dateFormat
),
date: new Date(review.syncedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
})
: t('journey.dayImport.staleNever', { reason })}
</Notice>
@@ -4,7 +4,6 @@ import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap, initialMapStyle } from '../Map/glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
@@ -396,9 +395,6 @@ function JourneyMapGL(
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
@@ -1,63 +0,0 @@
import { App } from '@capacitor/app';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { isAppLockEnabled, unlockApp } from '../../native/appLock';
import AppLockGate from './AppLockGate';
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) } }));
vi.mock('../../native/appLock', () => ({
RELOCK_AFTER_MS: 30_000,
isAppLockEnabled: vi.fn(),
unlockApp: vi.fn(),
}));
function stateListener(): (state: { isActive: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
if (!call) throw new Error('no appStateChange listener');
return call[1] as unknown as (state: { isActive: boolean }) => void;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isAppLockEnabled).mockReturnValue(true);
vi.mocked(unlockApp).mockResolvedValue(false);
});
describe('AppLockGate', () => {
it('covers the app at launch and asks at once; a successful unlock lifts it', async () => {
render(<AppLockGate />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
await waitFor(() => expect(unlockApp).toHaveBeenCalledTimes(1));
vi.mocked(unlockApp).mockResolvedValue(true);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('locks again after half a minute in the background, not after a glance away', async () => {
vi.mocked(unlockApp).mockResolvedValue(true);
const now = vi.spyOn(Date, 'now');
render(<AppLockGate />);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
vi.mocked(unlockApp).mockResolvedValue(false);
now.mockReturnValue(1_000);
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(10_000);
act(() => stateListener()({ isActive: true }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(60_000);
act(() => stateListener()({ isActive: true }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
now.mockRestore();
});
it('is nothing while the lock is off', () => {
vi.mocked(isAppLockEnabled).mockReturnValue(false);
const { container } = render(<AppLockGate />);
expect(container).toBeEmptyDOMElement();
expect(unlockApp).not.toHaveBeenCalled();
});
});
@@ -1,80 +0,0 @@
/**
* AppLockGate: covers TREK until Face ID, Touch ID or the passcode unlocks it,
* when the app lock is on (native/appLock.ts). It locks at launch and again
* after the app spent more than half a minute in the background, and asks at
* once rather than waiting for a tap; the button is there for a second try.
* Rendered once in App; outside the app, or with the lock off, it is nothing.
*/
import { App } from '@capacitor/app';
import { Lock } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { RELOCK_AFTER_MS, isAppLockEnabled, unlockApp } from '../../native/appLock';
import { useNativeCapabilities } from '../../native/capabilities';
export default function AppLockGate(): React.ReactElement | null {
const { t } = useTranslation();
// Re-read once the binary has said what it can do.
const { caps } = useNativeCapabilities();
const [locked, setLocked] = useState(false);
const asking = useRef(false);
const backgroundSince = useRef<number | null>(null);
const ask = useCallback(async () => {
if (asking.current) return;
asking.current = true;
const ok = await unlockApp(t('native.lock.reason'));
asking.current = false;
if (ok) setLocked(false);
}, [t]);
// At launch, once the capabilities are known.
useEffect(() => {
if (!isAppLockEnabled()) return;
setLocked(true);
void ask();
}, [caps, ask]);
// Back from the background after a while.
useEffect(() => {
if (!isAppLockEnabled()) return;
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
backgroundSince.current = Date.now();
return;
}
const since = backgroundSince.current;
backgroundSince.current = null;
if (since != null && Date.now() - since > RELOCK_AFTER_MS) {
setLocked(true);
void ask();
}
});
return () => {
void listener.then((handle) => handle.remove());
};
}, [caps, ask]);
if (!locked) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('native.lock.title')}
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
<Lock size={26} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-title font-semibold text-content">{t('native.lock.title')}</p>
<button
type="button"
onClick={() => void ask()}
className="rounded-full bg-accent px-6 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
>
{t('native.lock.unlock')}
</button>
</div>
);
}
@@ -1,43 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { tripsApi } from '../../api/client';
import { useSharedImportStore } from '../../native/sharedFiles';
import NativeImportSheet from './NativeImportSheet';
const navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
beforeEach(() => {
navigate.mockReset();
useSharedImportStore.setState({ file: null, tripId: null });
vi.spyOn(tripsApi, 'list').mockResolvedValue({
trips: [
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
{ id: 2, title: 'Japan', start_date: '2099-03-01', end_date: '2099-03-10' },
],
});
});
describe('NativeImportSheet', () => {
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(new File(['x'], 'booking.pdf', { type: 'application/pdf' })));
const rows = await screen.findAllByRole('button', { name: /Japan|Past trip/ });
expect(rows.map((r) => r.textContent)).toEqual(['Japan', 'Past trip']);
expect(screen.getByText('booking.pdf')).toBeInTheDocument();
fireEvent.click(rows[0]);
expect(useSharedImportStore.getState().tripId).toBe(2);
expect(navigate).toHaveBeenCalledWith('/trips/2');
});
it('stays closed with nothing shared', () => {
render(<NativeImportSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(tripsApi.list).not.toHaveBeenCalled();
});
});
@@ -1,88 +0,0 @@
/**
* NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts),
* so this asks which trip it belongs to and opens that trip's booking import with
* the file already chosen. Trips still ahead come first, the ones that ended after.
*/
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { tripsApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import MSheet from '../../mobile/components/MSheet';
import { useSharedImportStore } from '../../native/sharedFiles';
interface TripRow {
id: number;
title: string;
start_date?: string | null;
end_date?: string | null;
}
export default function NativeImportSheet(): React.ReactElement | null {
const { t } = useTranslation();
const navigate = useNavigate();
const file = useSharedImportStore((s) => s.file);
const tripId = useSharedImportStore((s) => s.tripId);
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
const setFile = useSharedImportStore((s) => s.setFile);
const [trips, setTrips] = useState<TripRow[]>([]);
const open = !!file && tripId == null;
useEffect(() => {
if (!open) return;
tripsApi.list().then(
(data: { trips?: TripRow[] }) => {
const today = new Date().toISOString().slice(0, 10);
const list = [...(data.trips ?? [])];
const ahead = (trip: TripRow) => (trip.end_date ?? '9999') >= today;
list.sort(
(a, b) => Number(ahead(b)) - Number(ahead(a)) || (a.start_date ?? '').localeCompare(b.start_date ?? '')
);
setTrips(list);
},
() => setTrips([])
);
}, [open]);
const pick = (id: number) => {
chooseTrip(id);
navigate(`/trips/${id}`);
};
return (
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
{t('native.share.title')}
</div>
{file && (
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
<FileText size={13} aria-hidden="true" />
<span className="truncate">{file.name}</span>
</div>
)}
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
key={trip.id}
type="button"
onClick={() => pick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.noTrips')}
</p>
)}
</div>
</div>
</MSheet>
);
}
@@ -1,46 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { _resetNativeCapabilities, checkAppUpdate, loadNativeCapabilities } from '../../native/capabilities';
import { isNativeApp } from '../../native/platform';
import { TrekShell } from '../../native/trekShell';
import NativeUpdateNotice from './NativeUpdateNotice';
vi.mock('../../native/platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn(() => false) }));
vi.mock('../../native/trekShell', () => ({
TrekShell: { getCapabilities: vi.fn() },
isUnimplemented: () => false,
}));
beforeEach(() => {
_resetNativeCapabilities();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(TrekShell.getCapabilities).mockResolvedValue({
shellApi: 2,
platform: 'android',
appVersion: '5.2.0',
build: '3',
features: [],
} as never);
});
describe('NativeUpdateNotice', () => {
it('stays away while the binary is new enough', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await checkAppUpdate(async () => ({ minNativeShellApi: 2 }));
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('leads to the store when the server needs a newer binary, and can be dismissed', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await loadNativeCapabilities();
await checkAppUpdate(async () => ({ minNativeShellApi: 3 }));
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', 'https://play.google.com/store/apps/details?id=com.liketrek.trek');
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
@@ -1,54 +0,0 @@
/**
* NativeUpdateNotice: this server's UI needs a newer app binary than the one
* installed (native/capabilities.ts, minNativeShellApi). The UI updates with
* the server, the binary only through the store, so the two can drift; the
* notice says so and leads to the store instead of leaving native calls to
* fail one by one. Rendered once in App, inside the app only.
*/
import { Download, X } from 'lucide-react';
import React, { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
const STORE_URL = {
ios: 'https://apps.apple.com/app/id6831237775',
android: 'https://play.google.com/store/apps/details?id=com.liketrek.trek',
} as const;
export default function NativeUpdateNotice(): React.ReactElement | null {
const { t } = useTranslation();
const { caps, updateRequired } = useNativeCapabilities();
const [dismissed, setDismissed] = useState(false);
if (!updateRequired || dismissed || caps.platform === 'web') return null;
const store = caps.platform === 'android' ? STORE_URL.android : STORE_URL.ios;
return (
<div
role="status"
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
className="fixed inset-x-4 z-[var(--z-notice)] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
>
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="text-body font-semibold">{t('native.update.title')}</div>
<div className="text-caption text-content-secondary">{t('native.update.body')}</div>
</div>
<a
href={store}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-full bg-accent px-3 py-1.5 text-caption font-semibold text-accent-text"
>
{t('native.update.open')}
</a>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common.close')}
className="shrink-0 rounded-full p-1 text-content-muted"
>
<X size={14} aria-hidden="true" />
</button>
</div>
);
}
@@ -1,70 +0,0 @@
/**
* NativeWidgetsSync: keeps the iOS home-screen widget and the travel-day Live
* Activity in step with the trips stored on this device (native/widgets.ts).
* Runs at start, whenever the app comes back to the foreground and every
* quarter hour while it is open, so the next stop moves on through the day.
* Renders nothing; outside an iOS binary with the widgets it does nothing.
*/
import { App } from '@capacitor/app';
import { useEffect } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
import { useSettingsStore } from '../../store/settingsStore';
import { formatTime } from '../../utils/formatters';
import { localDateStr } from '../../vacay/monthGrid';
const REFRESH_MS = 15 * 60 * 1000;
export default function NativeWidgetsSync(): null {
const { t, language } = useTranslation();
const { caps } = useNativeCapabilities();
const timeFormat = useSettingsStore((s) => s.settings.time_format);
useEffect(() => {
if (!widgetsActive()) return;
let cancelled = false;
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const trips = await offlineDb.trips.toArray();
const trip = pickTrip(trips, today);
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const day = trip
? todayOf(
trip,
days,
today,
(n, m) => t('native.widget.dayOf', { n, m }),
(time) => formatTime(time, language, timeFormat)
)
: null;
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
});
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
};
void sync();
const timer = window.setInterval(() => void sync(), REFRESH_MS);
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void sync();
});
return () => {
cancelled = true;
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat]);
return null;
}
+5 -5
View File
@@ -4,14 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { frameLng, type LngFrame } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, frame, enabled, onPoiClick, children }: {
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
/** The frame the markers in `children` are drawn in: the view's, not the trip's (see MapView). */
frame: LngFrame | null
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -21,7 +21,7 @@ export default function ClusteredPois({ pois, frame, enabled, onPoiClick, childr
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
// Measured and placed where the markers are drawn, on the world copy in view (#2975).
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
+2 -7
View File
@@ -69,19 +69,14 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
style={{
position: 'absolute',
bottom: bottomOffset,
// The map's base-layer switcher sits 20px off the left edge; this mirrors it.
right: 20,
right: 12,
zIndex: 1000,
width: 42,
height: 42,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
// Off, it wears the same frosted glass as the map's other round controls;
// on, the location blue says it is tracking.
background: isActive ? '#3b82f6' : 'var(--sidebar-bg)',
backdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
WebkitBackdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
@@ -1,4 +1,4 @@
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-007
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
@@ -86,17 +86,3 @@ describe('MapCompassPill', () => {
expect(button.style.background).toBe('transparent')
})
})
describe('MapCompassPill on the phone map', () => {
it('FE-COMP-MAPCOMPASS-007: appears only while the map is turned off north', () => {
const { map, rotateTo } = fakeCompassMap(0)
render(<MapCompassPill map={map} onlyWhenRotated />)
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
rotateTo(30)
expect(screen.getByRole('button', { name: 'Reset north' })).toBeInTheDocument()
rotateTo(0)
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
})
})
+1 -8
View File
@@ -15,12 +15,7 @@ export interface CompassMap {
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map, onlyWhenRotated = false }: {
map: CompassMap
/** Shown only while the map is turned off north, the way a native map's compass
* appears when it has something to undo (the phone map, where room is short). */
onlyWhenRotated?: boolean
}) {
export function MapCompassPill({ map }: { map: CompassMap }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
@@ -30,8 +25,6 @@ export function MapCompassPill({ map, onlyWhenRotated = false }: {
return () => { map.off('rotate', update) }
}, [map])
if (onlyWhenRotated && Math.abs(bearing) < 0.5) return null
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
@@ -43,7 +43,6 @@ import { MapView } from './MapView'
import { ContextMenu } from '../shared/ContextMenu'
import type { Ferrata } from '@trek/shared'
import { groupFerratas } from '../Ferratas/ferrataGroups'
import type { Poi } from './poiCategories'
// Every map this file builds, newest last, for the tests that reach past the component.
const maps: L.Map[] = []
@@ -484,30 +483,4 @@ describe('MapView on a real Leaflet map', () => {
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true')
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1')
})
it('draws POIs found outside the trip frame where the user is looking, not one world away (#2975)', async () => {
// Seattle and Los Angeles to Auckland: the trip is drawn in a frame seamed near 28 degrees east.
const pacific = [
{ ...buildPlace({ id: 11, name: 'Seattle', lat: 47.6, lng: -122.3 }), category_name: null, category_color: null, category_icon: null },
{ ...buildPlace({ id: 12, name: 'Los Angeles', lat: 34.05, lng: -118.2 }), category_name: null, category_color: null, category_icon: null },
{ ...buildPlace({ id: 13, name: 'Auckland', lat: -36.85, lng: 174.8 }), category_name: null, category_color: null, category_icon: null },
]
const acropolis = {
osm_id: 'node/1', name: 'Acropolis', lat: 37.97, lng: 23.73, category: 'sights', poi_type: 'attraction',
address: null, website: null, phone: null, opening_hours: null, cuisine: null,
} as Poi
render(<MapView places={pacific} center={[38, 10]} zoom={5} tileUrl={RASTER} pois={[acropolis]} />)
const map = maps[maps.length - 1]
// The user pans west past the seam to look at the Mediterranean on the real world.
act(() => { map.setView([38, 10], 5, { animate: false }) })
const marker = () => {
let found: L.Marker | null = null
map.eachLayer(layer => { if (layer instanceof L.Marker && layer.options.alt === 'Acropolis') found = layer })
return found as L.Marker | null
}
await waitFor(() => expect(marker()?.getLatLng().lng).toBeCloseTo(23.73, 6))
const drawnMarker = marker()
expect(drawnMarker).not.toBeNull()
expect(drawnMarker && map.getBounds().contains(drawnMarker.getLatLng())).toBe(true)
})
})
+2 -46
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -85,7 +85,7 @@ const clusterMock = vi.hoisted(() => {
group: {
getVisibleParent(marker: unknown) {
clusterMock.asked.push(marker)
return clusterMock.stacked ? { spiderfy, getChildCount: () => 2 } : marker
return clusterMock.stacked ? { spiderfy } : marker
},
},
}
@@ -1283,50 +1283,6 @@ describe('MapView selection panning (#921)', () => {
}
})
describe('a place card that grows over the selected pin (#2991)', () => {
const projectable = mapMock as unknown as Record<string, unknown>
// The flat projection of the mock puts lat 0.7 / lng 0.5 at (500, 700) in a 1000x1200 map.
const covered = buildMapPlace({ id: 21, lat: 0.7, lng: 0.5 })
const clear = buildMapPlace({ id: 22, lat: 0.2, lng: 0.5 })
beforeEach(() => {
projectable.project = vi.fn(() => ({ add: vi.fn(() => ({ x: 0, y: 0 })) }))
projectable.unproject = vi.fn(() => [1, 1])
projectable.getSize = vi.fn(() => ({ x: 1000, y: 1200 }))
})
afterEach(() => {
delete projectable.project
delete projectable.unproject
delete projectable.getSize
})
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
const places = [covered]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEW-091: leaves a pin that is still in the open alone', () => {
const places = [clear]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-092: pans only once when the selection and the card arrive together', () => {
const places = [covered]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={null} />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
})
})
it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => {
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
+135 -15
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -49,17 +49,17 @@ import { PluginMapLayers } from './MapPluginLayers'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { placeMarkerFrame, placeMarkerLook } from './markerLook'
import { bottomChromePadding } from './mapChromePadding'
import { placeMarkerLook } from './markerLook'
import { escapeHtml } from '@trek/shared'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import NightPauseTooltip from './NightPauseTooltip'
import PoiMarkers from './PoiMarkers'
import ClusteredPois from './ClusteredPois'
import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
@@ -68,7 +68,6 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { nativeRasterTemplate } from '../../native/tileCache'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
@@ -154,7 +153,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white')
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = safeHexColor(place.category_color, '#6b7280')
// Number badges (bottom-right), or the rating where there are none: a numbered stop
@@ -186,7 +188,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
width:${size}px;height:${size}px;
cursor:pointer;position:relative;
">
<div class="${frameClass}" style="
<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
@@ -206,7 +208,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const fallbackIcon = L.divIcon({
className: '',
html: `<div class="${frameClass}" style="
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
@@ -226,6 +228,59 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
return fallbackIcon
}
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(poi: Pick<Poi, 'category' | 'icon' | 'color'>, brandWikidata?: string | null) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
// the signature because the callers still have it; it simply no longer changes anything.
void brandWikidata
// A plugin category's key carries its colour and icon too, so two looks never share a pin.
const cacheKey = poiPinCacheKey(poi)
const cached = poiIconCache.get(cacheKey)
if (cached) return cached
const { color, svg } = poiPinParts(poi)
const icon = L.divIcon({
className: '',
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
})
poiIconCache.set(cacheKey, icon)
return icon
}
/**
* The hover tooltip of an explore POI: its name, and for a plugin POI the rows the
* plugin knows about it (the GL hover card shows the same, see buildPoiPopupHtml).
* React text throughout, so nothing a plugin sends is ever read as markup. A POI
* without rows keeps the bare name it has always had.
*/
function PoiTooltipBody({ poi }: Readonly<{ poi: Poi }>) {
const rows = poiDetailRows(poi)
if (!rows.length) return poi.name
// w-max because Leaflet's tooltip pane is 0px wide: without an intrinsic width the
// tooltip shrinks to its narrowest layout and the values wrap a letter per line. The
// label column stops at 45% and wraps, so a long label leaves its value room too.
return (
<div className="w-max max-w-56 whitespace-normal">
<div className="truncate font-semibold">{poi.name}</div>
<div className="mt-1 grid grid-cols-[fit-content(45%)_1fr] gap-x-1.5 font-normal" data-testid="poi-details">
{rows.map(row => (
<div key={poiDetailRowKey(row)} className="contents">
<span className="text-content-muted [overflow-wrap:anywhere]">{row.label}</span>
<span className="[overflow-wrap:anywhere]">{row.value}</span>
</div>
))}
</div>
</div>
)
}
// Clears the hover tooltip the moment the camera starts moving and suppresses
// re-showing it until the move ends: after a click-recenter the marker slides
// away under a stationary cursor, so the browser never fires mouseout — and
@@ -258,6 +313,46 @@ function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number],
return null
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
selectedPlace: Place | null
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
if (follow && selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
map.panTo(map.unproject(point), { animate: true })
} else {
map.panTo(latlng, { animate: true })
}
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
interface MapControllerProps {
center: [number, number]
zoom: number
@@ -426,7 +521,7 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { SelectionController } from './SelectionController'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
@@ -614,7 +709,6 @@ export const MapView = memo(function MapView({
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
inspectorHeight = 0,
hasDayDetail = false,
reservations = [] as Reservation[],
showReservationStats = false,
@@ -667,6 +761,8 @@ export const MapView = memo(function MapView({
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
/**
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
* decided before the map is constructed, because Leaflet cannot change a map's
@@ -678,6 +774,30 @@ export const MapView = memo(function MapView({
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (!el) return
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name)
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -695,10 +815,10 @@ export const MapView = memo(function MapView({
return {
top: 60,
right: rightWidth + 40,
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
bottom: hasInspector ? 320 : hasDayDetail ? 280 : 60,
left: leftWidth + 40,
}
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
@@ -939,7 +1059,7 @@ export const MapView = memo(function MapView({
) : (
<TileLayer
key="raster"
url={nativeRasterTemplate(basemap.url)}
url={basemap.url}
attribution={attributionForTile(basemap.url)}
maxZoom={19}
keepBuffer={8}
@@ -954,7 +1074,7 @@ export const MapView = memo(function MapView({
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1103,7 +1223,7 @@ export const MapView = memo(function MapView({
hiddenDates={dawarichHiddenDates}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<PoiMarkers pois={pois as Poi[]} clusterLoosely={clusterLoosely} onPoiClick={onPoiClick} draggable={!!onPoiDropOnRoute} />
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
@@ -1,151 +0,0 @@
/**
* The trip map offline (FE-COMP-MAPAUTO-OFFLINE-*).
*
* Whatever basemap the user picked, offline the map never goes grey: a basemap
* with nothing stored switches to the stored OpenFreeMap one, with the same
* markers and routes, and says so.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { MapViewAuto } from './MapViewAuto';
const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false }));
const seen = vi.hoisted(() => ({
leaflet: null as null | { tileUrl?: string },
gl: null as null | { engine: string; glProvider?: string; styleOverride?: string },
}));
vi.mock('./MapView', () => ({
MapView: (props: { tileUrl?: string }) => {
seen.leaflet = props;
return <div data-testid="leaflet-map" />;
},
}));
vi.mock('./glLazy', () => ({
MapViewGLMaplibre: (props: { glProvider?: string; styleOverride?: string }) => {
seen.gl = { engine: 'maplibre', ...props };
return <div data-testid="gl-map" />;
},
MapViewGLMapbox: (props: { glProvider?: string; styleOverride?: string }) => {
seen.gl = { engine: 'mapbox', ...props };
return <div data-testid="gl-map" />;
},
}));
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
}));
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
vi.mock('../../native/platform', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/platform')>()),
isNativeIos: () => false,
}));
const realGetContext = HTMLCanvasElement.prototype.getContext;
beforeEach(() => {
resetAllStores();
resetWebGLProbe();
env.offline = false;
env.dark = false;
env.tileStore = false;
seen.leaflet = null;
seen.gl = null;
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
type === 'webgl2' ? ctx : null
) as unknown as typeof realGetContext;
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
const pill = () => screen.queryByRole('status');
describe('MapViewAuto offline', () => {
it('FE-COMP-MAPAUTO-OFFLINE-001: a Mapbox map online stays Mapbox, with no pill', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'mapbox', glProvider: 'mapbox-gl', styleOverride: undefined });
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-002: offline, a Mapbox map draws the stored basemap with MapLibre and says so', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
env.offline = true;
env.dark = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'maplibre', glProvider: 'maplibre-gl', styleOverride: OFM_DARK });
expect(pill()).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-003: a MapLibre map already on a stored style stays as it is', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', maplibre_style: OFM_POSITRON } });
env.offline = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl?.styleOverride).toBeUndefined();
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-004: offline, Leaflet on OpenStreetMap tiles switches to the stored basemap', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
render(<MapViewAuto places={[]} tileUrl="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe(OFM_POSITRON);
expect(pill()).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-005: offline, a raster template the service worker stored stays', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined });
const toggle = screen.getByRole('button', { pressed: false });
fireEvent.click(toggle);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON });
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
});
});
+13 -81
View File
@@ -1,5 +1,5 @@
import { Suspense, useState } from 'react'
import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react'
import { Suspense } from 'react'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
@@ -9,22 +9,15 @@ import { useGlRenderer } from './glSupport'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useIsDark } from '../../hooks/useIsDark'
import { OFM_POSITRON } from '../../constants/mapDefaults'
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
import { tileCacheActive } from '../../native/tileCache'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: "prepare for offline" stores the OpenFreeMap basemap for every
// trip, whatever the provider (sync/tilePrefetcher.ts). Offline, a map whose own
// basemap cannot be drawn from storage (Mapbox, another OpenFreeMap style, a
// raster template the device cannot serve) switches to that stored basemap,
// with the same markers, routes and tracks, and says so with a small pill. It
// switches back once the connection returns. See offlineBasemap.ts.
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the GL tile rules in vite.config.js), not prefetched.
export function MapViewAuto(props: MapViewAutoProps) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
@@ -39,31 +32,7 @@ export function MapViewAuto(props: MapViewAutoProps) {
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
// browser has no WebGL2 (both GL engines need it), so the trip planner never
// shows an empty map or one that fails to start.
const configuredGl = useGlRenderer(provider, token)
const maplibreStyle = useSettingsStore(s => s.settings.maplibre_style)
const { offline } = useNetworkMode()
const dark = useIsDark()
const offlineStyle = offlineStyleFor(dark)
// In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30
// days, so offline it stays Mapbox, and the pill offers the stored trip region
// instead for the places never looked at. Everywhere else a basemap with
// nothing stored is swapped for the stored one straight away.
const nativeStore = tileCacheActive()
const [preferStored, setPreferStored] = useState(false)
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
if (leafletFallback) mapProps.tileUrl = offlineStyle
const pill = offline && mapboxKept
? (
<OfflineBasemapPill
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
pressed={preferStored}
onToggle={() => setPreferStored(v => !v)}
/>
)
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
const glProvider = useGlRenderer(provider, token)
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -76,49 +45,12 @@ export function MapViewAuto(props: MapViewAutoProps) {
// to Leaflet keeps a usable map instead of an error card.
// resetKeys: with two engine chunks, a failure under one provider must not
// keep showing Leaflet after the user switches to the other.
<>
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} styleOverride={glFallback ? offlineStyle : undefined} />
</Suspense>
</ErrorBoundary>
{pill}
</>
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
)
}
return (
<>
<MapView {...mapProps} />
{pill}
</>
)
}
/** A raster template cannot be served offline in the iOS app unless it has its tile store. */
function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
return nativeStore ? false : undefined
}
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
/**
* Says the map on screen is the stored offline basemap, not the user's own. With
* onToggle it is a switch between the user's map (as far as it was viewed and
* stored) and the stored trip region.
*/
function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) {
if (onToggle) {
return (
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</button>
)
}
return (
<div role="status" className={`${PILL} pointer-events-none`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</div>
)
return <MapView {...mapProps} />
}
+30 -21
View File
@@ -15,9 +15,7 @@ import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { useMarkerThumbs } from './useMarkerThumbs'
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { bottomChromePadding } from './mapChromePadding'
import { useSelectionCameraGL } from './useSelectionCameraGL'
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -27,7 +25,6 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap, initialMapStyle } from './glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -54,6 +51,7 @@ import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefault
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
@@ -101,8 +99,6 @@ const NO_DAYS: Day[] = []
/** The GL renderer: the shared map contract plus the engine it is bound to. */
interface Props extends MapViewProps {
glProvider?: GlMapProvider
/** Draws this style instead of the user's: the offline basemap (offlineBasemap.ts). */
styleOverride?: string
/**
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
* statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl
@@ -128,7 +124,10 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = safeHexColor(place.category_color, '#6b7280')
// The visual circle is `size` + 2*border on each side. To make the
@@ -170,7 +169,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const hasPhoto = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
if (hasPhoto) {
wrap.innerHTML = `
<div class="${frameClass}" style="
<div style="
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
@@ -184,7 +183,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
`
} else {
wrap.innerHTML = `
<div class="${frameClass}" style="
<div style="
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
@@ -456,7 +455,6 @@ export function MapViewGL({
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
inspectorHeight = 0,
hasDayDetail = false,
reservations = NO_RESERVATIONS,
visibleConnectionIds = NO_CONNECTION_IDS,
@@ -485,7 +483,6 @@ export function MapViewGL({
onRemoveVia,
onViewportChange,
glProvider = 'mapbox-gl',
styleOverride,
gl,
onMapReady,
...assistant
@@ -510,7 +507,7 @@ export function MapViewGL({
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const glStyle = styleOverride ?? styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const [mapReady, setMapReady] = useState(false)
const [readyMap, setReadyMap] = useState<FerrataGlMap | null>(null) // the ready map, read without touching mapRef
@@ -1019,9 +1016,6 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false
@@ -1923,9 +1917,9 @@ export function MapViewGL({
const paddingOpts = useMemo(() => {
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
const top = 60
const bottom = bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail })
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
useAssistantMapGL({ mapRef, mapReady, gl, pinsRef: assistantGhostPinsRef, attachPin: attachPlannerWaypointPin, markersRef, padding: paddingOpts, assistant })
const prevFitKey = useRef<number | null>(-1)
@@ -2017,10 +2011,25 @@ export function MapViewGL({
} catch { /* noop */ }
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
useSelectionCameraGL({
mapRef, selectedPlaceId, places, dayPlaces, selectedPlace,
follow: followSelection, padding: paddingOpts, enableMapbox3d,
})
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId || !followSelection) return
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return
try {
map.flyTo({
center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
// than the geometric centre (where the bottom panel would cover it).
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation
const jumpedToRef = useRef<[number, number] | null>(null)
@@ -146,17 +146,6 @@ describe('PoiCategoryPill', () => {
}
})
it('FE-COMP-POIPILL-013b: in the phone chrome the bar wears the day rail glass at its 36px height', () => {
pill({ categories: WITH_PLUGINS, fullWidth: true, phoneChrome: true })
for (const button of screen.getAllByRole('button')) {
expect(button.style.minWidth).toBe('34px')
expect(button.style.height).toBe('28px')
}
const bar = screen.getAllByRole('button')[0].closest('[style*="overflow"]') as HTMLElement
expect(bar.style.padding).toBe('3px')
expect(bar.style.background).toBe('var(--m-glass)')
})
it('FE-COMP-POIPILL-014: a desktop segment keeps its 34px instead of shrinking in a narrow corridor', () => {
pill({ categories: WITH_PLUGINS })
for (const button of screen.getAllByRole('button')) {
+5 -20
View File
@@ -18,23 +18,10 @@ interface Props {
* The phone map gives it the full width between the screen margins; on
* desktop it floats, so it stays content-width there. */
fullWidth?: boolean
/** The phone shell's chrome: the glass, hairline and 36px height of the day rail
* and the tab switch above it, so the three bars read as one set. */
phoneChrome?: boolean
}
// The touch target every segment keeps, however many categories there are.
const SEGMENT = 34
// On the phone the segment is as tall as a day chip; it keeps its width.
const PHONE_SEGMENT_HEIGHT = 28
/** The phone shell's glass, as the day rail draws it (bg/border --m-glass/--m-gbr). */
const PHONE_GLASS: React.CSSProperties = {
background: 'var(--m-glass)',
border: '1px solid var(--m-gbr)',
backdropFilter: 'blur(24px) saturate(170%)',
WebkitBackdropFilter: 'blur(24px) saturate(170%)',
}
// How far one wheel step moves the bar. Firefox counts a mouse wheel in lines and
// some drivers in pages; a line moves about one segment, a page one bar width.
@@ -63,11 +50,10 @@ interface SegmentProps {
loading: boolean
failed: boolean
fullWidth?: boolean
phoneChrome?: boolean
onToggle: (key: string) => void
}
function Segment({ cat, on, loading, failed, fullWidth, phoneChrome, onToggle }: Readonly<SegmentProps>) {
function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<SegmentProps>) {
return (
<Tooltip label={cat.label} placement="bottom">
<button
@@ -85,7 +71,7 @@ function Segment({ cat, on, loading, failed, fullWidth, phoneChrome, onToggle }:
flexShrink: fullWidth ? 1 : 0,
flexBasis: fullWidth ? 0 : undefined,
minWidth: SEGMENT,
width: fullWidth ? 'auto' : SEGMENT, height: phoneChrome ? PHONE_SEGMENT_HEIGHT : SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
width: fullWidth ? 'auto' : SEGMENT, height: SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
color: on ? '#fff' : undefined,
transition: 'background 0.14s, color 0.14s',
@@ -122,11 +108,11 @@ function Segment({ cat, on, loading, failed, fullWidth, phoneChrome, onToggle }:
// and whatever plugins add follows a thin divider (#1781); once that is more than the
// width holds, the bar scrolls sideways (by swipe, trackpad or mouse wheel) rather than
// shrinking a segment below the touch target.
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth, phoneChrome }: Readonly<Props>) {
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth }: Readonly<Props>) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = phoneChrome ? PHONE_GLASS : {
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
@@ -146,7 +132,6 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
loading={loading}
failed={!!errorKeys?.has(cat.key)}
fullWidth={fullWidth}
phoneChrome={phoneChrome}
onToggle={onToggle}
/>
)
@@ -157,7 +142,7 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
<div onWheel={wheelSideways} style={{
display: fullWidth ? 'flex' : 'inline-flex',
alignSelf: fullWidth ? 'stretch' : undefined,
alignItems: 'center', gap: 2, padding: phoneChrome ? 3 : 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
maxWidth: '100%', overflowX: 'auto', scrollbarWidth: 'none',
}}>
{categories.core.map(segment)}
-136
View File
@@ -1,136 +0,0 @@
import L from 'leaflet';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng, viewLngFrame, type LngFrame } from '../../utils/lngFrame';
import ClusteredPois from './ClusteredPois';
import { makePoiDraggable } from './markerDrag';
import type { Poi } from './poiCategories';
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker';
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>();
function createPoiIcon(poi: Pick<Poi, 'category' | 'icon' | 'color'>) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike.
// A plugin category's key carries its colour and icon too, so two looks never share a pin.
const cacheKey = poiPinCacheKey(poi);
const cached = poiIconCache.get(cacheKey);
if (cached) return cached;
const { color, svg } = poiPinParts(poi);
const icon = L.divIcon({
className: '',
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
});
poiIconCache.set(cacheKey, icon);
return icon;
}
/**
* The hover tooltip of an explore POI: its name, and for a plugin POI the rows the
* plugin knows about it (the GL hover card shows the same, see buildPoiPopupHtml).
* React text throughout, so nothing a plugin sends is ever read as markup. A POI
* without rows keeps the bare name it has always had.
*/
function PoiTooltipBody({ poi }: Readonly<{ poi: Poi }>) {
const rows = poiDetailRows(poi);
if (!rows.length) return poi.name;
// w-max because Leaflet's tooltip pane is 0px wide: without an intrinsic width the
// tooltip shrinks to its narrowest layout and the values wrap a letter per line. The
// label column stops at 45% and wraps, so a long label leaves its value room too.
return (
<div className="w-max max-w-56 whitespace-normal">
<div className="truncate font-semibold">{poi.name}</div>
<div className="mt-1 grid grid-cols-[fit-content(45%)_1fr] gap-x-1.5 font-normal" data-testid="poi-details">
{rows.map((row) => (
<div key={poiDetailRowKey(row)} className="contents">
<span className="text-content-muted [overflow-wrap:anywhere]">{row.label}</span>
<span className="[overflow-wrap:anywhere]">{row.value}</span>
</div>
))}
</div>
</div>
);
}
/**
* The explore POIs of the Leaflet map, clustered when asked. They are fetched for the
* visible area, which can lie outside the trip's longitude frame or on another world
* copy, so they are drawn on the copy in view, followed as the view moves (#2975).
*/
export default function PoiMarkers({
pois,
clusterLoosely,
onPoiClick,
draggable,
}: {
pois: Poi[];
clusterLoosely: boolean;
onPoiClick?: (poi: Poi) => void;
/** Whether a pin can be dragged onto the route. */
draggable: boolean;
}) {
const map = useMap();
const [frame, setFrame] = useState<LngFrame | null>(null);
// Followed only while there are POIs to draw, so a map without them keeps no listeners.
const tracking = pois.length > 0;
useEffect(() => {
if (!tracking) return undefined;
const follow = () => setFrame((current) => viewLngFrame(current, map.getCenter().lng));
map.whenReady(follow);
map.on('moveend', follow);
return () => {
map.off('moveend', follow);
};
}, [map, tracking]);
// Native clicks keep the newest handler without rebuilding every marker.
const clickRef = useRef(onPoiClick);
useEffect(() => {
clickRef.current = onPoiClick;
}, [onPoiClick]);
const markers = useMemo(
() =>
pois.map((poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, frameLng(frame, poi.lng)]}
icon={createPoiIcon(poi)}
zIndexOffset={500}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement();
if (!el) return;
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name);
el.onclick = (event) => {
event.stopPropagation();
clickRef.current?.(poi);
};
if (draggable) makePoiDraggable(el, poi.osm_id);
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
<PoiTooltipBody poi={poi} />
</Tooltip>
</Marker>
)),
[pois, onPoiClick, draggable, frame]
);
return (
<ClusteredPois pois={pois} frame={frame} enabled={clusterLoosely} onPoiClick={onPoiClick}>
{markers}
</ClusteredPois>
);
}
@@ -1,79 +0,0 @@
import type L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import type { Place } from '../../types';
import { frameLng } from '../../utils/lngFrame';
import type { ViewportPadding } from '../../utils/mapViewport';
import { useLngFrame } from './lngFrameContext';
import { isUnderChrome } from './mapChromePadding';
import { selectedPlaceTarget } from './selectedPlaceTarget';
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean;
places: Place[];
selectedPlaceId: number | null;
dayPlaces: Place[];
selectedPlace: Place | null;
paddingBox: ViewportPadding;
}
/**
* Where the Leaflet map goes when a place is selected: there, and out from under the
* panels. The GL twin is `useSelectionCameraGL`.
*/
export function SelectionController({
follow,
places,
selectedPlaceId,
dayPlaces,
selectedPlace,
paddingBox,
}: SelectionControllerProps) {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<number | null>(null);
/** The panel geometry the last pan was measured against. */
const pannedWith = useRef(paddingBox);
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
const lat = target?.lat ?? null;
const lng = target?.lng == null ? null : frameLng(frame, target.lng);
useEffect(() => {
const isNew = selectedPlaceId !== prev.current;
prev.current = selectedPlaceId;
if (!isNew || !follow || !selectedPlaceId || lat == null || lng == null) return;
pannedWith.current = paddingBox;
panToPlace(map, [lat, lng], paddingBox);
}, [selectedPlaceId, follow, lat, lng, map, paddingBox]);
// The place card only learns its height after it laid out, and grows again when its
// details arrive. If it grew over the pin the selection placed, bring the pin back out;
// a pin still in the open part of the map stays where the traveller left it (#2991).
useEffect(() => {
if (pannedWith.current === paddingBox) return;
pannedWith.current = paddingBox;
if (!follow || !selectedPlaceId || lat == null || lng == null) return;
if (typeof map.latLngToContainerPoint !== 'function') return;
if (isUnderChrome(map.latLngToContainerPoint([lat, lng]), map.getSize(), paddingBox)) {
panToPlace(map, [lat, lng], paddingBox);
}
}, [paddingBox, follow, selectedPlaceId, lat, lng, map]);
return null;
}
/**
* Pan to a place without changing zoom. The centre is offset by the side panels and the
* bottom card, so the pin lands in the middle of the *visible* map area rather than the
* geometric centre, where the card would cover it.
*/
function panToPlace(map: L.Map, latlng: [number, number], padding: ViewportPadding): void {
if (typeof map.project !== 'function' || typeof map.unproject !== 'function') {
map.panTo(latlng, { animate: true });
return;
}
const { top, right, bottom, left } = padding;
const point = map.project(latlng).add([(right - left) / 2, (bottom - top) / 2]);
map.panTo(map.unproject(point), { animate: true });
}
@@ -1,4 +1,3 @@
import type { Ref } from 'react';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher';
/** The card's gap to the side panels where nothing else sits at the map's edge. */
@@ -37,13 +36,3 @@ export function centredMapCard(
const width = `min(800px, calc(${free} - ${PILL_CLEARANCE + EDGE_GAP}px))`;
return { width, centre: `max(${centre}, calc(${leftWidth}px + ${PILL_CLEARANCE}px + ${width} / 2))` };
}
/** How a card that floats over the map is placed there, read by `centredMapCard`. */
export interface MapCardPlacement {
leftWidth?: number;
rightWidth?: number;
/** Over the desktop map, beside the base-layer pill. */
clearMapPill?: boolean;
/** The card's outer element, measured so the map keeps the selected pin above it (#2991). */
cardRef?: Ref<HTMLDivElement>;
}
@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest';
import { bottomChromePadding, isUnderChrome, PLACE_CARD_OFFSET_PX } from './mapChromePadding';
describe('bottomChromePadding', () => {
it('MAPCHROME-001: keeps the old allowances when nothing was measured', () => {
expect(bottomChromePadding({})).toBe(60);
expect(bottomChromePadding({ hasDayDetail: true })).toBe(280);
expect(bottomChromePadding({ hasInspector: true })).toBe(320);
expect(bottomChromePadding({ hasInspector: true, hasDayDetail: true })).toBe(320);
});
it('MAPCHROME-002: never goes below the allowance for a short card', () => {
expect(bottomChromePadding({ hasInspector: true, inspectorHeight: 180 })).toBe(320);
});
it('MAPCHROME-003: covers a tall card with room to spare above it (#2991)', () => {
// A 60vh card on a portrait iPad, 1366px tall.
const card = 820;
const padding = bottomChromePadding({ hasInspector: true, inspectorHeight: card });
expect(padding).toBeGreaterThan(card + PLACE_CARD_OFFSET_PX);
});
it('MAPCHROME-004: ignores a measured card that is no longer open', () => {
expect(bottomChromePadding({ hasInspector: false, inspectorHeight: 820 })).toBe(60);
});
});
describe('isUnderChrome', () => {
const size = { x: 1000, y: 1200 };
const padding = { top: 60, right: 340, bottom: 900, left: 340 };
it('MAPCHROME-005: reads the open strip between the panels as clear', () => {
expect(isUnderChrome({ x: 500, y: 200 }, size, padding)).toBe(false);
expect(isUnderChrome({ x: 500, y: 300 }, size, padding)).toBe(false);
});
it('MAPCHROME-006: reads a point below the card top, beside a panel or under the header as covered', () => {
expect(isUnderChrome({ x: 500, y: 301 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 200, y: 200 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 700, y: 200 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 500, y: 40 }, size, padding)).toBe(true);
});
});
@@ -1,47 +0,0 @@
import type { ViewportPadding } from '../../utils/mapViewport';
/** How far above the map's bottom edge the place card floats (PlaceInspector's `bottom`). */
export const PLACE_CARD_OFFSET_PX = 20;
/** Map kept clear above the place card, so a pin panned there is not touching its edge. */
const PLACE_CARD_CLEARANCE_PX = 40;
/** What a place card is assumed to cover before it has been measured. */
const PLACE_CARD_MIN_COVER_PX = 320;
export interface BottomChrome {
hasInspector?: boolean;
/** The place card's measured height in pixels, 0 or absent while it is not on screen. */
inspectorHeight?: number;
hasDayDetail?: boolean;
}
/**
* How much of the map's bottom edge the floating panels cover, the same on both renderers.
*
* The place card grows with its content up to 60vh, which on a portrait tablet reaches past
* the middle of the map. A fixed allowance put the selected pin right under it (#2991), so
* the card's real height counts once it is known; the fixed value stays the floor for the
* moment between the selection and the card's first layout.
*/
export function bottomChromePadding({ hasInspector, inspectorHeight = 0, hasDayDetail }: BottomChrome): number {
if (hasInspector)
return Math.max(PLACE_CARD_MIN_COVER_PX, inspectorHeight + PLACE_CARD_OFFSET_PX + PLACE_CARD_CLEARANCE_PX);
return hasDayDetail ? 280 : 60;
}
/**
* Whether a point on the map's container sits under the panels around it rather than in
* the open part of the map. Used to bring the selected pin back out after a panel grew
* over it, and to leave the camera alone when it did not.
*/
export function isUnderChrome(
point: { x: number; y: number },
size: { x: number; y: number },
padding: ViewportPadding
): boolean {
return (
point.x < padding.left ||
point.x > size.x - padding.right ||
point.y < padding.top ||
point.y > size.y - padding.bottom
);
}
@@ -80,11 +80,6 @@ export interface MapViewProps {
leftWidth?: number;
rightWidth?: number;
hasInspector?: boolean;
/**
* The place card's measured height in pixels while it is open. The selection pans the
* pin above it rather than above a fixed allowance the card can outgrow (#2991).
*/
inspectorHeight?: number;
hasDayDetail?: boolean;
reservations?: Reservation[];
visibleConnectionIds?: number[];
@@ -9,7 +9,7 @@
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, REVEAL_FAN_MAX_CHILDREN, revealInCluster } from './markerCluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, revealInCluster } from './markerCluster'
import { COINCIDENT_RADIUS_PX, STACK_RADIUS_PX } from './coincidentPlaces'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
@@ -118,44 +118,4 @@ describe('reaching a stop inside a bubble', () => {
// And a parent from a group that cannot spiderfy is left alone rather than called.
expect(revealInCluster({ getVisibleParent: () => ({}) }, {})).toBe(false)
})
/** A city's worth of stops around HOTEL, a few hundred metres apart, as a big trip has them. */
function city(count: number): [number, number][] {
return Array.from({ length: count }, (_, i) => [HOTEL[0] + (i % 8) * 0.003, HOTEL[1] + Math.floor(i / 8) * 0.004])
}
const fanned = (markers: L.Marker[], start: [number, number][]) =>
markers.some((marker, i) => marker.getLatLng().lat !== start[i][0] || marker.getLatLng().lng !== start[i][1])
it('MARKERCLUSTER-009: leaves a city-sized bubble of the overview closed (#2990)', () => {
const coords = city(60)
const { group, markers } = stopsOn(mapAt(5), coords)
const bubble = group.getVisibleParent(markers[17])
expect(bubble).not.toBe(markers[17])
expect(revealInCluster(group, markers[17])).toBe(false)
expect(fanned(markers, coords)).toBe(false)
})
it('MARKERCLUSTER-010: still opens a small bubble of the overview', () => {
const coords = city(REVEAL_FAN_MAX_CHILDREN)
const { group, markers } = stopsOn(mapAt(5), coords)
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
expect(revealInCluster(group, markers[0])).toBe(true)
expect(fanned(markers, coords)).toBe(true)
})
it('MARKERCLUSTER-011: opens a stack of any size when every stop shares one coordinate', () => {
const coords = Array.from({ length: REVEAL_FAN_MAX_CHILDREN + 8 }, () => HOTEL)
const { group, markers } = stopsOn(mapAt(16), coords)
expect(revealInCluster(group, markers[3])).toBe(true)
expect(new Set(markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)).size).toBe(coords.length)
})
it('MARKERCLUSTER-012: reads a bubble without bounds as one that cannot be told apart from a city', () => {
const spiderfy = () => { throw new Error('must not fan') }
const big = { spiderfy, getChildCount: () => REVEAL_FAN_MAX_CHILDREN + 1 }
expect(revealInCluster({ getVisibleParent: () => big }, {})).toBe(false)
})
})
+4 -37
View File
@@ -42,52 +42,19 @@ export interface ClusterGroupLike {
getVisibleParent?: (marker: unknown) => unknown
}
/** The part of a cluster bubble that `revealInCluster` reads. */
interface RevealableCluster {
spiderfy: () => void
getChildCount: () => number
getBounds?: () => { getNorthEast: () => L.LatLng; getSouthWest: () => L.LatLng }
}
/**
* The most stops a selection fans open around itself. Up to here the fan stays a small
* ring around the bubble; a city-sized bubble from the overview would throw hundreds of
* legs across the whole map instead (#2990).
*/
export const REVEAL_FAN_MAX_CHILDREN = 12
/**
* Whether opening this bubble shows the selection or buries the map.
*
* A small bubble always opens. A bigger one only when every stop in it sits on the same
* coordinate, the stack no zoom can take apart, which is the case the fan is there for.
* Anything else is a pile of ordinary neighbours that zooming in separates, so it stays
* closed and the selection is shown by the pan alone.
*/
function fansOpenOnSelect(cluster: RevealableCluster): boolean {
if (cluster.getChildCount() <= REVEAL_FAN_MAX_CHILDREN) return true
const bounds = cluster.getBounds?.()
return !!bounds && bounds.getNorthEast().equals(bounds.getSouthWest())
}
function isRevealableCluster(layer: unknown): layer is RevealableCluster {
const candidate = layer as Partial<RevealableCluster> | null
return !!candidate && typeof candidate.spiderfy === 'function' && typeof candidate.getChildCount === 'function'
}
/**
* Fan open the bubble a stop is hidden inside, if it is in one and small enough.
* Fan open the bubble a stop is hidden inside, if it is in one.
*
* Picking a stop from the places rail used to raise its pin out of the stack with a
* z-index. A stop sharing its coordinates now sits in a cluster instead and has no pin
* of its own to raise, so the bubble has to open for the selection to be visible at all.
* Deliberately not `zoomToShowLayer`: the zoom belongs to the day fit, and the selection
* only ever pans. See `fansOpenOnSelect` for which bubbles are opened.
* only ever pans.
*/
export function revealInCluster(group: ClusterGroupLike | null | undefined, marker: unknown): boolean {
if (!group || !marker || typeof group.getVisibleParent !== 'function') return false
const parent = group.getVisibleParent(marker)
if (parent === marker || !isRevealableCluster(parent) || !fansOpenOnSelect(parent)) return false
const parent = group.getVisibleParent(marker) as { spiderfy?: () => void } | null
if (!parent || parent === marker || typeof parent.spiderfy !== 'function') return false
parent.spiderfy()
return true
}
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { placeMarkerFrame, placeMarkerLook, SELECTED_MARKER } from './markerLook';
describe('placeMarkerFrame', () => {
it('MARKERLOOK-001: gives the selected pin the white border, blue ring and pulse class (#2991)', () => {
expect(placeMarkerFrame(true, '#ff0000')).toEqual({
borderColor: SELECTED_MARKER.border,
shadow: SELECTED_MARKER.ring,
className: SELECTED_MARKER.className,
});
expect(SELECTED_MARKER.ring).toContain('#0a84ff');
});
it('MARKERLOOK-002: keeps every other pin in its category colour, without the class', () => {
const frame = placeMarkerFrame(false, '#ff0000');
expect(frame.borderColor).toBe('#ff0000');
expect(frame.className).toBe('');
expect(frame.shadow).not.toContain('#0a84ff');
});
});
describe('placeMarkerLook', () => {
it('MARKERLOOK-003: draws a selected pin larger than a planned one, even when it was compact', () => {
expect(placeMarkerLook({ _compact: true }, true).size).toBeGreaterThan(placeMarkerLook({}, false).size);
expect(placeMarkerLook({ _compact: true }, false).showPhoto).toBe(false);
});
});
-23
View File
@@ -1,5 +1,3 @@
import './selectedMarker.css'
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
* planner hands to the map. Underscored because they are the map's reading of the plan,
@@ -41,24 +39,3 @@ export function placeMarkerLook(place: PlaceMarkerFlags, selected: boolean): Pla
key: `${compact ? 'c' : ''}${pending ? 'p' : ''}`,
}
}
/**
* How the selected place stands out from every other pin, the same on both renderers.
*
* Map paint rather than theme: it sits on tiles that are light, dark or a photo, beside
* pins whose border is their category colour. The old near-black border and grey halo
* vanished on dark tiles (#2991); a white border inside a saturated blue ring reads on
* all of them, and the class adds a slow pulse (selectedMarker.css) that stops under reduced motion.
*/
export const SELECTED_MARKER = {
border: '#ffffff', // theme-lint-disable: map paint, has to read on any tile
ring: '0 0 0 3px #0a84ff, 0 4px 14px rgba(0,0,0,0.35)',
className: 'trek-marker-selected',
} as const
/** The border colour and shadow of a round place marker, selected or not. */
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
return selected
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
describe('offlineBasemap', () => {
it('picks the stored style for the colour scheme', () => {
expect(offlineStyleFor(false)).toBe(OFM_POSITRON);
expect(offlineStyleFor(true)).toBe(OFM_DARK);
});
it('knows OpenStreetMap standard tiles may not be stored, sharded or not', () => {
expect(isOfflineForbiddenTemplate('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBe(false);
expect(isOfflineForbiddenTemplate('not a url')).toBe(false);
});
it('lets only MapLibre on a stored style draw offline as configured', () => {
expect(glWorksOffline('maplibre-gl', OFM_POSITRON)).toBe(true);
expect(glWorksOffline('maplibre-gl', OFM_DARK)).toBe(true);
// The default MapLibre style is Liberty, whose sprite and glyphs are not stored.
expect(glWorksOffline('maplibre-gl', '')).toBe(false);
expect(glWorksOffline('mapbox-gl', OFM_POSITRON)).toBe(false);
});
it('keeps a stored raster template on the web, never in the iOS app or for OSM', () => {
expect(leafletWorksOffline(OFM_POSITRON, true)).toBe(true);
expect(leafletWorksOffline('https://tiles.openfreemap.org/styles/liberty', false)).toBe(false);
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', false)).toBe(true);
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', true)).toBe(false);
expect(leafletWorksOffline('https://tile.openstreetmap.org/{z}/{x}/{y}.png', false)).toBe(false);
});
});
@@ -1,54 +0,0 @@
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { isNativeIos } from '../../native/platform';
import { isVectorStyle } from '../../utils/tileUrl';
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
/**
* The basemap a map falls back to offline, so that whatever the user picked
* online (Mapbox, a raster template, another OpenFreeMap style) they never
* face a grey map once the connection goes.
*
* The two OpenFreeMap styles below are the ones "prepare for offline" stores
* for every trip, whatever the configured provider (sync/tilePrefetcher.ts):
* both share one planet tile set, so the dark one only adds its style, sprite
* and glyphs. A fallback has to be exactly a style that was stored, or its
* sprite and labels are missing offline.
*/
export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
/** The stored style that matches the colour scheme. */
export function offlineStyleFor(dark: boolean): string {
return dark ? OFM_DARK : OFM_POSITRON;
}
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
export function isOfflineForbiddenTemplate(template: string): boolean {
try {
const host = new URL(template.replace(/\{s\}/g, 'a')).hostname;
return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
} catch {
return false;
}
}
/**
* Whether a GL map can be drawn offline as configured. Only MapLibre on one of
* the stored styles can: Mapbox has nothing stored to draw from, and another
* OpenFreeMap style lacks its sprite and glyphs offline.
*/
export function glWorksOffline(provider: GlMapProvider, maplibreStyle: string | null | undefined): boolean {
return provider === 'maplibre-gl' && OFFLINE_STYLES.includes(normalizeStyleForProvider('maplibre-gl', maplibreStyle));
}
/**
* Whether the Leaflet map's basemap can be drawn offline as configured. A
* stored vector style can. A raster template can where the service worker
* serves the prefetched tiles, which the iOS app's WebView does not run for a
* self-hosted server, and never for a host whose tiles may not be stored.
*/
export function leafletWorksOffline(tileUrl: string, nativeIos: boolean = isNativeIos()): boolean {
if (isVectorStyle(tileUrl)) return OFFLINE_STYLES.includes(tileUrl);
return !nativeIos && !isOfflineForbiddenTemplate(tileUrl);
}
@@ -1,28 +0,0 @@
/* The selected place pin on either map (#2991). Its static ring is inline, so a browser
without the animation still shows it; the pulse only widens a fading second ring
around it, and the pin itself never moves. */
@keyframes trek-marker-selected-pulse {
0% {
box-shadow:
0 0 0 3px #0a84ff,
0 0 0 3px rgba(10, 132, 255, 0.55),
0 4px 14px rgba(0, 0, 0, 0.35);
}
100% {
box-shadow:
0 0 0 3px #0a84ff,
0 0 0 16px rgba(10, 132, 255, 0),
0 4px 14px rgba(0, 0, 0, 0.35);
}
}
.trek-marker-selected {
animation: trek-marker-selected-pulse 1.8s ease-out infinite;
}
/* Under reduced motion the ring stays, it just stops breathing. */
@media (prefers-reduced-motion: reduce) {
.trek-marker-selected {
animation: none;
}
}
@@ -1,79 +0,0 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { bottomChromePadding } from './mapChromePadding';
import { useSelectionCameraGL } from './useSelectionCameraGL';
/** A 1000x1200 map whose projection is lng/lat times ten. */
function glMap() {
return {
getZoom: vi.fn(() => 12),
flyTo: vi.fn(),
easeTo: vi.fn(),
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
getContainer: vi.fn(() => ({ clientWidth: 1000, clientHeight: 1200 })),
};
}
// Projected to (500, 700) and (500, 200).
const low = { id: 1, lat: 70, lng: 50 };
const high = { id: 2, lat: 20, lng: 50 };
function padding(inspectorHeight: number) {
return { top: 60, right: 340, left: 340, bottom: bottomChromePadding({ hasInspector: true, inspectorHeight }) };
}
function setup(selectedPlaceId: number, follow = true) {
const map = glMap();
const mapRef = { current: map };
const view = renderHook(
({ selected, pad }) =>
useSelectionCameraGL({
mapRef,
selectedPlaceId: selected,
places: [low, high],
dayPlaces: [],
selectedPlace: null,
follow,
padding: pad,
enableMapbox3d: false,
}),
{ initialProps: { selected: selectedPlaceId, pad: padding(0) } }
);
return { map, ...view };
}
describe('useSelectionCameraGL', () => {
it('SELCAM-GL-001: flies to a new selection, padded by the panels, at least to zoom 14', () => {
const { map } = setup(1);
expect(map.flyTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [50, 70], zoom: 14, padding: padding(0) })
);
});
it('SELCAM-GL-002: eases the pin back out when the place card grew over it (#2991)', () => {
const { map, rerender } = setup(1);
rerender({ selected: 1, pad: padding(900) });
expect(map.easeTo).toHaveBeenCalledWith({ center: [50, 70], padding: padding(900), duration: 300 });
expect(map.flyTo).toHaveBeenCalledTimes(1);
});
it('SELCAM-GL-003: leaves a pin that is still in the open where it is', () => {
const { map, rerender } = setup(2);
rerender({ selected: 2, pad: padding(900) });
expect(map.easeTo).not.toHaveBeenCalled();
});
it('SELCAM-GL-004: moves only once when the selection and the card arrive together', () => {
const { map, rerender } = setup(2);
rerender({ selected: 1, pad: padding(900) });
expect(map.flyTo).toHaveBeenCalledTimes(2);
expect(map.easeTo).not.toHaveBeenCalled();
});
it('SELCAM-GL-005: does not move a locked map at all', () => {
const { map, rerender } = setup(1, false);
rerender({ selected: 1, pad: padding(900) });
expect(map.flyTo).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
});
});
@@ -1,89 +0,0 @@
import { useEffect, useRef } from 'react';
import type { ViewportPadding } from '../../utils/mapViewport';
import { isUnderChrome } from './mapChromePadding';
import { selectedPlaceTarget, type MapTarget } from './selectedPlaceTarget';
/** The part of a MapLibre/Mapbox map the selection camera drives. */
interface SelectionCameraMap {
getZoom: () => number;
flyTo: (options: Record<string, unknown>) => unknown;
easeTo: (options: Record<string, unknown>) => unknown;
project?: (lngLat: [number, number]) => { x: number; y: number };
getContainer: () => { clientWidth: number; clientHeight: number };
}
interface SelectionCameraOptions<P extends MapTarget> {
mapRef: { readonly current: SelectionCameraMap | null | undefined };
selectedPlaceId: number | null;
places: P[];
dayPlaces: P[];
selectedPlace: P | null;
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean;
/** The side panels and the bottom card over the map, so the pin lands in the open part. */
padding: ViewportPadding;
enableMapbox3d: boolean;
}
/**
* Where the GL map goes when a place is selected: there, and out from under the panels.
*
* A selection flies to the pin, padded by the panels so it lands in the centre of the
* visible map rather than the geometric one. The place card only learns its height after
* it laid out and grows again when its details arrive; if it grew over the pin, the pin is
* eased back out above it at the same zoom, and one still in the open stays put (#2991).
*/
export function useSelectionCameraGL<P extends MapTarget>({
mapRef,
selectedPlaceId,
places,
dayPlaces,
selectedPlace,
follow,
padding,
enableMapbox3d,
}: SelectionCameraOptions<P>): void {
/** The panel geometry the last move to the selection was measured against. */
const movedWith = useRef(padding);
/** The selection (and 3D mode) the camera last flew for: only a change of it flies. */
const flownFor = useRef<string | null>(null);
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
const lat = target?.lat ?? null;
const lng = target?.lng ?? null;
useEffect(() => {
const key = `${selectedPlaceId}:${enableMapbox3d}`;
if (flownFor.current === key) return;
flownFor.current = key;
movedWith.current = padding;
const map = mapRef.current;
if (!map || !selectedPlaceId || !follow || !lat || !lng) return;
try {
map.flyTo({
center: [lng, lat],
zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
padding,
});
} catch {
/* noop */
}
}, [selectedPlaceId, enableMapbox3d, follow, lat, lng, mapRef, padding]);
useEffect(() => {
if (movedWith.current === padding) return;
movedWith.current = padding;
const map = mapRef.current;
if (!map || !selectedPlaceId || !follow || lat == null || lng == null) return;
if (typeof map.project !== 'function') return;
try {
const container = map.getContainer();
const size = { x: container.clientWidth, y: container.clientHeight };
if (!isUnderChrome(map.project([lng, lat]), size, padding)) return;
map.easeTo({ center: [lng, lat], padding, duration: 300 });
} catch {
/* noop */
}
}, [padding, selectedPlaceId, follow, lat, lng, mapRef]);
}
@@ -1,58 +0,0 @@
// FE-PACK-BRINGERCARD-001 to FE-PACK-BRINGERCARD-004
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { BringersCard, MoreBringers } from './PackingBringersCard';
import { bringersOf } from './packingBringers';
const members = [
{ id: 1, username: 'anna', avatar_url: '/uploads/avatars/anna.jpg' },
{ id: 2, username: 'ben', avatar: '/uploads/avatars/ben.jpg' },
{ id: 3, username: 'carla' },
];
describe('bringersOf', () => {
it('FE-PACK-BRINGERCARD-001: lists the bringer first, then the others, each with the picture the trip knows', () => {
const item = {
owner_id: 1,
owner_username: 'anna',
contributors: [
{ user_id: 2, username: 'ben' },
{ user_id: 3, username: 'carla' },
],
};
expect(bringersOf(item, members)).toEqual([
{ name: 'anna', avatar: '/uploads/avatars/anna.jpg' },
{ name: 'ben', avatar: '/uploads/avatars/ben.jpg' },
{ name: 'carla', avatar: null },
]);
});
it('FE-PACK-BRINGERCARD-002: lists a person once and copes with a bringer the trip no longer has', () => {
const item = { owner_id: 9, owner_username: 'dora', contributors: [{ user_id: 9, username: 'dora' }] };
expect(bringersOf(item, members)).toEqual([{ name: 'dora', avatar: null }]);
expect(bringersOf({}, members)).toEqual([]);
});
});
describe('BringersCard', () => {
it('FE-PACK-BRINGERCARD-003: names everyone on a line of their own under the heading', () => {
render(
<BringersCard
bringers={bringersOf(
{ owner_id: 1, owner_username: 'anna', contributors: [{ user_id: 3, username: 'carla' }] },
members
)}
/>
);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('anna');
expect(rows[1]).toHaveTextContent('carla');
expect(screen.getByText('Brought by')).toBeInTheDocument();
});
it('FE-PACK-BRINGERCARD-004: the "+N" circle counts the others', () => {
const { container } = render(<MoreBringers count={3} />);
expect(container).toHaveTextContent('+3');
});
});
@@ -1,91 +0,0 @@
import type { CSSProperties } from 'react';
import { useTranslation } from '../../i18n';
import type { Bringer } from './packingBringers';
const CIRCLE: CSSProperties = {
width: 20,
height: 20,
borderRadius: '50%',
flexShrink: 0,
display: 'grid',
placeItems: 'center',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontWeight: 700,
};
/** A bringer's picture, or the first letter of the name on a colour taken from it. */
export function BringerAvatar({ bringer }: { bringer: Bringer }) {
if (bringer.avatar) {
return <img src={bringer.avatar} alt="" style={{ ...CIRCLE, objectFit: 'cover', display: 'block' }} />;
}
return (
<span
aria-hidden
style={{
...CIRCLE,
background: `hsl(${((bringer.name.codePointAt(0) ?? 0) * 37) % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
color: 'white',
textTransform: 'uppercase',
}}
>
{bringer.name[0]}
</span>
);
}
/** The "+N" of the others bringing it, as a circle tucked under the first bringer's picture. */
export function MoreBringers({ count }: { count: number }) {
return (
<span
aria-hidden
style={{
...CIRCLE,
marginInlineStart: -6,
color: 'var(--text-muted)',
background: 'var(--bg-tertiary)',
boxShadow: '0 0 0 2px var(--bg-card)',
fontVariantNumeric: 'tabular-nums',
}}
>
+{count}
</span>
);
}
/** What the row's tooltip shows: everyone bringing the item, one per line with their picture. */
export function BringersCard({ bringers }: { bringers: readonly Bringer[] }) {
const { t } = useTranslation();
return (
<div style={{ minWidth: 150 }}>
<div
className="text-content-faint"
style={{
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))',
fontWeight: 700,
letterSpacing: '.06em',
textTransform: 'uppercase',
paddingBottom: 6,
}}
>
{t('packing.bringersTitle')}
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{bringers.map((b, i) => (
<li
key={b.name}
className={i > 0 ? 'border-t border-edge-faint' : undefined}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
>
<BringerAvatar bringer={b} />
<span
className="text-content"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
>
{b.name}
</span>
</li>
))}
</ul>
</div>
);
}
@@ -379,16 +379,13 @@ describe('ArtikelZeile — category picker', () => {
})
describe('ArtikelZeile — sharing badges', () => {
it('FE-W5ROW-018: an item somebody else brings shows their name in the TREK tooltip', async () => {
it('FE-W5ROW-018: an item somebody else brings shows their name', () => {
setup({
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 1, owner_id: 2, owner_username: 'Bob' } as Partial<PackingItem>),
currentUserId: 1,
})
const badge = screen.getByLabelText('by Bob')
expect(badge).not.toHaveAttribute('title')
fireEvent.mouseEnter(badge)
expect(await screen.findByRole('tooltip')).toHaveTextContent('by Bob')
expect(screen.getByLabelText('by Bob')).toHaveAttribute('title', 'by Bob')
})
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
@@ -397,10 +394,10 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByLabelText('by')).toBeInTheDocument()
expect(screen.getByTitle('by')).toBeInTheDocument()
})
it('FE-W5ROW-020: an item shared by me counts its recipients', async () => {
it('FE-W5ROW-020: an item shared by me counts its recipients', () => {
setup({
item: buildPackingItem({
id: 1, name: 'Stove', is_private: 1, owner_id: 1,
@@ -410,11 +407,10 @@ describe('ArtikelZeile — sharing badges', () => {
})
expect(screen.getByLabelText('shared with 2')).toHaveTextContent('2')
fireEvent.mouseEnter(screen.getByLabelText('shared with 2'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('shared with 2: alice, bob')
expect(screen.getByTitle('shared with 2: alice, bob')).toBeInTheDocument()
})
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', async () => {
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
setup({
item: buildPackingItem({
id: 1, name: 'Stove', is_private: 0, owner_username: 'Bob',
@@ -423,15 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
// The bringer's initial on an avatar, the co-bringers as a "+N" circle; the tooltip
// names everyone, each on a line of their own (#2782).
const badge = screen.getByLabelText('brought by Bob, cleo')
expect(badge).toHaveTextContent('B+1')
fireEvent.mouseEnter(badge)
const rows = within(await screen.findByRole('tooltip')).getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('Bob')
expect(rows[1]).toHaveTextContent('cleo')
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
})
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
@@ -440,7 +429,7 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByLabelText('brought by Bob')).toHaveTextContent(/^B$/)
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^B$/)
})
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {

Some files were not shown because too many files have changed in this diff Show More