mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a6b49b6b | |||
| 0855bafca7 | |||
| a39f3fe40c | |||
| f13a0aa6ee | |||
| 10d1a61614 | |||
| 2bcfc1bfcb | |||
| b6e3c9bf39 | |||
| 736dc5dda9 | |||
| ae26a6c61c | |||
| 8979bd1fcd | |||
| 74d243d8e0 | |||
| 56b9bac031 | |||
| 4983b3407c | |||
| ed1728feec | |||
| 11472c05ff | |||
| f2ac12ca4f | |||
| b53130ca0d | |||
| a3a4f7cf79 | |||
| ac7094df05 | |||
| 80130f7946 | |||
| fc648bc7e4 | |||
| 9005d6b333 | |||
| 52f0fb882f | |||
| b8ec428ad1 | |||
| a1d3ffc2ce | |||
| 46b9df54bf | |||
| 3c0c3b3f6e | |||
| 5395d53332 | |||
| ec08032e98 | |||
| cc5eca5553 | |||
| 1323183cce | |||
| 009be05d22 | |||
| 860ffc1dee | |||
| c515f95d17 | |||
| 855bf8610a | |||
| 742edc4a79 | |||
| 1975a23aab | |||
| 80c8d9c602 | |||
| 948c9ef57b | |||
| bac968c0bf | |||
| 520562ef86 | |||
| 001253c39b | |||
| 5345b50d17 | |||
| a3622e91b6 | |||
| be305c3daf | |||
| ec78cfbdd0 | |||
| 8023be452a | |||
| 5112c96c05 | |||
| 04b1fb72bb | |||
| a9d11ccb2e | |||
| 848e3f90e1 | |||
| e83b6741ad | |||
| 0a3d93b59b | |||
| 102e2141ec | |||
| 768fd6f439 | |||
| 231a610466 | |||
| 7db5ec0721 | |||
| a2b9d96e86 | |||
| e0022c12c4 | |||
| 66b8f2c906 | |||
| 6fbe10fe63 | |||
| 278871a31e | |||
| bdcef33a3e | |||
| 8e8cff8105 | |||
| 6b086eaed5 | |||
| 0ac06555cb | |||
| 1b87c62847 | |||
| 98eae61b90 | |||
| 19956f2742 | |||
| 7506eaa4aa | |||
| 57c9c9d5f2 | |||
| 0b3d2873c2 | |||
| 1454b58ea1 | |||
| 2ab4459bc3 | |||
| 6ebb92af6c | |||
| 3ab4927cb7 | |||
| 6a0ae482d7 | |||
| 20eeaeb61f | |||
| 5cbe4e2fa3 | |||
| b124e1bc9c | |||
| 08034c4402 | |||
| 0577a9ccb9 | |||
| 2f03554a60 | |||
| 28079197e7 | |||
| 0b46b89b62 | |||
| 05caf67bc4 | |||
| 6d3c400e4a | |||
| 9932288b04 | |||
| 56b13541bd | |||
| d568e20f02 | |||
| f03d59bc22 | |||
| 0833ca28c2 | |||
| 3ed5f21a48 | |||
| 2eb6b79bea | |||
| 8dd2e9716d | |||
| 1cdde6e9a4 | |||
| 034cfb539a |
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -403,7 +403,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -111,19 +111,21 @@ jobs:
|
||||
|
||||
ios:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: macos-15
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 60
|
||||
environment: store
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
||||
- name: Select Xcode 26
|
||||
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||
# runs letterboxed on the inner display of an iPhone Duo.
|
||||
- name: Select Xcode 27.1
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
XCODE=/Applications/Xcode_27.1.app
|
||||
test -d "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos27.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -90,18 +90,20 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
ios:
|
||||
runs-on: macos-15
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
||||
- name: Select Xcode 26
|
||||
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||
# runs letterboxed on the inner display of an iPhone Duo.
|
||||
- name: Select Xcode 27.1
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
XCODE=/Applications/Xcode_27.1.app
|
||||
test -d "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos27.1
|
||||
|
||||
- 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 client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
path: plugin-sdk/coverage/
|
||||
|
||||
- name: Scan
|
||||
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
|
||||
uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
|
||||
env:
|
||||
# SonarQube Cloud is the scan action's default host, so no
|
||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||
|
||||
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
(gbOpen) release, except for Ireland (below).
|
||||
|
||||
## Tailte Éireann — counties of Ireland
|
||||
|
||||
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
|
||||
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
|
||||
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
|
||||
layer for Ireland.
|
||||
|
||||
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source:
|
||||
https://data-osi.opendata.arcgis.com/
|
||||
|
||||
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
|
||||
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
|
||||
generalised boundaries for reference only; they may not match the statutory
|
||||
boundaries exactly.
|
||||
|
||||
## OpenStreetMap — geocoding, place details & descriptions
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Vacay**: leave calendar with half days, public holidays from [nagerholidays.com](https://nagerholidays.com), school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
@@ -276,6 +276,8 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
**TrueNAS SCALE:** Install TREK directly from the Community catalog. See the [TrueNAS SCALE installation guide](https://github.com/liketrek/TREK/wiki/Install-TrueNAS-SCALE).
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
@@ -348,7 +350,7 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
||||
## Updating
|
||||
|
||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
||||
Docker run, Helm, Portainer, Unraid, TrueNAS SCALE and Proxmox, plus the encryption-key note.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -437,8 +439,9 @@ Every variable, its default and what it does: see
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
|
||||
[**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
|
||||
[NOTICE.md](NOTICE.md) for full third-party attributions.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.2
|
||||
version: 5.1.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.2"
|
||||
appVersion: "5.1.4"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run build # prebuild generates PWA icons, then vite build; postbuild writes a Brotli copy beside each hashed asset for the server to hand out
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
||||
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.KeyboardShortcutGroup;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.liketrek.trek.screen.TrekKeyCommands;
|
||||
import com.liketrek.trek.tiles.TrekTileStore;
|
||||
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
|
||||
// runs and on its own origin. BridgeActivity reads `config` while it
|
||||
// builds the bridge inside super.onCreate().
|
||||
boolean restarting = TrekServerStore.consumeRestart(this);
|
||||
TrekServerStore.recheckAfterUpdate(this);
|
||||
String server = TrekServerStore.getUrl(this);
|
||||
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
|
||||
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
|
||||
@@ -36,7 +45,17 @@ public class MainActivity extends BridgeActivity {
|
||||
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
||||
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, TrekServerStore.getSignInHosts(this)));
|
||||
if (onServer) {
|
||||
bridge.setWebViewClient(
|
||||
new TrekWebViewClient(
|
||||
bridge,
|
||||
this::showAddressScreen,
|
||||
this::showSignInBar,
|
||||
this::showErrorPageBar,
|
||||
TrekServerStore.getSignInHosts(this)
|
||||
)
|
||||
);
|
||||
}
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
@@ -68,6 +87,122 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
private View signInBar;
|
||||
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
|
||||
private boolean onSignInPage;
|
||||
/** Whether the page is an error page in place of the server's (TrekWebViewClient.onReceivedHttpError). */
|
||||
private boolean onErrorPage;
|
||||
private boolean keyboardUp;
|
||||
private TextView signInHint;
|
||||
|
||||
/**
|
||||
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
|
||||
* not there, so nothing else would lead back to the address screen, and a
|
||||
* passkey cannot work in a web view for a domain the app is not tied to.
|
||||
* The bar says so and offers to change the server.
|
||||
*/
|
||||
private void showSignInBar(boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
onSignInPage = show;
|
||||
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||
updateSignInBar();
|
||||
});
|
||||
}
|
||||
|
||||
/** The same bar over an error page in place of the server's, with words for that. */
|
||||
private void showErrorPageBar(boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
onErrorPage = show;
|
||||
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||
updateSignInBar();
|
||||
});
|
||||
}
|
||||
|
||||
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
|
||||
private void updateSignInBar() {
|
||||
if (signInBar == null) return;
|
||||
if (signInHint != null) {
|
||||
signInHint.setText(onSignInPage ? TrekServerStore.signInHintLabel(this) : TrekServerStore.errorPageHintLabel(this));
|
||||
}
|
||||
signInBar.setVisibility((onSignInPage || onErrorPage) && !keyboardUp ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private View buildSignInBar() {
|
||||
float dp = getResources().getDisplayMetrics().density;
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setGravity(Gravity.CENTER_VERTICAL);
|
||||
bar.setPaddingRelative(Math.round(16 * dp), Math.round(10 * dp), Math.round(10 * dp), Math.round(10 * dp));
|
||||
// The look of the app's own first screens: a white card with a sand edge,
|
||||
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(0xF7FFFFFF);
|
||||
background.setCornerRadius(18 * dp);
|
||||
background.setStroke(Math.max(1, Math.round(dp)), 0xFFEFE4D8);
|
||||
bar.setBackground(background);
|
||||
bar.setElevation(8 * dp);
|
||||
|
||||
TextView hint = new TextView(this);
|
||||
signInHint = hint;
|
||||
hint.setText(TrekServerStore.signInHintLabel(this));
|
||||
hint.setTextColor(0xFF6B5F57);
|
||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
|
||||
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
|
||||
change.setText(TrekServerStore.changeServerLabel(this));
|
||||
change.setAllCaps(false);
|
||||
change.setTextColor(0xFFFFFFFF);
|
||||
GradientDrawable pill = new GradientDrawable();
|
||||
pill.setColor(0xFF111827);
|
||||
pill.setCornerRadius(12 * dp);
|
||||
change.setBackground(pill);
|
||||
change.setMinHeight(0);
|
||||
change.setMinimumHeight(0);
|
||||
change.setPaddingRelative(Math.round(14 * dp), Math.round(9 * dp), Math.round(14 * dp), Math.round(9 * dp));
|
||||
LinearLayout.LayoutParams changeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
changeParams.setMarginStart(Math.round(10 * dp));
|
||||
change.setOnClickListener((v) -> {
|
||||
TrekServerStore.setUrl(this, null);
|
||||
TrekServerStore.markRestart(this);
|
||||
recreate();
|
||||
});
|
||||
bar.addView(change, changeParams);
|
||||
|
||||
int margin = Math.round(12 * dp);
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
);
|
||||
params.setMargins(margin, 0, margin, margin);
|
||||
FrameLayout root = findViewById(android.R.id.content);
|
||||
root.addView(bar, params);
|
||||
// Above the gesture bar, and above the keyboard while it is up.
|
||||
bar.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
// Read off the window's own insets: Capacitor hands the views below the
|
||||
// decor a copy with the system bars zeroed, and before Android 11 that
|
||||
// takes the keyboard with it.
|
||||
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
|
||||
int bottom = window != null
|
||||
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
|
||||
: insets.getStableInsetBottom();
|
||||
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
|
||||
if (imeVisible != keyboardUp) {
|
||||
keyboardUp = imeVisible;
|
||||
updateSignInBar();
|
||||
}
|
||||
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
|
||||
if (p.bottomMargin != margin + bottom) {
|
||||
p.bottomMargin = margin + bottom;
|
||||
v.setLayoutParams(p);
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
bar.requestApplyInsets();
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server did not load: back to the address screen, which offers retry
|
||||
* and change. A fresh task rather than recreate(), because this usually
|
||||
|
||||
@@ -22,6 +22,19 @@ final class TrekServerStore {
|
||||
|
||||
private static final String PREFS = "trek_shell";
|
||||
private static final String KEY_URL = "server_url";
|
||||
private static final String KEY_PREVIOUS_URL = "previous_server_url";
|
||||
private static final String KEY_SERVER_CHECK = "server_check";
|
||||
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
|
||||
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
|
||||
private static final String KEY_LABEL_ERROR_PAGE_HINT = "label_error_page_hint";
|
||||
|
||||
/**
|
||||
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
|
||||
* server remembered under a lower value is checked once more: the first
|
||||
* launch after an update that raised it puts the address screen back up,
|
||||
* filled in with that server (getPreviousUrl). Raise both together.
|
||||
*/
|
||||
private static final int SERVER_CHECK = 3;
|
||||
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
|
||||
private static final String KEY_SHELL_ONCE = "shell_once";
|
||||
private static final String KEY_RESTART = "restart";
|
||||
@@ -56,6 +69,52 @@ final class TrekServerStore {
|
||||
return prefs(context).getString(KEY_URL, null);
|
||||
}
|
||||
|
||||
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
|
||||
static void recheckAfterUpdate(Context context) {
|
||||
SharedPreferences prefs = prefs(context);
|
||||
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
|
||||
String server = getUrl(context);
|
||||
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
|
||||
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
* as the address screen passed them (setServer's `labels`): the server's UI
|
||||
* is not there to say them. Null keeps the English fallback.
|
||||
*/
|
||||
static void setLabels(Context context, String signInHint, String changeServer, String errorPageHint) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
|
||||
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
|
||||
if (errorPageHint != null) editor.putString(KEY_LABEL_ERROR_PAGE_HINT, errorPageHint);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
static String errorPageHintLabel(Context context) {
|
||||
return prefs(context).getString(
|
||||
KEY_LABEL_ERROR_PAGE_HINT,
|
||||
"This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||
);
|
||||
}
|
||||
|
||||
static String signInHintLabel(Context context) {
|
||||
return prefs(context).getString(
|
||||
KEY_LABEL_SIGN_IN_HINT,
|
||||
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
);
|
||||
}
|
||||
|
||||
static String changeServerLabel(Context context) {
|
||||
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
|
||||
}
|
||||
|
||||
/** The server an update asked to check again, until a server is set. */
|
||||
static String getPreviousUrl(Context context) {
|
||||
return prefs(context).getString(KEY_PREVIOUS_URL, null);
|
||||
}
|
||||
|
||||
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
||||
static void setUrl(Context context, String url) {
|
||||
setServer(context, url, null);
|
||||
@@ -66,7 +125,7 @@ final class TrekServerStore {
|
||||
if (url == null) {
|
||||
editor.remove(KEY_URL);
|
||||
} else {
|
||||
editor.putString(KEY_URL, url);
|
||||
editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
|
||||
}
|
||||
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||
@@ -97,6 +156,24 @@ final class TrekServerStore {
|
||||
}
|
||||
|
||||
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||
private static final String KEY_FAILURE = "last_failure";
|
||||
|
||||
/**
|
||||
* Why the server's page did not load, in the WebView's own terms
|
||||
* ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows
|
||||
* it small under its message, so a report carries the cause.
|
||||
*/
|
||||
static void setFailure(Context context, String failure) {
|
||||
prefs(context).edit().putString(KEY_FAILURE, failure).commit();
|
||||
}
|
||||
|
||||
/** The failure behind this address screen, once. */
|
||||
static String takeFailure(Context context) {
|
||||
String failure = prefs(context).getString(KEY_FAILURE, null);
|
||||
if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit();
|
||||
return failure;
|
||||
}
|
||||
|
||||
static void showShellOnce(Context context) {
|
||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
@@ -70,6 +72,7 @@ public class TrekShellPlugin extends Plugin {
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
"clientCertificate",
|
||||
"filePreview",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
@@ -267,11 +270,53 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
|
||||
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
|
||||
* app on the device views the type; the UI then hands it to the share sheet.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void previewFile(PluginCall call) {
|
||||
String path = call.getString("path");
|
||||
Uri parsed = path == null ? null : Uri.parse(path);
|
||||
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
|
||||
java.io.File cache = getContext().getCacheDir();
|
||||
boolean inCache;
|
||||
try {
|
||||
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
|
||||
} catch (java.io.IOException e) {
|
||||
inCache = false;
|
||||
}
|
||||
if (!inCache || !file.isFile()) {
|
||||
call.reject("a file in the app's cache is required", "INVALID");
|
||||
return;
|
||||
}
|
||||
String type = call.getString("mimeType", "application/octet-stream");
|
||||
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
|
||||
Intent view = new Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(content, type)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getActivity().runOnUiThread(() -> {
|
||||
JSObject result = new JSObject();
|
||||
try {
|
||||
getActivity().startActivity(view);
|
||||
result.put("result", "shown");
|
||||
} catch (ActivityNotFoundException e) {
|
||||
result.put("result", "unsupported");
|
||||
}
|
||||
call.resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getServer(PluginCall call) {
|
||||
String url = TrekServerStore.getUrl(getContext());
|
||||
String previous = TrekServerStore.getPreviousUrl(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", url == null ? JSONObject.NULL : url);
|
||||
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
|
||||
String failure = TrekServerStore.takeFailure(getContext());
|
||||
result.put("failure", failure == null ? JSONObject.NULL : failure);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@@ -284,6 +329,15 @@ public class TrekShellPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
JSObject labels = call.getObject("labels");
|
||||
if (labels != null) {
|
||||
TrekServerStore.setLabels(
|
||||
getContext(),
|
||||
labels.getString("signInHint"),
|
||||
labels.getString("changeServer"),
|
||||
labels.getString("errorPageHint")
|
||||
);
|
||||
}
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
String welcome = call.getString("welcome");
|
||||
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
|
||||
@@ -382,7 +436,11 @@ public class TrekShellPlugin extends Plugin {
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
|
||||
if (call == null) {
|
||||
keepOrphanAuthReturn(intent);
|
||||
return;
|
||||
}
|
||||
if (data == null || !authScheme.equals(data.getScheme())) return;
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", data.toString());
|
||||
call.resolve(result);
|
||||
@@ -408,6 +466,48 @@ public class TrekShellPlugin extends Plugin {
|
||||
}, CANCEL_GRACE_MS);
|
||||
}
|
||||
|
||||
private static final String AUTH_PREFS = "trek_auth_return";
|
||||
/** As long as the server keeps the code it carries (NATIVE_HANDOFF_CODE_TTL_MS), with room to spare. */
|
||||
private static final long ORPHAN_AUTH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A return to the sign-in scheme with no call waiting for it: Android ended
|
||||
* the app while the browser was in front, and the browser's return started
|
||||
* it anew (the launch intent comes through here as well). Kept on disk, as
|
||||
* the process may be new too, until the sign-in page takes it. The return
|
||||
* is then taken off the activity's intent: Capacitor hands the launch intent
|
||||
* in again whenever the activity is created, and Android does as well when
|
||||
* it rebuilds the task from the recent apps.
|
||||
*/
|
||||
private void keepOrphanAuthReturn(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data == null || !"auth".equals(data.getHost())) return;
|
||||
if (!getContext().getString(R.string.custom_url_scheme).equals(data.getScheme())) return;
|
||||
if (getActivity() != null && getActivity().getIntent() != null && getActivity().getIntent().getData() != null
|
||||
&& data.equals(getActivity().getIntent().getData())) {
|
||||
getActivity().setIntent(new Intent(getActivity().getIntent()).setData(null));
|
||||
}
|
||||
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return;
|
||||
getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("url", data.toString())
|
||||
.putLong("at", System.currentTimeMillis())
|
||||
.commit();
|
||||
}
|
||||
|
||||
/** The kept return of a sign-in (keepOrphanAuthReturn), once and while fresh; null otherwise. */
|
||||
@PluginMethod
|
||||
public void takeAuthCallback(PluginCall call) {
|
||||
android.content.SharedPreferences prefs = getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE);
|
||||
String url = prefs.getString("url", null);
|
||||
long at = prefs.getLong("at", 0);
|
||||
prefs.edit().clear().commit();
|
||||
boolean fresh = url != null && System.currentTimeMillis() - at <= ORPHAN_AUTH_TTL_MS;
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", fresh ? url : JSONObject.NULL);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private PluginCall pendingAuthCall() {
|
||||
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
|
||||
}
|
||||
@@ -633,6 +733,12 @@ public class TrekShellPlugin extends Plugin {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
|
||||
}
|
||||
|
||||
/** Whether this Google account already holds a delivered cloud subscription. */
|
||||
@PluginMethod
|
||||
public void storeHeld(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.held(getContext(), call);
|
||||
}
|
||||
|
||||
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
|
||||
@PluginMethod
|
||||
public void storeUnfinished(PluginCall call) {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.webkit.ClientCertRequest;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -24,13 +27,31 @@ import java.util.Set;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
|
||||
private final Consumer<Boolean> onSignInPage;
|
||||
/** Told whether the page now shown is an error page in place of the server's, for the same bar. */
|
||||
private final Consumer<Boolean> onErrorPage;
|
||||
/** The main-frame address that last came back as a 4xx page nobody else handled. */
|
||||
private String errorPageUrl;
|
||||
private final Set<String> signInHosts;
|
||||
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
|
||||
private boolean serverShown;
|
||||
/** The page itself failed on the network once already; the next failure goes to the address screen. */
|
||||
private boolean retriedMainFrame;
|
||||
/** The page loaded again after a network error, until that load starts: its error page counts for nothing. */
|
||||
private String retryUrl;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
TrekWebViewClient(
|
||||
Bridge bridge,
|
||||
Runnable onUnreachable,
|
||||
Consumer<Boolean> onSignInPage,
|
||||
Consumer<Boolean> onErrorPage,
|
||||
Set<String> signInHosts
|
||||
) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.onSignInPage = onSignInPage;
|
||||
this.onErrorPage = onErrorPage;
|
||||
this.signInHosts = new HashSet<>(signInHosts);
|
||||
}
|
||||
|
||||
@@ -61,10 +82,26 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
return super.shouldOverrideUrlLoading(view, request);
|
||||
}
|
||||
|
||||
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
|
||||
@Override
|
||||
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload);
|
||||
if (retryUrl != null) return;
|
||||
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
|
||||
// The 4xx answer arrives before this commit or after it; either way the
|
||||
// flag is read once here, so the next page clears the bar again.
|
||||
boolean error = url != null && url.equals(errorPageUrl);
|
||||
errorPageUrl = null;
|
||||
onErrorPage.accept(error);
|
||||
}
|
||||
|
||||
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
// The error page of a load that is about to be retried: not the server's
|
||||
// first page, so no welcome page and no learning of sign-in hosts yet.
|
||||
if (retryUrl != null) return;
|
||||
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
|
||||
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
|
||||
if (welcome != null) view.loadUrl(welcome);
|
||||
@@ -106,13 +143,35 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
String server = TrekServerStore.getUrl(view.getContext());
|
||||
Uri serverUri = server == null ? null : Uri.parse(server);
|
||||
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, () -> {
|
||||
TrekServerStore.setFailure(view.getContext(), "client certificate requested");
|
||||
onUnreachable.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page that failed on the network is loaded once more before the app says
|
||||
* the server is unreachable. Started from the background, as when the
|
||||
* browser hands a sign-in back to an app Android had ended, a Samsung phone
|
||||
* may hold its network back for a moment, and the first load fails with the
|
||||
* server there all along. The error is logged, as a report has nothing else.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
Log.w("TrekShell", "Page failed: " + error.getErrorCode() + " " + error.getDescription());
|
||||
if (!retriedMainFrame && isNetworkError(error.getErrorCode())) {
|
||||
retriedMainFrame = true;
|
||||
retryUrl = request.getUrl().toString();
|
||||
view.postDelayed(() -> {
|
||||
String url = retryUrl;
|
||||
retryUrl = null;
|
||||
view.loadUrl(url);
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")");
|
||||
onUnreachable.run();
|
||||
return;
|
||||
}
|
||||
@@ -121,10 +180,12 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
/**
|
||||
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
|
||||
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
|
||||
* and TREK serves its UI for every other path. The exception is a proxy
|
||||
* that asked this page for a client certificate and got none, as Cloudflare
|
||||
* does with a 403: the address screen then says so and offers to add one.
|
||||
* 503 while TREK restarts). A 4xx page is not TREK's, which serves its UI for
|
||||
* every path: a proxy that asked this page for a client certificate and got
|
||||
* none, as Cloudflare does with a 403, sends the app to the address screen,
|
||||
* which says so and offers to add one. Any other 4xx page (a proxy's block
|
||||
* page, a certificate it refused) stays up with the bar and its way out, so
|
||||
* the app is never stuck on a page without TREK's UI.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
@@ -132,10 +193,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
if (!request.isForMainFrame()) {
|
||||
super.onReceivedHttpError(view, request, errorResponse);
|
||||
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||
TrekServerStore.setFailure(view.getContext(), "HTTP " + status);
|
||||
onUnreachable.run();
|
||||
} else if (status >= 400) {
|
||||
errorPageUrl = request.getUrl().toString();
|
||||
onErrorPage.accept(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNetworkError(int code) {
|
||||
return code == WebViewClient.ERROR_HOST_LOOKUP
|
||||
|| code == WebViewClient.ERROR_CONNECT
|
||||
|| code == WebViewClient.ERROR_TIMEOUT
|
||||
|| code == WebViewClient.ERROR_IO
|
||||
|| code == WebViewClient.ERROR_UNKNOWN;
|
||||
}
|
||||
|
||||
private static boolean wantedCertificate(Uri url) {
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
|
||||
|
||||
@@ -119,23 +119,38 @@ object TrekBilling {
|
||||
val id = call.getString("productId") ?: return call.reject("productId is required")
|
||||
ready(activity) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
|
||||
details(activity, listOf(id)) { found ->
|
||||
val product = found.firstOrNull()
|
||||
val offer = product?.let(::baseOffer)
|
||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
||||
pendingPurchase = call
|
||||
pendingProduct = id
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
||||
)
|
||||
.build()
|
||||
activity.runOnUiThread {
|
||||
val launch = client(activity).launchBillingFlow(activity, params)
|
||||
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
|
||||
}
|
||||
// One subscription per Google account: a second plan would run beside the first.
|
||||
// A delivered one stops the purchase here; one that was paid but never set up is
|
||||
// handed back as bought, so its instance is set up now.
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(activity).queryPurchasesAsync(params) { result, purchases ->
|
||||
val held = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
|
||||
when {
|
||||
held == null -> launchPurchase(activity, call, id)
|
||||
held.isAcknowledged -> call.resolve(JSObject().put("kind", "subscribed"))
|
||||
else -> call.resolve(receipt(held).put("kind", "bought"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchPurchase(activity: Activity, call: PluginCall, id: String) {
|
||||
details(activity, listOf(id)) { found ->
|
||||
val product = found.firstOrNull()
|
||||
val offer = product?.let(::baseOffer)
|
||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
||||
pendingPurchase = call
|
||||
pendingProduct = id
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
||||
)
|
||||
.build()
|
||||
activity.runOnUiThread {
|
||||
val launch = client(activity).launchBillingFlow(activity, params)
|
||||
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +223,19 @@ object TrekBilling {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this Google account holds a delivered subscription under `prefix`; while one runs, the shell offers no purchase. */
|
||||
fun held(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: PREFIX
|
||||
ready(context) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject().put("held", false))
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(context).queryPurchasesAsync(params) { result, purchases ->
|
||||
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
|
||||
call.resolve(JSObject().put("held", found?.isAcknowledged == true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest active subscription under `prefix` on this Google account, if any. */
|
||||
fun restore(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: "cloud."
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// The chunk group that holds the lucide icons the app loads at startup (vite.config.js).
|
||||
//
|
||||
// Every lucide icon is a module of its own, and the plugin icon lookup
|
||||
// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation
|
||||
// every page renders. Left to itself the bundler puts each icon that some other chunk
|
||||
// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few
|
||||
// hundred bytes each, every one a request of its own on a first visit and an entry of its
|
||||
// own in the service worker's precache. In one group they are one file, whose hash changes
|
||||
// only when lucide-react does.
|
||||
//
|
||||
// `tags: ['$initial']` for the reason the other groups carry it: without it the group would
|
||||
// also take icons that today sit behind a lazy route and make them part of the first load.
|
||||
export const ICON_CHUNK = {
|
||||
name: 'vendor-icons',
|
||||
priority: 20,
|
||||
tags: ['$initial'],
|
||||
test: /[\\/]node_modules[\\/]lucide-react[\\/]/,
|
||||
};
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<string>TREK</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -10,6 +10,7 @@ import WebKit
|
||||
class TrekBridgeViewController: CAPBridgeViewController {
|
||||
override func instanceDescriptor() -> InstanceDescriptor {
|
||||
let descriptor = super.instanceDescriptor()
|
||||
TrekServerStore.recheckAfterUpdate()
|
||||
if let server = TrekServerStore.url {
|
||||
// The trailing slash matters: Capacitor treats every URL that starts
|
||||
// with this string as the app's own, and without it that would take
|
||||
@@ -40,10 +41,13 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
|
||||
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
guardBridgeMessages()
|
||||
watchSignInPages()
|
||||
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
|
||||
@@ -53,6 +57,257 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
// A long press on a link would lift a preview of the page it points to,
|
||||
// which no native list does; the press means "drag" or nothing here.
|
||||
webView?.allowsLinkPreview = false
|
||||
paintBehindWebView()
|
||||
}
|
||||
|
||||
// MARK: - What shows where the web view does not reach
|
||||
|
||||
/// The UI reports its theme through StatusBar.setStyle (native/nativeApp.ts),
|
||||
/// which lands here; the colour behind the web view follows it.
|
||||
override func setNeedsStatusBarAppearanceUpdate() {
|
||||
super.setNeedsStatusBarAppearanceUpdate()
|
||||
paintBehindWebView()
|
||||
}
|
||||
|
||||
/// Some iOS releases (27.0.x) lay the web view out below the status bar
|
||||
/// instead of under it, and the strip above the page then showed this
|
||||
/// view's black. It now carries the top colour of the screen background
|
||||
/// (mobile.css --m-scr), so the strip reads as part of the page on every
|
||||
/// release, and the rubber band at the ends shows the same colour.
|
||||
private func paintBehindWebView() {
|
||||
let dark: Bool
|
||||
switch preferredStatusBarStyle {
|
||||
case .lightContent: dark = true
|
||||
case .darkContent: dark = false
|
||||
default: dark = traitCollection.userInterfaceStyle == .dark
|
||||
}
|
||||
let color = dark
|
||||
? UIColor(red: 0x17 / 255, green: 0x17 / 255, blue: 0x1C / 255, alpha: 1)
|
||||
: UIColor(red: 0xFB / 255, green: 0xFA / 255, blue: 0xF7 / 255, alpha: 1)
|
||||
view.backgroundColor = color
|
||||
webView?.scrollView.backgroundColor = color
|
||||
webView?.underPageBackgroundColor = color
|
||||
}
|
||||
|
||||
// MARK: - The safe area the page keeps clear of
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
private var publishedSafeArea: UIEdgeInsets?
|
||||
|
||||
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
|
||||
/// web view overlaps it. Where a release lays the web view out below the
|
||||
/// status bar it reports 0, yet a camera in a corner can still reach into
|
||||
/// the page. The part of the window's safe area (and of the status bar) that
|
||||
/// falls inside the web view goes to the page as --trek-native-safe-*, which
|
||||
/// mobile.css takes as a floor under env(), never in its place.
|
||||
func publishSafeArea(force: Bool = false) {
|
||||
guard let webView, let window = view.window else { return }
|
||||
let frame = webView.convert(webView.bounds, to: window)
|
||||
let safe = window.safeAreaInsets
|
||||
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
let insets = UIEdgeInsets(
|
||||
top: max(0, max(safe.top, statusBar) - frame.minY),
|
||||
left: max(0, safe.left - frame.minX),
|
||||
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
|
||||
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
|
||||
)
|
||||
if !force, insets == publishedSafeArea { return }
|
||||
publishedSafeArea = insets
|
||||
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
|
||||
let js = "(function(s){"
|
||||
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
|
||||
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
|
||||
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
|
||||
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
|
||||
+ "})(document.documentElement.style)"
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - A proxy's sign-in page
|
||||
|
||||
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
|
||||
/// not there, so nothing else leads back to the address screen, and a passkey
|
||||
/// cannot work in a web view for a domain the app is not tied to. A bar says
|
||||
/// so and offers to change the server; the keyboard gets room on that page,
|
||||
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
|
||||
private var signInBar: UIView?
|
||||
private var urlObservation: NSKeyValueObservation?
|
||||
private var insetObservation: NSKeyValueObservation?
|
||||
private var onSignInPage = false
|
||||
/// Whether the main frame shows an error page in place of the server's (a 4xx
|
||||
/// nobody else handled: a proxy's block page, a client certificate it refused).
|
||||
private var onErrorPage = false
|
||||
private var signInHint: UILabel?
|
||||
private var keyboardUp = false
|
||||
/// How far the keyboard reaches into the view while it is up.
|
||||
private var keyboardRoom: CGFloat = 0
|
||||
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
|
||||
private var ownsInset = false
|
||||
private var bodyReset: DispatchWorkItem?
|
||||
|
||||
private func watchSignInPages() {
|
||||
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
|
||||
let signIn = TrekServerStore.isSignInHost(webView.url)
|
||||
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
|
||||
}
|
||||
// The keyboard plugin zeroes the scroll inset on every frame change it
|
||||
// makes, the last one a while after the keyboard is up. On a sign-in
|
||||
// page the inset wanted here is put back each time.
|
||||
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { self?.applyInset() }
|
||||
}
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
private func signInPageChanged(_ signIn: Bool) {
|
||||
onSignInPage = signIn
|
||||
if signIn && signInBar == nil { signInBar = makeSignInBar() }
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
private func errorPageChanged(_ error: Bool) {
|
||||
onErrorPage = error
|
||||
if error && signInBar == nil { signInBar = makeSignInBar() }
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
/// Out of the way while the keyboard is up: the form needs the room.
|
||||
private func updateSignInBar() {
|
||||
let labels = TrekServerStore.labels
|
||||
signInHint?.text = onSignInPage
|
||||
? labels["signInHint"]
|
||||
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
: labels["errorPageHint"] ?? "This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||
signInBar?.isHidden = !((onSignInPage || onErrorPage) && !keyboardUp)
|
||||
applyInset()
|
||||
}
|
||||
|
||||
/// On a sign-in page the page can scroll past what covers its end: the
|
||||
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
|
||||
private func applyInset() {
|
||||
guard let scrollView = webView?.scrollView else { return }
|
||||
let wanted: CGFloat
|
||||
if onSignInPage || onErrorPage {
|
||||
if keyboardUp {
|
||||
wanted = keyboardRoom
|
||||
} else if let bar = signInBar {
|
||||
view.layoutIfNeeded()
|
||||
wanted = max(0, view.bounds.height - bar.frame.minY)
|
||||
} else {
|
||||
wanted = 0
|
||||
}
|
||||
ownsInset = true
|
||||
} else if ownsInset {
|
||||
wanted = 0
|
||||
ownsInset = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
|
||||
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
|
||||
}
|
||||
|
||||
/// The keyboard plugin also sizes the body to the area above the keyboard,
|
||||
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
|
||||
/// body gets its height back once that has run, so the button under the
|
||||
/// keyboard can be scrolled into view through the inset above.
|
||||
@objc private func keyboardWillShow(_ note: Notification) {
|
||||
keyboardUp = true
|
||||
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
|
||||
}
|
||||
bodyReset?.cancel()
|
||||
if onSignInPage {
|
||||
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
|
||||
let reset = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
|
||||
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
|
||||
self.applyInset()
|
||||
}
|
||||
bodyReset = reset
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
|
||||
}
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
@objc private func keyboardWillHide() {
|
||||
keyboardUp = false
|
||||
bodyReset?.cancel()
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
private func makeSignInBar() -> UIView {
|
||||
let labels = TrekServerStore.labels
|
||||
// The look of the app's own first screens: a white card with a sand edge,
|
||||
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||
let bar = UIView()
|
||||
bar.backgroundColor = UIColor(white: 1, alpha: 0.97)
|
||||
bar.layer.cornerRadius = 18
|
||||
bar.layer.cornerCurve = .continuous
|
||||
bar.layer.borderWidth = 1
|
||||
bar.layer.borderColor = UIColor(red: 0xEF / 255, green: 0xE4 / 255, blue: 0xD8 / 255, alpha: 1).cgColor
|
||||
bar.layer.shadowColor = UIColor(red: 0x1F / 255, green: 0x1A / 255, blue: 0x17 / 255, alpha: 1).cgColor
|
||||
bar.layer.shadowOpacity = 0.18
|
||||
bar.layer.shadowRadius = 18
|
||||
bar.layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||
bar.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let hint = UILabel()
|
||||
hint.text = labels["signInHint"]
|
||||
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
hint.textColor = UIColor(red: 0x6B / 255, green: 0x5F / 255, blue: 0x57 / 255, alpha: 1)
|
||||
hint.font = .preferredFont(forTextStyle: .footnote)
|
||||
hint.adjustsFontForContentSizeCategory = true
|
||||
hint.numberOfLines = 0
|
||||
signInHint = hint
|
||||
hint.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
var look = UIButton.Configuration.filled()
|
||||
look.baseBackgroundColor = UIColor(red: 0x11 / 255, green: 0x18 / 255, blue: 0x27 / 255, alpha: 1)
|
||||
look.baseForegroundColor = .white
|
||||
look.cornerStyle = .fixed
|
||||
look.background.cornerRadius = 12
|
||||
look.contentInsets = NSDirectionalEdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)
|
||||
let change = UIButton(configuration: look)
|
||||
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
|
||||
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
|
||||
change.translatesAutoresizingMaskIntoConstraints = false
|
||||
change.setContentHuggingPriority(.required, for: .horizontal)
|
||||
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
bar.addSubview(hint)
|
||||
bar.addSubview(change)
|
||||
view.addSubview(bar)
|
||||
NSLayoutConstraint.activate([
|
||||
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
|
||||
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
|
||||
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
|
||||
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
|
||||
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
|
||||
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
|
||||
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
|
||||
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
||||
])
|
||||
return bar
|
||||
}
|
||||
|
||||
/// What resetServer does from the UI: forget the server, back to the address screen.
|
||||
@objc private func changeServerFromSignIn() {
|
||||
TrekServerStore.url = nil
|
||||
view.window?.rootViewController = TrekBridgeViewController()
|
||||
}
|
||||
|
||||
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
||||
@@ -157,13 +412,70 @@ enum TrekKeyCommands {
|
||||
enum TrekServerStore {
|
||||
private static let key = "TrekServerURL"
|
||||
|
||||
private static let failureKey = "TrekLastFailure"
|
||||
|
||||
/// Why the server's page did not load ("NSURLErrorDomain -1003"): the
|
||||
/// address screen shows it small under its message, so a report carries
|
||||
/// the cause. Taken once.
|
||||
static func noteFailure(_ error: Error) {
|
||||
let ns = error as NSError
|
||||
UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey)
|
||||
}
|
||||
|
||||
static func takeFailure() -> String? {
|
||||
let failure = UserDefaults.standard.string(forKey: failureKey)
|
||||
UserDefaults.standard.removeObject(forKey: failureKey)
|
||||
return failure
|
||||
}
|
||||
|
||||
private static let previousKey = "TrekPreviousServerURL"
|
||||
|
||||
private static let checkedKey = "TrekServerCheck"
|
||||
|
||||
private static let labelsKey = "TrekShellLabels"
|
||||
|
||||
/// The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
/// as the address screen passed them (setServer's `labels`): the server's UI
|
||||
/// is not there to say them. Empty keeps the English fallback.
|
||||
static var labels: [String: String] {
|
||||
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
|
||||
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
|
||||
}
|
||||
|
||||
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
|
||||
/// A server remembered under a lower value is checked once more: the first
|
||||
/// launch after an update that raised it puts the address screen back up,
|
||||
/// filled in with that server (previousURL). Raise both together.
|
||||
private static let serverCheck = 3
|
||||
|
||||
/// Runs before the first bridge is built, so a server picked under an older
|
||||
/// minimum is not loaded unchecked.
|
||||
static func recheckAfterUpdate() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
|
||||
defaults.set(serverCheck, forKey: checkedKey)
|
||||
guard let server = url else { return }
|
||||
url = nil
|
||||
defaults.set(server, forKey: previousKey)
|
||||
}
|
||||
|
||||
/// The server an update asked to check again, until a server is set.
|
||||
static var previousURL: String? {
|
||||
UserDefaults.standard.string(forKey: previousKey)
|
||||
}
|
||||
|
||||
private static let signInHostsKey = "TrekServerSignInHosts"
|
||||
|
||||
/// Setting the server drops the sign-in hosts of the one before it.
|
||||
static var url: String? {
|
||||
get { UserDefaults.standard.string(forKey: key) }
|
||||
set {
|
||||
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
if let newValue {
|
||||
UserDefaults.standard.set(newValue, forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: previousKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||
}
|
||||
@@ -473,6 +785,10 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
|
||||
private var serverShown = false
|
||||
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
|
||||
var onErrorPage: ((Bool) -> Void)?
|
||||
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
|
||||
var onPageShown: (() -> Void)?
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -525,7 +841,12 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
inner.webView?(webView, didFinish: navigation)
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true }
|
||||
onPageShown?()
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) {
|
||||
serverShown = true
|
||||
// The server's page is up: an earlier failure is no cause any more.
|
||||
_ = TrekServerStore.takeFailure()
|
||||
}
|
||||
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||
webView.load(URLRequest(url: welcome))
|
||||
}
|
||||
@@ -541,6 +862,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
|
||||
guard let self, let webView else { return }
|
||||
// Capacitor's own answer to a failed load: the address screen (errorPath).
|
||||
TrekServerStore.noteFailure(URLError(.clientCertificateRejected))
|
||||
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
|
||||
}
|
||||
return
|
||||
@@ -556,16 +878,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
/// A proxy that asked the page for a client certificate, got none and
|
||||
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
|
||||
/// app to the address screen, which says so and offers to add one, rather
|
||||
/// than leaving the proxy's error page up.
|
||||
/// than leaving the proxy's error page up. Any other 4xx page is not TREK's,
|
||||
/// which serves its UI for every path (a proxy's block page, a certificate it
|
||||
/// refused): it stays up with the bar and its way out to another server.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
|
||||
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
|
||||
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
|
||||
decisionHandler(.cancel)
|
||||
TrekServerStore.noteFailure(URLError(.clientCertificateRequired))
|
||||
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
|
||||
return
|
||||
}
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse {
|
||||
let error = http.statusCode >= 400 && http.statusCode < 500
|
||||
DispatchQueue.main.async { [weak self] in self?.onErrorPage?(error) }
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
@@ -576,11 +905,13 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
TrekServerStore.noteFailure(error)
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
TrekServerStore.noteFailure(error)
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import PassKit
|
||||
import QuickLook
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -45,6 +46,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeHeld", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
@@ -55,6 +57,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -66,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||
static var features: [String] {
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
@@ -346,7 +349,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc func getServer(_ call: CAPPluginCall) {
|
||||
call.resolve(["url": TrekServerStore.url ?? NSNull()])
|
||||
call.resolve([
|
||||
"url": TrekServerStore.url ?? NSNull(),
|
||||
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
|
||||
"failure": TrekServerStore.takeFailure() ?? NSNull(),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func setServer(_ call: CAPPluginCall) {
|
||||
@@ -355,6 +362,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
return
|
||||
}
|
||||
TrekServerStore.url = raw
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
if !labels.isEmpty { TrekServerStore.labels = labels }
|
||||
// After the URL: setting it clears the hosts of the server before.
|
||||
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
||||
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
||||
@@ -555,6 +564,41 @@ extension TrekShellPlugin {
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Quick Look
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
|
||||
/// One preview at a time, so a static: an extension cannot store per instance.
|
||||
private static var previewSource: TrekFilePreviewSource?
|
||||
|
||||
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
|
||||
/// an image, a document, with the system's own share button. "unsupported" for
|
||||
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
|
||||
@objc func previewFile(_ call: CAPPluginCall) {
|
||||
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
call.reject("a file in the app's cache is required", "INVALID")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let presenter = self.bridge?.viewController else {
|
||||
call.reject("nothing to show the preview from", "UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
|
||||
call.resolve(["result": "unsupported"])
|
||||
return
|
||||
}
|
||||
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
|
||||
TrekShellPlugin.previewSource = source
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = source
|
||||
presenter.present(preview, animated: true)
|
||||
call.resolve(["result": "shown"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
extension TrekShellPlugin {
|
||||
@@ -637,6 +681,17 @@ extension TrekShellPlugin {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
// One subscription per Apple ID: another plan would change it, not add one.
|
||||
// A held one that was delivered stops the purchase here. One that was paid
|
||||
// but never set up is handed back as bought, so its instance is set up now.
|
||||
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
||||
if await TrekStore.awaitingSetup(held) {
|
||||
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
||||
} else {
|
||||
call.resolve(["kind": "subscribed"])
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
guard let product = try await Product.products(for: [id]).first else {
|
||||
call.resolve(["kind": "failed"])
|
||||
@@ -693,6 +748,21 @@ extension TrekShellPlugin {
|
||||
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: whether this Apple ID holds a delivered subscription under
|
||||
/// `prefix`. Read from the entitlements on the device, so no password is asked;
|
||||
/// while one runs, the shell offers no purchase.
|
||||
@objc func storeHeld(_ call: CAPPluginCall) {
|
||||
let prefix = call.getString("prefix") ?? "cloud."
|
||||
Task {
|
||||
guard let held = await TrekStore.currentEntitlement(prefix: prefix) else {
|
||||
call.resolve(["held": false])
|
||||
return
|
||||
}
|
||||
let awaiting = await TrekStore.awaitingSetup(held)
|
||||
call.resolve(["held": !awaiting])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TrekStore {
|
||||
@@ -723,17 +793,62 @@ enum TrekStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64)?
|
||||
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
|
||||
|
||||
/// The subscription this Apple ID holds under `prefix`, newest first. The
|
||||
/// entitlements on the device come first; on a fresh install they can still
|
||||
/// be empty while the subscription runs, so the status of the subscription
|
||||
/// group, which StoreKit asks the App Store for, decides then.
|
||||
static func currentEntitlement(prefix: String) async -> Held? {
|
||||
var newest: (date: Date, held: Held)?
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productID.hasPrefix(prefix),
|
||||
transaction.revocationDate == nil else { continue }
|
||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
|
||||
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
|
||||
}
|
||||
}
|
||||
return newest.map { ($0.jws, $0.id) }
|
||||
if let newest { return newest.held }
|
||||
return await subscriptionGroupHeld(prefix: prefix)
|
||||
}
|
||||
|
||||
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
|
||||
/// productIdFor in iap.ts); one of them is enough to find their group.
|
||||
private static let plans = ["start", "basis", "plus", "pro"]
|
||||
|
||||
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
|
||||
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
|
||||
guard let products = try? await Product.products(for: ids),
|
||||
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
|
||||
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
|
||||
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
|
||||
for status in statuses where running.contains(status.state) {
|
||||
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
|
||||
return held(status.transaction.jwsRepresentation, transaction)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
|
||||
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
|
||||
}
|
||||
|
||||
/// A first purchase that was paid but whose instance was never set up: handed
|
||||
/// back as bought so it is set up now. A renewal the device has not finished
|
||||
/// yet is no such purchase; its subscription is running and counts as held.
|
||||
static func awaitingSetup(_ held: Held) async -> Bool {
|
||||
guard held.firstPurchase else { return false }
|
||||
return await isUnfinished(held.id)
|
||||
}
|
||||
|
||||
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
||||
static func isUnfinished(_ id: UInt64) async -> Bool {
|
||||
for await result in Transaction.unfinished {
|
||||
if case .verified(let transaction) = result, transaction.id == id { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,3 +1072,26 @@ enum TrekSharedKeychain {
|
||||
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/// The one file Quick Look shows, under the name the user knows it by.
|
||||
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
|
||||
private let item: Item
|
||||
|
||||
init(url: URL, title: String?) {
|
||||
item = Item(url: url, title: title)
|
||||
}
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
|
||||
|
||||
final class Item: NSObject, QLPreviewItem {
|
||||
let previewItemURL: URL?
|
||||
let previewItemTitle: String?
|
||||
|
||||
init(url: URL, title: String?) {
|
||||
previewItemURL = url
|
||||
previewItemTitle = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The amount inside a price as the store writes it ("4,90 €", "$4.99",
|
||||
* "CHF 12.90", "1.290 ₽"), and a way to write another amount the same way: the
|
||||
* same currency, the same decimal mark, the same digit grouping. The plan picker
|
||||
* counts a price up to its new value with it, and turns a yearly price into what
|
||||
* it makes a month, without knowing the store's locale.
|
||||
*/
|
||||
interface Amount {
|
||||
/** The amount as a number, in the price's own unit (4.9 for "4,90 €"). */
|
||||
value: number;
|
||||
/** The price written for another amount, in the same format. */
|
||||
render(value: number): string;
|
||||
}
|
||||
|
||||
// Digits with the marks stores put between them: . , any space (\s takes the
|
||||
// no-break ones too) and the apostrophes Swiss formats group thousands with.
|
||||
const NUMBER = /\d[\d.,\s'’]*\d|\d/;
|
||||
|
||||
export function amountIn(label: string): Amount | null {
|
||||
const match = NUMBER.exec(label);
|
||||
if (!match) return null;
|
||||
const raw = match[0];
|
||||
// The decimal mark is the last . or , followed by one or two digits to the end.
|
||||
const decimal = /([.,])(\d{1,2})$/.exec(raw);
|
||||
const decimals = decimal ? decimal[2].length : 0;
|
||||
const mark = decimal ? decimal[1] : '';
|
||||
const whole = decimal ? raw.slice(0, raw.length - decimal[0].length) : raw;
|
||||
const group = /[.,\s'’]/.exec(whole)?.[0] ?? '';
|
||||
const digits = whole.replace(/\D/g, '') + (decimal ? `.${decimal[2]}` : '');
|
||||
const value = Number(digits);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const before = label.slice(0, match.index);
|
||||
const after = label.slice(match.index + raw.length);
|
||||
return {
|
||||
value,
|
||||
render(next: number) {
|
||||
const fixed = Math.max(0, next).toFixed(decimals);
|
||||
const [int, frac] = fixed.split('.');
|
||||
const grouped = group ? int.replace(/\B(?=(\d{3})+(?!\d))/g, group) : int;
|
||||
return `${before}${grouped}${frac !== undefined ? mark + frac : ''}${after}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#fffaf5" />
|
||||
<title>TREK</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1016
-542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* The landscape behind the first screens of the app: one world seen from above,
|
||||
* an island out at sea, the coast with its town, the farmland behind it and the
|
||||
* mountains at the back. A fragment shader draws it every frame (water, surf,
|
||||
* boats, a car on the road), so it is never a photo and never the same twice.
|
||||
*
|
||||
* The plan picker moves the camera through that world, one place per plan, so
|
||||
* choosing reads as travelling further inland. The scenes are framed for a phone
|
||||
* 390 px wide: a wider or taller box (iPad, an unfolded phone) shows more of the
|
||||
* world around the same centre.
|
||||
*
|
||||
* Kept light for the phone it runs on: the device pixel ratio is capped at 2,
|
||||
* the picture drops to a lower resolution when frames come slow, the loop stops
|
||||
* while the app is in the background or the canvas has left the page, and with
|
||||
* reduced motion it is one still frame. Without WebGL the box keeps its plain
|
||||
* sea colour from the stylesheet.
|
||||
*/
|
||||
|
||||
/** Where the camera stands for a place, in world pixels, for a 390 px frame. */
|
||||
export type Camera = readonly [number, number];
|
||||
export const WELCOME_CAMERA: Camera = [0, 0];
|
||||
/** One place per plan, in the order the plans come: island, coast, farmland, mountains. */
|
||||
const PLAN_CAMERAS: readonly Camera[] = [
|
||||
[62, -392],
|
||||
[0, 24],
|
||||
[60, 290],
|
||||
[70, 660],
|
||||
];
|
||||
export function cameraForPlan(index: number): Camera {
|
||||
return PLAN_CAMERAS[Math.max(0, Math.min(PLAN_CAMERAS.length - 1, index))];
|
||||
}
|
||||
|
||||
const FRAME_WIDTH = 390;
|
||||
|
||||
const FRAGMENT = `
|
||||
precision highp float;
|
||||
uniform vec2 uRes; uniform float uDpr; uniform float uT; uniform vec2 uCam; uniform float uHaze;
|
||||
float coast(float x) { return 128.0 + 12.0 * sin(x * 0.018 + 0.6) + 7.0 * sin(x * 0.051 + 2.1) + 4.0 * sin(x * 0.11); }
|
||||
float riverX(float y) { return 168.0 - (y - 120.0) * 0.30 + 9.0 * sin(y * 0.035 + 0.8) + 4.0 * sin(y * 0.09); }
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
vec2 hash2(vec2 p) { return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453); }
|
||||
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); }
|
||||
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
|
||||
float fbm3(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 3; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
|
||||
float wave(vec2 p, float t) {
|
||||
return 0.55 * sin(dot(p, vec2(0.040, 0.085)) - t * 1.10) + 0.35 * sin(dot(p, vec2(-0.070, 0.050)) - t * 1.45)
|
||||
+ 0.22 * sin(dot(p, vec2(0.110, 0.130)) - t * 2.10) + 0.12 * sin(dot(p, vec2(-0.170, 0.190)) - t * 2.90)
|
||||
+ 0.90 * fbm(p * 0.055 + vec2(t * 0.18, t * 0.32));
|
||||
}
|
||||
float ridged(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { float n = 1.0 - abs(noise(p) * 2.0 - 1.0); v += a * n * n; p = p * 2.07 + vec2(3.1, 1.7); a *= 0.5; } return v; }
|
||||
/* Height: gentle near the shore, sharp ridges further inland. */
|
||||
float peaks(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 7; i++) { v += a * (1.0 - abs(noise(p) * 2.0 - 1.0)); p = p * 2.13 + vec2(1.3, 4.7); a *= 0.5; } return v; }
|
||||
float ground(vec2 p) {
|
||||
float inland = smoothstep(10.0, 170.0, p.y - coast(p.x));
|
||||
return fbm(p * 0.011) * 0.4 + ridged(p * 0.014 + 5.0) * 1.15 * inland + fbm3(p * 0.06) * 0.08;
|
||||
}
|
||||
/* The island out at sea: signed distance to its shore in px (> 0 on land),
|
||||
a round main body with an uneven coast and a headland to the east. */
|
||||
float islD(vec2 p) {
|
||||
vec2 q = (p - vec2(230.0, -250.0)) / vec2(1.5, 1.0);
|
||||
float r = length(q);
|
||||
float rad = 66.0 + 46.0 * (fbm(q * 0.016 + 4.0) - 0.5) + 30.0 * (fbm(q * 0.045 + 8.0) - 0.5) + 10.0 * (fbm(p * 0.09) - 0.5);
|
||||
vec2 q2 = (p - vec2(372.0, -222.0)) / vec2(1.6, 1.0);
|
||||
float head = 15.0 + 10.0 * (fbm(p * 0.04 + 9.0) - 0.5) - length(q2);
|
||||
return max(rad - r, head);
|
||||
}
|
||||
float islH(vec2 p) {
|
||||
float s = islD(p);
|
||||
return smoothstep(0.0, 60.0, s) * (0.5 + 0.7 * fbm(p * 0.018 + 2.0)) + ridged(p * 0.03) * 0.22 * smoothstep(8.0, 50.0, s);
|
||||
}
|
||||
/* Trees: one per cell at most, a round crown of varying size. Returns the
|
||||
crown's cover (0..1) and how far from its centre, for the shading. */
|
||||
vec3 tree(vec2 p, float cell) {
|
||||
vec2 c = floor(p / cell);
|
||||
float best = 9.0; vec2 off = vec2(0.0); float sz = 0.0;
|
||||
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
|
||||
vec2 g = c + vec2(float(i), float(j));
|
||||
vec2 h = hash2(g);
|
||||
vec2 o = (g + 0.15 + 0.7 * h) * cell;
|
||||
float r = cell * (0.38 + 0.22 * hash(g + 3.1));
|
||||
float d = length(p - o) / r;
|
||||
if (d < best) { best = d; off = (p - o) / r; sz = r; }
|
||||
}
|
||||
return vec3(1.0 - smoothstep(0.86, 1.0, best), off);
|
||||
}
|
||||
/* A Mediterranean town: one house per cell, a rectangle turned the way
|
||||
the lane runs there (the direction drifts across town, so lanes curve),
|
||||
sized at random and packed close. Whitewashed cubes with a parapet and
|
||||
now and then a water tank, pastel walls, terracotta roofs pitched along
|
||||
the long side, and the odd blue dome. */
|
||||
float town(vec2 p, out vec3 roof, out vec2 hid) {
|
||||
float cell = 9.0;
|
||||
vec2 c = floor(p / cell);
|
||||
roof = vec3(0.0);
|
||||
hid = c;
|
||||
float got = 0.0;
|
||||
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
|
||||
vec2 g = c + vec2(float(i), float(j));
|
||||
if (hash(g + 2.7) < 0.07) continue;
|
||||
vec2 o = (g + 0.5 + 0.35 * (hash2(g) - 0.5)) * cell;
|
||||
float ang = (noise(o * 0.018) - 0.5) * 2.4 + (hash(g + 7.7) - 0.5) * 0.25;
|
||||
vec2 cs = vec2(cos(ang), sin(ang));
|
||||
vec2 rel = p - o;
|
||||
vec2 l = vec2(dot(rel, cs), dot(rel, vec2(-cs.y, cs.x)));
|
||||
vec2 hs = cell * vec2(0.36 + 0.16 * hash(g + 1.3), 0.27 + 0.14 * hash(g + 4.1));
|
||||
if (abs(l.x) < hs.x && abs(l.y) < hs.y) {
|
||||
float k = hash(g + 5.3);
|
||||
vec3 col = k < 0.44 ? vec3(0.96, 0.95, 0.91) : k < 0.68 ? vec3(0.80, 0.46, 0.32) : k < 0.80 ? vec3(0.93, 0.79, 0.56) : k < 0.90 ? vec3(0.94, 0.75, 0.67) : vec3(0.88, 0.86, 0.80);
|
||||
float rim = min(hs.x - abs(l.x), hs.y - abs(l.y));
|
||||
if (k >= 0.44 && k < 0.68) {
|
||||
// pitched roof: the ridge runs along the long side, one slope lit, one shaded
|
||||
float lit = dot(vec2(-cs.y, cs.x), vec2(-0.67, -0.73)) * sign(l.y);
|
||||
col *= lit > 0.0 ? 1.08 : 0.80;
|
||||
col *= 0.95 + 0.05 * sin(l.x * 3.6);
|
||||
col *= 1.0 - 0.25 * (1.0 - smoothstep(0.0, 0.5, abs(l.y)));
|
||||
} else {
|
||||
// flat roof: a parapet round the edge, a water tank on some
|
||||
col *= 1.0 - 0.13 * (1.0 - smoothstep(0.4, 1.1, rim));
|
||||
vec2 tk = vec2(hs.x * 0.45, -hs.y * 0.35);
|
||||
if (hash(g + 6.2) < 0.35 && length(l - tk) < 0.95) col = vec3(0.45, 0.47, 0.50) + 0.25 * step(0.0, -(l - tk).x - (l - tk).y);
|
||||
if (k < 0.44 && hash(g + 4.4) < 0.08 && length(l) < min(hs.x, hs.y) * 0.85) {
|
||||
col = mix(vec3(0.12, 0.30, 0.64), vec3(0.58, 0.76, 0.98), clamp(0.55 - dot(rel / (min(hs.x, hs.y) * 0.85), vec2(-0.67, -0.73)) * 0.6, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
col *= 0.93 + 0.1 * hash(g + 9.9);
|
||||
roof = col;
|
||||
hid = g;
|
||||
got = 1.0;
|
||||
}
|
||||
}
|
||||
return got;
|
||||
}
|
||||
void main() {
|
||||
vec2 sp = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y) / uDpr;
|
||||
vec2 p = sp + uCam;
|
||||
float t = uT;
|
||||
vec3 L = normalize(vec3(-0.55, -0.6, 0.62));
|
||||
float d = coast(p.x) - p.y; // > 0 at sea, < 0 inland, in px
|
||||
float wash = 6.0 + 4.0 * sin(t * 1.2 + p.x * 0.045) + 3.0 * fbm3(vec2(p.x * 0.08, t * 0.6));
|
||||
vec3 col;
|
||||
if (d > -2.0) {
|
||||
/* ── sea ── */
|
||||
float e = 0.9, h = wave(p, t);
|
||||
vec3 n = normalize(vec3(-(wave(p + vec2(e, 0.0), t) - h) * 1.6, -(wave(p + vec2(0.0, e), t) - h) * 1.6, 1.0));
|
||||
float depth = smoothstep(0.0, 120.0, d);
|
||||
col = mix(vec3(0.47, 0.83, 0.82), vec3(0.17, 0.58, 0.72), smoothstep(0.0, 0.45, depth));
|
||||
col = mix(col, vec3(0.07, 0.33, 0.55), smoothstep(0.45, 1.0, depth));
|
||||
col *= 0.78 + 0.32 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
|
||||
vec3 Ls = normalize(vec3(0.45, -0.55, 0.70));
|
||||
vec3 R = reflect(-Ls, n);
|
||||
col += vec3(1.0, 0.97, 0.9) * (pow(clamp(R.z, 0.0, 1.0), 90.0) * 0.45 + pow(clamp(R.z, 0.0, 1.0), 900.0) * step(0.55, noise(p * 0.6 + t * 2.0)) * 1.4);
|
||||
float ca = pow(1.0 - abs(sin(fbm(p * 0.09 + vec2(t * 0.35, -t * 0.25)) * 9.0)), 6.0);
|
||||
col += vec3(0.85, 1.0, 0.95) * ca * 0.22 * (1.0 - smoothstep(0.0, 40.0, d));
|
||||
float runs = smoothstep(0.82, 1.0, sin(d * 0.32 + t * 2.2 + fbm3(p * 0.05) * 3.0)) * (1.0 - smoothstep(6.0, 46.0, d));
|
||||
float lace = smoothstep(0.45, 0.75, fbm3(p * 0.22 + vec2(t * 0.5, 0.0)));
|
||||
float foam = max(1.0 - smoothstep(wash - 4.0, wash + 3.0, d), runs * lace * 0.8);
|
||||
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(foam, 0.0, 1.0) * 0.92);
|
||||
// boats crossing, each in a lane of its own (46 px apart, wider than any
|
||||
// wake), at a speed that swells and eases but never turns back
|
||||
for (int k = 0; k < 2; k++) {
|
||||
float fk = float(k);
|
||||
float dir = fk < 0.5 ? 1.0 : -1.0;
|
||||
float speed = 10.0 + fk * 4.0;
|
||||
float run = speed * t + 14.0 * sin(t * 0.23 + fk * 2.0) + 6.0 * sin(t * 0.61 + fk * 4.0);
|
||||
float lane = 36.0 + fk * 46.0;
|
||||
float drift = 2.6 * sin(t * 0.31 + fk * 1.7);
|
||||
vec2 bp = vec2(mod(fk * 190.0 + run * dir, 480.0) - 45.0, lane + drift);
|
||||
float yaw = 2.6 * 0.31 * cos(t * 0.31 + fk * 1.7) / speed;
|
||||
vec2 q = p - bp;
|
||||
q = vec2(q.x * dir, q.y);
|
||||
q = vec2(q.x + yaw * q.y, q.y - yaw * q.x);
|
||||
float back = -q.x;
|
||||
float open = 0.29 + 0.05 * sin(t * 1.3 + fk * 3.0 + back * 0.05);
|
||||
float spread = back * open;
|
||||
float churn = smoothstep(0.25, 0.65, noise(vec2(back * 0.35 - t * 1.6, q.y * 0.45 + fk * 7.0)));
|
||||
float edgeW = 0.7 + 0.8 * noise(vec2(back * 0.2 + fk * 3.0, t * 0.8));
|
||||
float fade = step(0.0, back) * (1.0 - smoothstep(8.0, 64.0, back));
|
||||
float wake = (1.0 - smoothstep(edgeW * 0.6, edgeW * 1.8, abs(abs(q.y) - spread))) * fade * (0.55 + 0.45 * churn);
|
||||
wake += (1.0 - smoothstep(0.0, 2.4, abs(q.y))) * step(0.0, back) * (1.0 - smoothstep(4.0, 30.0, back)) * 0.7 * churn;
|
||||
col = mix(col, vec3(0.96, 0.98, 1.0), clamp(wake, 0.0, 1.0) * 0.78);
|
||||
float hull = length(q / vec2(6.5, 2.4));
|
||||
col = mix(col, vec3(0.20, 0.30, 0.40), (1.0 - smoothstep(1.0, 1.5, length((q - vec2(-1.0, 1.4)) / vec2(6.5, 2.4)))) * 0.25);
|
||||
col = mix(col, mix(vec3(0.98), vec3(0.80, 0.82, 0.85), step(0.0, q.y)), 1.0 - smoothstep(0.92, 1.0, hull));
|
||||
col = mix(col, vec3(0.55, 0.36, 0.24), 1.0 - smoothstep(0.9, 1.0, length((q - vec2(-1.0, 0.0)) / vec2(2.4, 1.2))));
|
||||
}
|
||||
// the island: a shelf of shallow water, a reef, surf, a beach, a wooded hill
|
||||
if (abs(p.y + 250.0) < 175.0 && abs(p.x - 265.0) < 310.0) {
|
||||
float sI = islD(p);
|
||||
if (sI < 0.0) {
|
||||
float sd = -sI;
|
||||
float shelf = 1.0 - smoothstep(4.0, 78.0, sd);
|
||||
float ripple = 0.5 + 0.5 * sin(dot(p, vec2(0.42, 0.25)) + fbm(p * 0.05) * 6.0);
|
||||
vec3 sh = mix(vec3(0.13, 0.52, 0.66), vec3(0.40, 0.83, 0.81), smoothstep(62.0, 10.0, sd));
|
||||
sh = mix(sh, vec3(0.80, 0.86, 0.74), smoothstep(26.0, 0.0, sd) * 0.4);
|
||||
sh *= 0.95 + 0.08 * ripple * smoothstep(50.0, 4.0, sd);
|
||||
float swell = sin(sd * 0.3 + t * 1.8 + fbm3(p * 0.03) * 4.0);
|
||||
sh *= 1.0 + 0.09 * swell * smoothstep(70.0, 20.0, sd) * smoothstep(0.0, 8.0, sd);
|
||||
float reef = smoothstep(0.57, 0.68, fbm(p * 0.045 + 13.0)) * smoothstep(12.0, 22.0, sd) * (1.0 - smoothstep(46.0, 60.0, sd));
|
||||
sh = mix(sh, vec3(0.18, 0.40, 0.42) * (0.82 + 0.3 * noise(p * 0.5)), reef * 0.75);
|
||||
float lw = 0.85 + 0.3 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
|
||||
col = mix(col, sh * lw, shelf);
|
||||
float cs = pow(1.0 - abs(sin(fbm(p * 0.12 + vec2(t * 0.4, -t * 0.3)) * 10.0)), 7.0);
|
||||
col += vec3(0.9, 1.0, 0.95) * cs * 0.24 * smoothstep(45.0, 4.0, sd);
|
||||
// surf breaking along the reef edge, in pieces
|
||||
float reefLine = 44.0 + 12.0 * (fbm(p * 0.02 + 5.0) - 0.5);
|
||||
float brk = (1.0 - smoothstep(0.0, 2.0 + 2.5 * fbm3(p * 0.04 + vec2(t * 0.25, 0.0)), abs(sd - reefLine - 2.5 * sin(t * 0.8 + p.x * 0.03))));
|
||||
brk *= smoothstep(0.35, 0.62, noise(p * 0.08 + vec2(t * 0.6, 0.0))) * smoothstep(0.42, 0.56, fbm(p * 0.03 + 13.0));
|
||||
col = mix(col, vec3(0.96, 0.99, 1.0), brk * 0.6);
|
||||
// waves rolling in, foam lines thinning out as they come, the swash on the sand
|
||||
float lace2 = smoothstep(0.42, 0.72, fbm3(p * 0.25 + vec2(t * 0.4, 0.0)));
|
||||
float roll = smoothstep(0.9, 1.0, swell) * (1.0 - smoothstep(5.0, 26.0, sd));
|
||||
float iw = 4.5 + 3.5 * sin(t * 1.1 + fbm(p * 0.03) * 6.0);
|
||||
float swash = 1.0 - smoothstep(iw - 3.0, iw + 2.5, sd);
|
||||
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(max(swash * (0.55 + 0.45 * lace2), roll * lace2 * 0.8), 0.0, 1.0) * 0.9);
|
||||
} else {
|
||||
float hI = islH(p);
|
||||
vec3 nI = normalize(vec3(-(islH(p + vec2(1.0, 0.0)) - hI) * 110.0, -(islH(p + vec2(0.0, 1.0)) - hI) * 110.0, 1.0));
|
||||
float shI = clamp(dot(nI, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float bwI = 5.0 + 15.0 * fbm(p * 0.03 + 2.0);
|
||||
vec3 sandI = vec3(0.95, 0.89, 0.74) + (hash(floor(p * 3.0)) - 0.5) * 0.05;
|
||||
sandI = mix(sandI, vec3(0.76, 0.69, 0.56), (1.0 - smoothstep(0.0, 4.5, sI)) * 0.75);
|
||||
float big = step(0.55, fbm(p * 0.025 + 6.0));
|
||||
vec3 tr = big > 0.5 ? tree(p, 8.0) : tree(p, 4.6);
|
||||
vec3 trS = big > 0.5 ? tree(p - vec2(2.6, 3.0), 8.0) : tree(p - vec2(1.7, 2.0), 4.6);
|
||||
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
|
||||
float jung = smoothstep(bwI, bwI + 8.0, sI);
|
||||
vec3 under = mix(vec3(0.46, 0.55, 0.28), vec3(0.28, 0.42, 0.19), noise(p * 0.1)) * (1.0 - 0.35 * trS.x);
|
||||
vec3 canopy = mix(vec3(0.09, 0.27, 0.12), vec3(0.42, 0.62, 0.28), lit) * (0.9 + 0.2 * noise(p * 2.0));
|
||||
vec3 landI = mix(sandI, under, jung);
|
||||
landI *= 1.0 - 0.3 * trS.x * smoothstep(bwI - 4.0, bwI, sI) * (1.0 - jung);
|
||||
landI = mix(landI, canopy, tr.x * smoothstep(bwI - 3.0, bwI + 5.0, sI) * (0.35 + 0.65 * smoothstep(0.22, 0.36, fbm(p * 0.035 + 1.0))));
|
||||
// bare rock on the steep top and boulders where the headland meets the sea
|
||||
float slI = 1.0 - nI.z;
|
||||
landI = mix(landI, vec3(0.62, 0.58, 0.50) * (0.9 + 0.15 * noise(p * 0.8)), smoothstep(0.7, 0.88, slI) * 0.6);
|
||||
vec3 bo = tree(p, 4.0);
|
||||
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
|
||||
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
|
||||
float rk = smoothstep(0.6, 0.7, fbm(p * 0.05 + 4.0)) * (1.0 - smoothstep(bwI - 2.0, bwI + 4.0, sI));
|
||||
landI = mix(landI, rocks, rk);
|
||||
landI *= 0.5 + 0.7 * shI;
|
||||
col = mix(vec3(0.90, 0.95, 0.93), landI, smoothstep(0.0, 1.4, sI));
|
||||
}
|
||||
// a sailboat going round, well outside the reef
|
||||
float sa = t * 0.06;
|
||||
vec2 sb = vec2(230.0, -250.0) + vec2(cos(sa) * 1.5, sin(sa)) * 140.0;
|
||||
vec2 tang = normalize(vec2(-sin(sa) * 1.5, cos(sa)));
|
||||
vec2 q = p - sb;
|
||||
q = vec2(dot(q, tang), dot(q, vec2(-tang.y, tang.x)));
|
||||
float back = -q.x;
|
||||
float wk = (1.0 - smoothstep(0.5, 1.6, abs(abs(q.y) - back * 0.22))) * step(0.0, back) * (1.0 - smoothstep(6.0, 44.0, back));
|
||||
col = mix(col, vec3(0.96, 0.98, 1.0), wk * 0.7);
|
||||
col = mix(col, vec3(0.10, 0.22, 0.32), (1.0 - smoothstep(1.0, 1.4, length((q - vec2(-1.5, 2.6)) / vec2(5.0, 3.0)))) * 0.25);
|
||||
col = mix(col, vec3(0.98), 1.0 - smoothstep(0.9, 1.0, length(q / vec2(4.8, 1.7))));
|
||||
float sail = max(abs(q.x + 0.4) - 2.6, abs(q.y + 1.2 - (q.x + 0.4) * 0.15) - 0.7);
|
||||
col = mix(col, vec3(0.88, 0.86, 0.82), 1.0 - smoothstep(-0.2, 0.3, sail));
|
||||
}
|
||||
} else {
|
||||
/* ── land: hillside, macchia, and the town down at the water ── */
|
||||
float e = 1.2, h = ground(p);
|
||||
vec3 n = normalize(vec3(-(ground(p + vec2(e, 0.0)) - h) * 17.0, -(ground(p + vec2(0.0, e)) - h) * 17.0, 1.0));
|
||||
float shade = clamp(dot(n, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float slope = 1.0 - n.z;
|
||||
float veg = fbm(p * 0.03 + 2.0);
|
||||
vec3 base = mix(vec3(0.78, 0.72, 0.53), vec3(0.50, 0.56, 0.33), smoothstep(0.42, 0.58, veg));
|
||||
base = mix(base, vec3(0.36, 0.45, 0.25), smoothstep(0.6, 0.7, veg) * 0.8);
|
||||
base *= 0.9 + 0.16 * noise(p * 1.4) + 0.06 * (hash(floor(p * 2.0)) - 0.5);
|
||||
float shrub = smoothstep(0.42, 0.6, fbm(p * 0.022 + 11.0));
|
||||
vec3 shT = tree(p - vec2(2.0, 2.4), 6.0);
|
||||
base *= 1.0 - 0.34 * shT.x * shrub;
|
||||
vec3 tr = tree(p, 6.0);
|
||||
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
|
||||
vec3 leaf = mix(vec3(0.16, 0.27, 0.14), vec3(0.45, 0.55, 0.30), lit) * (0.9 + 0.2 * noise(p * 2.3));
|
||||
base = mix(base, leaf, tr.x * shrub * (1.0 - smoothstep(0.35, 0.6, slope)));
|
||||
vec3 limestone = mix(vec3(0.70, 0.66, 0.58), vec3(0.88, 0.85, 0.78), noise(p * 0.7)) * (0.92 + 0.12 * noise(p * 3.1));
|
||||
base = mix(base, limestone, smoothstep(0.3, 0.6, slope) * 0.75);
|
||||
// the town: dense by the water, thinning out up the hill, house by house
|
||||
vec3 roof, tmpRoof; vec2 hid, tmpId;
|
||||
float occ = town(p, roof, hid);
|
||||
float occS = town(p - vec2(2.4, 3.0), tmpRoof, tmpId);
|
||||
float near = exp(d / 150.0);
|
||||
float want = fbm(p * 0.011 + 3.0) * 0.75 * exp(d / 420.0) + near * 0.6;
|
||||
float here = step(0.55 + 0.12 * hash(hid + 6.6), want);
|
||||
float hereS = step(0.55 + 0.12 * hash(tmpId + 6.6), want);
|
||||
// lanes are paved only in the dense heart; where no house stands the hillside shows
|
||||
float dense = smoothstep(0.62, 0.7, want);
|
||||
vec3 stone = vec3(0.87, 0.84, 0.77) * (0.95 + 0.07 * noise(p * 1.6));
|
||||
vec3 open = roof.x < -0.5 ? mix(stone, vec3(0.48, 0.57, 0.34), step(0.5, hash(hid + 3.0))) : stone;
|
||||
col = mix(base, open, dense);
|
||||
if (occ * here > 0.5) col = roof;
|
||||
else if (occS * hereS > 0.5) col *= 0.64;
|
||||
// the hinterland: parcels with their own crop and row direction, hedges between
|
||||
float farm = smoothstep(150.0, 200.0, -d) * (1.0 - smoothstep(450.0, 510.0, -d));
|
||||
if (farm > 0.0) {
|
||||
vec2 fp = (p + vec2(fbm(p * 0.015), fbm(p * 0.015 + 7.0)) * 10.0) * vec2(0.62, 1.0);
|
||||
vec2 fc = floor(fp / 58.0);
|
||||
float b1 = 99.0, b2 = 99.0; vec2 fid = vec2(0.0);
|
||||
for (int fj = -1; fj <= 1; fj++) for (int fi = -1; fi <= 1; fi++) {
|
||||
vec2 g = fc + vec2(float(fi), float(fj));
|
||||
float dd = length(fp - (g + 0.25 + 0.5 * hash2(g)) * 58.0);
|
||||
if (dd < b1) { b2 = b1; b1 = dd; fid = g; } else if (dd < b2) { b2 = dd; }
|
||||
}
|
||||
float kind = hash(fid + 1.7);
|
||||
float ang = hash(fid + 4.2) * 3.1416;
|
||||
vec2 dir = vec2(cos(ang), sin(ang));
|
||||
float u = dot(p, dir), v = dot(p, vec2(-dir.y, dir.x));
|
||||
vec3 parcel;
|
||||
if (kind < 0.36) {
|
||||
float r = smoothstep(-0.2, 0.6, sin(u * 6.2831 / 4.0));
|
||||
vec3 vine = mix(vec3(0.24, 0.36, 0.16), vec3(0.44, 0.54, 0.25), noise(p * 1.4)) * (0.85 + 0.2 * noise(vec2(u * 0.3, v * 1.6)));
|
||||
parcel = mix(vec3(0.67, 0.57, 0.42), vine, r);
|
||||
} else if (kind < 0.56) {
|
||||
parcel = mix(vec3(0.88, 0.78, 0.52), vec3(0.80, 0.68, 0.43), 0.5 + 0.5 * sin(u * 6.2831 / 2.4)) * (0.94 + 0.1 * noise(p * 0.5));
|
||||
} else if (kind < 0.74) {
|
||||
vec2 q = vec2(u, v) / 9.0;
|
||||
vec2 qc = fract(q) - 0.5 + (hash2(floor(q)) - 0.5) * 0.18;
|
||||
float olive = 1.0 - smoothstep(0.2, 0.33, length(qc));
|
||||
float oshadow = 1.0 - smoothstep(0.2, 0.36, length(qc - vec2(0.12, 0.16)));
|
||||
vec3 soil = vec3(0.76, 0.68, 0.52) * (1.0 - 0.28 * oshadow);
|
||||
parcel = mix(soil, mix(vec3(0.36, 0.42, 0.28), vec3(0.56, 0.60, 0.42), clamp(0.6 - dot(qc, vec2(L.x, L.y)) * 1.4, 0.0, 1.0)), olive);
|
||||
} else if (kind < 0.88) {
|
||||
parcel = mix(vec3(0.50, 0.39, 0.28), vec3(0.63, 0.51, 0.37), 0.5 + 0.5 * sin(u * 6.2831 / 1.9)) * (0.95 + 0.08 * noise(p * 0.6));
|
||||
} else {
|
||||
parcel = mix(vec3(0.47, 0.57, 0.29), vec3(0.58, 0.63, 0.33), noise(p * 0.08));
|
||||
}
|
||||
// hedgerows and the odd cypress along the field edges
|
||||
float edgeD = (b2 - b1) * 0.5;
|
||||
parcel *= 0.92 + 0.16 * hash(fid + 5.5);
|
||||
float hedge = 1.0 - smoothstep(0.5, 1.5, edgeD);
|
||||
vec3 cy = tree(p, 5.0);
|
||||
parcel = mix(parcel, vec3(0.30, 0.35, 0.20), hedge * 0.75);
|
||||
parcel = mix(parcel, vec3(0.12, 0.20, 0.10), cy.x * (1.0 - smoothstep(1.5, 3.5, edgeD)) * step(0.7, hash(fid + 9.0)));
|
||||
float level = 1.0 - smoothstep(0.38, 0.6, slope);
|
||||
col = mix(col, parcel, farm * level * (1.0 - step(0.5, occ * here)));
|
||||
// a country road winding through, a car on it
|
||||
float ry = 425.0 + 26.0 * sin(p.x * 0.011 + 1.0) + 9.0 * sin(p.x * 0.029);
|
||||
float rd = abs(p.y - ry);
|
||||
col = mix(col, vec3(0.86, 0.82, 0.72), (1.0 - smoothstep(4.6, 5.6, rd)) * farm);
|
||||
col = mix(col, vec3(0.36, 0.36, 0.37) * (0.95 + 0.08 * noise(p * 2.0)), (1.0 - smoothstep(3.4, 4.2, rd)) * farm);
|
||||
col = mix(col, vec3(0.95), (1.0 - smoothstep(0.25, 0.6, rd)) * step(0.5, fract(p.x / 9.0)) * farm);
|
||||
float cx = mod(t * 22.0 + 6.0 * sin(t * 0.4), 760.0) - 120.0;
|
||||
float cyy = 425.0 + 26.0 * sin(cx * 0.011 + 1.0) + 9.0 * sin(cx * 0.029) - 1.6;
|
||||
float slopeR = 26.0 * 0.011 * cos(cx * 0.011 + 1.0) + 9.0 * 0.029 * cos(cx * 0.029);
|
||||
vec2 cq = p - vec2(cx, cyy);
|
||||
float ca = atan(slopeR);
|
||||
cq = vec2(cq.x * cos(ca) + cq.y * sin(ca), -cq.x * sin(ca) + cq.y * cos(ca));
|
||||
float body = max(abs(cq.x) - 3.4, abs(cq.y) - 1.4);
|
||||
col = mix(col, vec3(0.14, 0.14, 0.15), (1.0 - smoothstep(0.0, 1.4, max(abs(cq.x - 0.8) - 3.6, abs(cq.y - 0.9) - 1.6))) * 0.35);
|
||||
col = mix(col, vec3(0.78, 0.16, 0.13), 1.0 - smoothstep(-0.2, 0.4, body));
|
||||
col = mix(col, vec3(0.20, 0.24, 0.30), (1.0 - smoothstep(-0.2, 0.3, max(abs(cq.x - 0.9) - 1.0, abs(cq.y) - 1.0))));
|
||||
}
|
||||
col *= 0.64 + 0.5 * shade;
|
||||
col *= 1.0 - 0.1 * smoothstep(0.55, 0.0, h);
|
||||
// the high country: broad massifs, conifers in the valley, snow on the tops
|
||||
float alpine = smoothstep(470.0, 590.0, -d);
|
||||
if (alpine > 0.0) {
|
||||
float lakeD = length((p - vec2(250.0, 790.0)) / vec2(2.3, 1.0));
|
||||
float valley = mix(0.35, 1.0, smoothstep(26.0, 80.0, lakeD));
|
||||
float hA = (peaks(p * 0.006 + 5.0) * 1.3 - 0.2 + fbm(p * 0.035) * 0.05) * valley;
|
||||
float hX = (peaks((p + vec2(1.5, 0.0)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(1.5, 0.0)) * 0.035) * 0.05) * valley;
|
||||
float hY = (peaks((p + vec2(0.0, 1.5)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(0.0, 1.5)) * 0.035) * 0.05) * valley;
|
||||
vec3 nA = normalize(vec3(-(hX - hA) * 95.0, -(hY - hA) * 95.0, 1.0));
|
||||
float shA = clamp(dot(nA, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float slA = 1.0 - nA.z;
|
||||
vec3 rockA = mix(vec3(0.46, 0.43, 0.39), vec3(0.62, 0.58, 0.52), noise(p * 0.05)) * (0.94 + 0.08 * noise(p * 0.9));
|
||||
vec3 trA = tree(p, 4.4);
|
||||
vec3 trS = tree(p - vec2(1.6, 2.0), 4.4);
|
||||
float conifer = (1.0 - smoothstep(0.40, 0.50, hA + (noise(p * 0.06) - 0.5) * 0.08)) * (1.0 - smoothstep(0.5, 0.75, slA));
|
||||
vec3 meadow = mix(vec3(0.42, 0.50, 0.28), vec3(0.52, 0.56, 0.32), noise(p * 0.1));
|
||||
vec3 forestA = meadow * (1.0 - 0.4 * trS.x);
|
||||
forestA = mix(forestA, mix(vec3(0.07, 0.15, 0.09), vec3(0.20, 0.33, 0.18), clamp(0.6 - dot(trA.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0)), trA.x * smoothstep(0.2, 0.5, fbm(p * 0.02 + 3.0) + 0.25));
|
||||
vec3 alp = mix(rockA, forestA, conifer);
|
||||
float snowA = smoothstep(0.75, 0.81, hA + (noise(p * 0.09) - 0.5) * 0.1) * (1.0 - smoothstep(0.55, 0.8, slA));
|
||||
snowA = max(snowA, smoothstep(0.86, 0.94, hA));
|
||||
alp *= 0.36 + 0.86 * shA;
|
||||
alp = mix(alp * vec3(0.8, 0.87, 1.05), alp * vec3(1.04, 1.0, 0.95), smoothstep(0.35, 0.8, shA));
|
||||
vec3 snowC = mix(vec3(0.70, 0.77, 0.88), vec3(0.98, 0.98, 0.97), smoothstep(0.2, 0.75, shA)) * (0.96 + 0.05 * noise(p * 0.6));
|
||||
alp = mix(alp, snowC, snowA);
|
||||
col = mix(col, alp, alpine);
|
||||
}
|
||||
// a mountain lake in a valley
|
||||
vec2 lc = vec2(250.0, 790.0);
|
||||
float ld = length((p - lc) / vec2(2.3, 1.0)) + (fbm(p * 0.05) - 0.5) * 14.0;
|
||||
if (ld < 34.0) {
|
||||
float lw = wave(p * 1.3, t * 0.6);
|
||||
vec3 lake = mix(vec3(0.12, 0.36, 0.48), vec3(0.24, 0.55, 0.62), smoothstep(34.0, 12.0, ld));
|
||||
lake *= 0.86 + 0.12 * lw;
|
||||
lake += vec3(1.0, 0.98, 0.92) * pow(noise(p * 0.6 + t * 1.2), 14.0) * 0.7;
|
||||
col = mix(col, lake, smoothstep(34.0, 31.0, ld));
|
||||
col = mix(col, vec3(0.82, 0.80, 0.74), (1.0 - smoothstep(0.0, 1.8, abs(ld - 32.0))) * 0.8);
|
||||
}
|
||||
// the shore: small coves of sand between rocks, wet where the wash runs
|
||||
float bw = 17.0 + 10.0 * fbm3(vec2(p.x * 0.03, 1.0));
|
||||
float shore = 1.0 - smoothstep(bw - 3.0, bw + 2.0, -d);
|
||||
float rock = smoothstep(0.56, 0.66, fbm(p * 0.05 + 4.0));
|
||||
vec3 sand = vec3(0.93, 0.86, 0.71) + (hash(floor(p * 3.0)) - 0.5) * 0.06;
|
||||
float wet = 1.0 - smoothstep(0.0, 6.0 + wash * 0.4, -d);
|
||||
sand = mix(sand, vec3(0.74, 0.67, 0.55), wet * 0.8);
|
||||
// boulders: round stones lit from the top left, darker in the cracks between
|
||||
vec3 bo = tree(p, 4.5);
|
||||
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
|
||||
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
|
||||
vec3 edgeCol = mix(sand, rocks, rock);
|
||||
col = mix(col, edgeCol, shore);
|
||||
}
|
||||
// haze into the page below: a long, uneven edge that drifts slowly
|
||||
float H = uRes.y / uDpr;
|
||||
float edge = H - 118.0 + 42.0 * (fbm3(vec2(sp.x * 0.011, t * 0.04)) - 0.5) * 2.0 + 22.0 * (fbm(sp * 0.028 + vec2(t * 0.05, 0.0)) - 0.5) * 2.0;
|
||||
float mist = smoothstep(edge - 34.0, edge + 84.0, sp.y);
|
||||
mist = clamp(mist + smoothstep(H - 24.0, H, sp.y), 0.0, 1.0);
|
||||
col = mix(col, vec3(1.0, 0.98, 0.961), mist * uHaze);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
const VERTEX = 'attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }';
|
||||
|
||||
export interface Scenery {
|
||||
/** Glides the camera to another place; a still picture jumps there. */
|
||||
flyTo(camera: Camera): void;
|
||||
}
|
||||
|
||||
interface SceneryOptions {
|
||||
/** The height the scene is framed for; the box may be taller or shorter. */
|
||||
frame: number;
|
||||
camera?: Camera;
|
||||
/** The soft fade into the page at the foot of the picture; off beside a column. */
|
||||
haze?: boolean;
|
||||
}
|
||||
|
||||
const reducedMotion = (): boolean =>
|
||||
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
export function startScenery(box: HTMLElement, opts: SceneryOptions): Scenery | null {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'scenery';
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
box.prepend(canvas);
|
||||
const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false, powerPreference: 'low-power' });
|
||||
if (!gl) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
const compile = (type: number, src: string): WebGLShader | null => {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
return shader;
|
||||
};
|
||||
const program = gl.createProgram();
|
||||
const vs = compile(gl.VERTEX_SHADER, VERTEX);
|
||||
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
|
||||
if (!program || !vs || !fs) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
||||
const position = gl.getAttribLocation(program, 'a');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
const uRes = gl.getUniformLocation(program, 'uRes');
|
||||
const uDpr = gl.getUniformLocation(program, 'uDpr');
|
||||
const uT = gl.getUniformLocation(program, 'uT');
|
||||
const uCam = gl.getUniformLocation(program, 'uCam');
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'uHaze'), opts.haze === false ? 0 : 1);
|
||||
|
||||
// The picture's resolution: the screen's, capped at 2, scaled down when frames come slow.
|
||||
let quality = 1;
|
||||
let offset: [number, number] = [0, 0];
|
||||
const resize = () => {
|
||||
const cssWidth = box.clientWidth || FRAME_WIDTH;
|
||||
const cssHeight = box.clientHeight || opts.frame;
|
||||
const ratio = Math.min(2, Math.max(1, window.devicePixelRatio || 1)) * quality;
|
||||
canvas.width = Math.max(1, Math.round(cssWidth * ratio));
|
||||
canvas.height = Math.max(1, Math.round(cssHeight * ratio));
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.uniform2f(uRes, canvas.width, canvas.height);
|
||||
gl.uniform1f(uDpr, canvas.width / cssWidth);
|
||||
// The scene is framed for a phone: a bigger box shows more around the same
|
||||
// centre, a smaller one (a small iPhone) the middle of it.
|
||||
offset = [(cssWidth - FRAME_WIDTH) / 2, (cssHeight - opts.frame) / 2];
|
||||
};
|
||||
resize();
|
||||
|
||||
const still = reducedMotion();
|
||||
const born = performance.now();
|
||||
let camera: [number, number] = [opts.camera?.[0] ?? 0, opts.camera?.[1] ?? 0];
|
||||
let flight: { from: [number, number]; to: Camera; start: number; duration: number } | null = null;
|
||||
let running = false;
|
||||
let lastFrame = 0;
|
||||
let measured = 0;
|
||||
let slowFrames = 0;
|
||||
|
||||
const draw = (now: number) => {
|
||||
if (flight) {
|
||||
const k = Math.min(1, (now - flight.start) / flight.duration);
|
||||
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
|
||||
camera = [
|
||||
flight.from[0] + (flight.to[0] - flight.from[0]) * e,
|
||||
flight.from[1] + (flight.to[1] - flight.from[1]) * e,
|
||||
];
|
||||
if (k >= 1) flight = null;
|
||||
}
|
||||
gl.uniform2f(uCam, camera[0] - offset[0], camera[1] - offset[1]);
|
||||
gl.uniform1f(uT, still ? 4 : (now - born) / 1000 + 4);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
};
|
||||
|
||||
const frame = (now: number) => {
|
||||
if (!canvas.isConnected || document.hidden) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
// A device that cannot keep up gets a smaller picture once, rather than a stutter.
|
||||
if (lastFrame && quality === 1 && measured < 90) {
|
||||
measured += 1;
|
||||
if (now - lastFrame > 24) slowFrames += 1;
|
||||
if (measured === 90 && slowFrames > 45) {
|
||||
quality = 0.7;
|
||||
resize();
|
||||
}
|
||||
}
|
||||
lastFrame = now;
|
||||
draw(now);
|
||||
if (!still || flight) requestAnimationFrame(frame);
|
||||
else running = false;
|
||||
};
|
||||
const run = () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
lastFrame = 0;
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
run();
|
||||
|
||||
// The loop stops in the background (document.hidden) and starts again on return.
|
||||
const wake = () => {
|
||||
if (!document.hidden && canvas.isConnected) run();
|
||||
};
|
||||
document.addEventListener('visibilitychange', wake);
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!canvas.isConnected) {
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', wake);
|
||||
return;
|
||||
}
|
||||
resize();
|
||||
if (still) run();
|
||||
});
|
||||
observer.observe(box);
|
||||
}
|
||||
canvas.addEventListener('webglcontextlost', (event) => {
|
||||
event.preventDefault();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
return {
|
||||
flyTo(to: Camera) {
|
||||
if (still) {
|
||||
camera = [to[0], to[1]];
|
||||
run();
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(to[0] - camera[0], to[1] - camera[1]);
|
||||
flight = {
|
||||
from: [camera[0], camera[1]],
|
||||
to,
|
||||
start: performance.now(),
|
||||
duration: Math.min(1700, 700 + distance * 1.1),
|
||||
};
|
||||
run();
|
||||
},
|
||||
};
|
||||
}
|
||||
+1212
-1102
File diff suppressed because it is too large
Load Diff
+23
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.2",
|
||||
"version": "5.1.4",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -18,6 +18,7 @@
|
||||
"store:graphic": "node scripts/generate-feature-graphic.mjs",
|
||||
"store:check": "node scripts/check-store-metadata.mjs",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/precompress.mjs",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -52,15 +53,15 @@
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@assistant-ui/react": "0.15.32",
|
||||
"@capacitor/action-sheet": "^8.1.1",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/android": "^8.6.0",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/dialog": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.3",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
"@capacitor/ios": "^8.6.0",
|
||||
"@capacitor/keyboard": "^8.0.5",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/splash-screen": "^8.0.2",
|
||||
@@ -105,19 +106,19 @@
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.20.0",
|
||||
"dexie": "^4.4.6",
|
||||
"axios": "^1.21.0",
|
||||
"dexie": "^4.4.7",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.5.2",
|
||||
"heic-to": "^1.6.7",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"plyr": "^3.8.5",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
@@ -131,41 +132,41 @@
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.0",
|
||||
"@capacitor/cli": "^8.6.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@jscpd/core": "^4.2.5",
|
||||
"@jscpd/tokenizer": "^4.2.6",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@testing-library/jest-dom": "^6.10.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.9.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitejs/plugin-react": "^6.2.1",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fake-indexeddb": "^6.2.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.15.0",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"rollup-plugin-visualizer": "^7.1.2",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "8.3.6",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The page /api/auth/native/start answers with runs this (see NativeAuthController.start).
|
||||
// A browser that ran an older TREK keeps that version's app in its service worker, and
|
||||
// the worker would answer the handoff page with an app that has no such page. So the
|
||||
// worker goes first, and the handoff page loads from the server.
|
||||
(function () {
|
||||
var script = document.currentScript;
|
||||
var target = script && script.getAttribute('data-target');
|
||||
if (!target || target.indexOf('/native-handoff?') !== 0) return;
|
||||
var gone = false;
|
||||
var go = function () {
|
||||
if (gone) return;
|
||||
gone = true;
|
||||
location.replace(target);
|
||||
};
|
||||
// Until the sign-in is handed back to the app, no worker is registered and no
|
||||
// new version reloads the page (src/native/browserSignInFlag.ts): a tab still
|
||||
// open on the older TREK keeps its worker alive, and either would bring it back.
|
||||
try {
|
||||
localStorage.setItem('trek_native_signin', String(Date.now()));
|
||||
} catch (e) {
|
||||
/* site data blocked: the worker is still taken off below */
|
||||
}
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.getRegistrations) {
|
||||
go();
|
||||
return;
|
||||
}
|
||||
// Never stuck here: whatever the browser does with the workers, the page moves on.
|
||||
var timer = setTimeout(go, 3000);
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then(function (registrations) {
|
||||
return Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister();
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
clearTimeout(timer);
|
||||
go();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
// Registers the service worker, as vite-plugin-pwa's own registerSW.js does; this
|
||||
// file in public/ takes its place. Not while the app's sign-in runs in this browser
|
||||
// (src/native/browserSignInFlag.ts): a tab still open on an older TREK keeps that
|
||||
// version's worker alive, and registering now would bring it back to answer the
|
||||
// sign-in's next page with an app that has no handoff page.
|
||||
(function () {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
try {
|
||||
var started = Number(localStorage.getItem('trek_native_signin'));
|
||||
var age = Date.now() - started;
|
||||
if (started > 0 && age >= 0 && age < 15 * 60 * 1000) return;
|
||||
} catch (e) {
|
||||
/* site data blocked: register as usual */
|
||||
}
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* The work behind scripts/precompress.mjs: a Brotli copy (<file>.br) beside every
|
||||
* compressible build file under dist/assets.
|
||||
*
|
||||
* The server compresses a build file on every download otherwise, at the low level
|
||||
* a request path can afford. A file under assets/ carries its content hash in its
|
||||
* name and never changes, so it is compressed once here, at the level a build can
|
||||
* afford, and the server hands that copy out as it is
|
||||
* (server/src/nest/platform/precompressed-static.ts).
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { brotliCompressSync, constants } from 'node:zlib';
|
||||
|
||||
/** What the server's own compression would take; fonts, images and video are compressed formats already. */
|
||||
const COMPRESSIBLE = /\.(?:m?js|css|svg|json|wasm|html|webmanifest)$/i;
|
||||
/** The server leaves anything below 1 kB uncompressed too: the headers cost more than the saving. */
|
||||
export const MIN_BYTES = 1024;
|
||||
/** Quality 11 takes about ten times as long per byte as 9, so the largest chunks get 9. */
|
||||
const BIG_BYTES = 1024 * 1024;
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else yield full;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the .br copies under `dir` and returns what it did. A copy that would not be
|
||||
* smaller than its file is not written, and a directory that does not exist is no work.
|
||||
*/
|
||||
export function precompress(dir) {
|
||||
const result = { files: 0, before: 0, after: 0 };
|
||||
let files;
|
||||
try {
|
||||
files = [...walk(dir)];
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return result;
|
||||
throw err;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!COMPRESSIBLE.test(file)) continue;
|
||||
const input = readFileSync(file);
|
||||
if (input.length < MIN_BYTES) continue;
|
||||
const output = brotliCompressSync(input, {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: input.length > BIG_BYTES ? 9 : 11,
|
||||
[constants.BROTLI_PARAM_SIZE_HINT]: input.length,
|
||||
},
|
||||
});
|
||||
if (output.length >= input.length) continue;
|
||||
writeFileSync(`${file}.br`, output);
|
||||
result.files += 1;
|
||||
result.before += input.length;
|
||||
result.after += output.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* postbuild: writes a Brotli copy beside every compressible file under dist/assets,
|
||||
* which the server hands to a browser that accepts Brotli instead of compressing
|
||||
* the file again on each download. scripts/lib/precompress.mjs holds the work.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { precompress } from './lib/precompress.mjs';
|
||||
|
||||
const { files, before, after } = precompress(fileURLToPath(new URL('../dist/assets', import.meta.url)));
|
||||
const mb = (bytes) => (bytes / 1048576).toFixed(1);
|
||||
console.log(`precompress: ${files} files, ${mb(before)} MB -> ${mb(after)} MB (brotli)`);
|
||||
@@ -10,7 +10,7 @@
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2910,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
@@ -18,7 +18,7 @@
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/LoginPage.tsx": 1294,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
|
||||
@@ -42,12 +42,11 @@
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Map/ReservationOverlay.test.tsx": 1,
|
||||
"src/components/Map/transitGeometry.ts": 1,
|
||||
"src/components/Map/useDawarichTrailGL.test.ts": 4,
|
||||
"src/components/Map/useMarkerThumbs.ts": 1,
|
||||
"src/components/PDF/TripPDF.tsx": 40,
|
||||
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
|
||||
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 61,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 52,
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
"pages/JourneyPublicPage.tsx": 113,
|
||||
"pages/LoginPage.tsx": 58,
|
||||
"pages/LoginPage.tsx": 54,
|
||||
"pages/OAuthAuthorizePage.tsx": 33,
|
||||
"pages/PluginPage.tsx": 1,
|
||||
"pages/ResetPasswordPage.tsx": 23,
|
||||
@@ -302,6 +302,7 @@
|
||||
"pages/VacayPage.tsx": 13,
|
||||
"pages/atlas/useAtlas.ts": 2,
|
||||
"pages/collections/useCollections.ts": 1,
|
||||
"pages/login/MfaCodeStep.tsx": 4,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 2,
|
||||
"pages/sharedTrip/SharedMap.tsx": 1,
|
||||
"pages/tripPlanner/ListsContainer.tsx": 1,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/markerLook.ts": 1,
|
||||
"components/Map/poiCategories.ts": 2,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingBringersCard.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
|
||||
@@ -145,7 +145,7 @@ apiClient.interceptors.request.use(
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/native-handoff']
|
||||
const publicPrefixes = ['/shared/', '/public/']
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAdminUserActions } from './useAdminUserActions';
|
||||
|
||||
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-007
|
||||
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-009
|
||||
|
||||
const api = vi.hoisted(() => ({ rotateJwtSecret: vi.fn(), resetUserPasskeys: vi.fn(), resetUserMfa: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ adminApi: api }));
|
||||
const api = vi.hoisted(() => ({
|
||||
rotateJwtSecret: vi.fn(),
|
||||
resetUserPasskeys: vi.fn(),
|
||||
resetUserMfa: vi.fn(),
|
||||
}));
|
||||
const http = vi.hoisted(() => ({ delete: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ adminApi: api, apiClient: http }));
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key);
|
||||
|
||||
@@ -28,6 +33,7 @@ beforeEach(() => {
|
||||
api.rotateJwtSecret.mockReset().mockResolvedValue({});
|
||||
api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 });
|
||||
api.resetUserMfa.mockReset().mockResolvedValue({ success: true });
|
||||
http.delete.mockReset().mockResolvedValue({ data: { success: true, unlinked: true } });
|
||||
});
|
||||
|
||||
describe('useAdminUserActions', () => {
|
||||
@@ -101,4 +107,26 @@ describe('useAdminUserActions', () => {
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMUSERACT-008: removing the SSO link calls the API, toasts and reports success', async () => {
|
||||
const { admin, actions } = mount();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await actions.unlinkSso({ id: 11 });
|
||||
});
|
||||
expect(http.delete).toHaveBeenCalledWith('/admin/users/11/sso');
|
||||
expect(admin.toast.success).toHaveBeenCalledWith('admin.sso.unlinkDone');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMUSERACT-009: a failed SSO unlink toasts and reports failure', async () => {
|
||||
http.delete.mockRejectedValue(new Error('x'));
|
||||
const { admin, actions } = mount();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await actions.unlinkSso({ id: 11 });
|
||||
});
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { adminApi } from '../../api/client';
|
||||
import { adminApi, apiClient } from '../../api/client';
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
interface UserActionsHost {
|
||||
@@ -12,8 +12,8 @@ interface UserActionsHost {
|
||||
/**
|
||||
* The irreversible user actions behind the admin dialogs and their phone sheets:
|
||||
* rotating the JWT secret, which signs everyone out including the admin, removing a
|
||||
* user's passkeys and switching off a user's two-factor login. Each shell keeps its own
|
||||
* confirm step and busy state around them.
|
||||
* user's passkeys, switching off a user's two-factor login and removing a user's SSO
|
||||
* link. Each shell keeps its own confirm step and busy state around them.
|
||||
*/
|
||||
export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
|
||||
const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin;
|
||||
@@ -55,5 +55,17 @@ export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
|
||||
}
|
||||
};
|
||||
|
||||
return { rotateJwt, resetPasskeys, resetMfa };
|
||||
/** Forgets the user's SSO identity so the next SSO sign-in links again (#3065); true when it worked. */
|
||||
const unlinkSso = async (user: { id: number }): Promise<boolean> => {
|
||||
try {
|
||||
await apiClient.delete(`/admin/users/${user.id}/sso`);
|
||||
toast.success(t('admin.sso.unlinkDone'));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return { rotateJwt, resetPasskeys, resetMfa, unlinkSso };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import L from 'leaflet';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
|
||||
import CollectionMap from './CollectionMap';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
|
||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollectionMap pins (#3073)', () => {
|
||||
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
|
||||
|
||||
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
|
||||
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
|
||||
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
|
||||
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
|
||||
const { view } = await renderMap([
|
||||
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
|
||||
]);
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
|
||||
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
|
||||
expect(html.toLowerCase()).toContain('#7c3aed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
|
||||
* while the map is built rather than flying in.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
||||
const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||
// The places as a set: another list, a filter or a new place frames again, while a
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
|
||||
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const list = places.filter(p => {
|
||||
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (!q) return true
|
||||
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
|
||||
return textMatchesSearch([p.name, p.address], search)
|
||||
})
|
||||
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
||||
else list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionImportablePlace, Trip } from '@trek/shared'
|
||||
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
|
||||
const savedCount = items.length - newCount
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return items
|
||||
.filter(p => (onlyNew ? !p.already_in_list : true))
|
||||
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
|
||||
.filter(p => textMatchesSearch([p.name, p.address], query))
|
||||
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
|
||||
}, [items, onlyNew, query])
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
import {
|
||||
buildSnapshot,
|
||||
liveDayDue,
|
||||
pickTrip,
|
||||
publishWidgets,
|
||||
todayOf,
|
||||
@@ -207,9 +208,10 @@ export default function NativeWidgetsSync(): null {
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
const live = main.day
|
||||
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
|
||||
: null;
|
||||
// The Live Activity from three hours before the day's first timed stop, not all day long.
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
const live =
|
||||
main.day && liveDayDue(main.day, minutes) ? tripDayState(main.day, minutes, t('native.widget.dayDone')) : null;
|
||||
await publishWidgets(main.snapshot, live, choices);
|
||||
await publishToWatch(hero, main, today);
|
||||
};
|
||||
|
||||
@@ -122,8 +122,8 @@ describe('DawarichTrailLayer', () => {
|
||||
|
||||
const [casing, line] = lines()
|
||||
expect(casing.getAttribute('data-pane')).toBe('trek-track-casing')
|
||||
expect(casing.getAttribute('data-weight')).toBe('6')
|
||||
expect(casing.getAttribute('data-opacity')).toBe('0.55')
|
||||
expect(casing.getAttribute('data-weight')).toBe('7')
|
||||
expect(casing.getAttribute('data-opacity')).toBe('0.9')
|
||||
|
||||
// Only the casing takes the pane; the coloured line is left in Leaflet's
|
||||
// default overlay pane. Pinned as the fact it is rather than as a rule:
|
||||
@@ -131,8 +131,9 @@ describe('DawarichTrailLayer', () => {
|
||||
// it, so the recorded line currently draws over the plan while the GL twin
|
||||
// puts both of its layers under it.
|
||||
expect(line.getAttribute('data-pane')).toBe('')
|
||||
expect(line.getAttribute('data-weight')).toBe('3')
|
||||
expect(line.getAttribute('data-dash')).toBe('6 5')
|
||||
// Full colour, wide and long-dashed, so it reads on a busy street map.
|
||||
expect(line.getAttribute('data-weight')).toBe('4')
|
||||
expect(line.getAttribute('data-dash')).toBe('10 5')
|
||||
expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]]))
|
||||
|
||||
expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { frameLine } from '../../utils/lngFrame'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import {
|
||||
DAWARICH_TRAIL_CASING,
|
||||
DAWARICH_TRAIL_STROKE,
|
||||
trailSegments,
|
||||
type DawarichTrailSegment,
|
||||
} from './dawarichTrail'
|
||||
@@ -55,8 +56,8 @@ export default function DawarichTrailLayer({
|
||||
// theme-lint-disable — map paint
|
||||
pathOptions={{
|
||||
color: DAWARICH_TRAIL_CASING,
|
||||
weight: 6,
|
||||
opacity: 0.55,
|
||||
weight: DAWARICH_TRAIL_STROKE.casingWidth,
|
||||
opacity: DAWARICH_TRAIL_STROKE.casingOpacity,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
@@ -72,9 +73,9 @@ export default function DawarichTrailLayer({
|
||||
positions={segment.points}
|
||||
pathOptions={{
|
||||
color: segment.color,
|
||||
weight: 3,
|
||||
opacity: 0.85,
|
||||
dashArray: '6 5',
|
||||
weight: DAWARICH_TRAIL_STROKE.width,
|
||||
opacity: DAWARICH_TRAIL_STROKE.opacity,
|
||||
dashArray: DAWARICH_TRAIL_STROKE.dashLeaflet,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
|
||||
@@ -27,6 +27,22 @@ export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map pain
|
||||
/** The white casing under the line, so it stays visible over a satellite tile. */
|
||||
export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
|
||||
|
||||
/**
|
||||
* How the recorded route is stroked, shared by the Leaflet and GL layers. A
|
||||
* thin, translucent dash over a nearly opaque white casing read as a pale
|
||||
* lavender trace on a busy street map and was barely visible (TestFlight
|
||||
* feedback, 5.1.3). Full colour, a wider line and longer dashes keep it
|
||||
* readable; the dash still tells it apart from the planned route.
|
||||
*/
|
||||
export const DAWARICH_TRAIL_STROKE = {
|
||||
casingWidth: 7,
|
||||
casingOpacity: 0.9,
|
||||
width: 4,
|
||||
opacity: 1,
|
||||
dashLeaflet: '10 5',
|
||||
dashGL: [2.5, 1.25] as [number, number],
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Per-day hues, cycled.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, Croissant, ShoppingBasket, type LucideIcon } from 'lucide-react'
|
||||
import type { TourPoiCategory } from '@trek/shared'
|
||||
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
@@ -29,6 +29,9 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
// Where to stock up: the bread for the day, and a supermarket or village shop.
|
||||
{ key: 'bakery', labelKey: 'poi.cat.bakeries', Icon: Croissant, color: '#D97706' }, // theme-lint-disable: marker and pill paint, as the core pins above
|
||||
{ key: 'supermarket', labelKey: 'poi.cat.groceries', Icon: ShoppingBasket, color: '#0D9488' }, // theme-lint-disable: marker and pill paint, as the core pins above
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,9 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
|
||||
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
|
||||
// BIK-07: the road trip's charging colour, so a charger reads as one on every map.
|
||||
ebike_charging: { key: 'ebike_charging', labelKey: 'tours.poi.cat.ebike_charging', Icon: BatteryCharging, color: SERVICE_COLORS.charging },
|
||||
// Resupply on a multi-day hike, in the explore pill's own looks.
|
||||
bakery: { key: 'bakery', labelKey: 'tours.poi.cat.bakery', Icon: Croissant, color: coreColor('bakery') },
|
||||
supermarket: { key: 'supermarket', labelKey: 'tours.poi.cat.supermarket', Icon: ShoppingBasket, color: coreColor('supermarket') },
|
||||
}
|
||||
|
||||
// A Map rather than an object, so a category named after an Object.prototype member
|
||||
|
||||
@@ -180,16 +180,23 @@ describe('useDawarichTrailGL', () => {
|
||||
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }))
|
||||
|
||||
const [casingCall, lineCall] = gl.spies.addLayer.mock.calls
|
||||
expect(casingCall[0].id).toBe(CASING_LAYER)
|
||||
expect(casingCall[0].paint['line-color']).toBe(DAWARICH_TRAIL_CASING)
|
||||
expect(casingCall[0].paint['line-width']).toBe(6)
|
||||
expect(lineCall[0].id).toBe(LINE_LAYER)
|
||||
if (!casingCall || !lineCall) throw new Error('expected the casing and the line layer')
|
||||
type LayerSpec = { id: string; paint: Record<string, unknown> }
|
||||
const casing = casingCall[0] as LayerSpec
|
||||
const line = lineCall[0] as LayerSpec
|
||||
expect(casing.id).toBe(CASING_LAYER)
|
||||
expect(casing.paint['line-color']).toBe(DAWARICH_TRAIL_CASING)
|
||||
expect(casing.paint['line-width']).toBe(7)
|
||||
expect(casing.paint['line-opacity']).toBe(0.9)
|
||||
expect(line.id).toBe(LINE_LAYER)
|
||||
// Per-feature colour, with the casing white as the fallback a feature
|
||||
// without one falls back to. The GPX layer uses the same shape.
|
||||
expect(lineCall[0].paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING])
|
||||
expect(line.paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING])
|
||||
// Dashed, so the recording is distinguishable from the planned route for
|
||||
// someone who cannot tell the two hues apart.
|
||||
expect(lineCall[0].paint['line-dasharray']).toEqual([2, 1.6])
|
||||
expect(line.paint['line-dasharray']).toEqual([2.5, 1.25])
|
||||
expect(line.paint['line-width']).toBe(4)
|
||||
expect(line.paint['line-opacity']).toBe(1)
|
||||
|
||||
// Both go beneath the planned route's casing. The line is inserted second
|
||||
// and therefore ends up above its own casing and still below the plan.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
||||
import type { Map } from 'mapbox-gl'
|
||||
import type { GeoJSONSource } from 'mapbox-gl'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail'
|
||||
import { DAWARICH_TRAIL_CASING, DAWARICH_TRAIL_STROKE, trailGeoJson, trailSegments } from './dawarichTrail'
|
||||
|
||||
/**
|
||||
* The recorded route on the GL renderer — the imperative twin of
|
||||
@@ -67,7 +67,11 @@ export function useDawarichTrailGL(
|
||||
source: SOURCE,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
// theme-lint-disable — map paint
|
||||
paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 },
|
||||
paint: {
|
||||
'line-color': DAWARICH_TRAIL_CASING,
|
||||
'line-width': DAWARICH_TRAIL_STROKE.casingWidth,
|
||||
'line-opacity': DAWARICH_TRAIL_STROKE.casingOpacity,
|
||||
},
|
||||
},
|
||||
anchor,
|
||||
)
|
||||
@@ -81,11 +85,11 @@ export function useDawarichTrailGL(
|
||||
// Per-day colour off the feature, with the same fallback the GPX
|
||||
// layer uses when a feature somehow carries none.
|
||||
'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.85,
|
||||
'line-width': DAWARICH_TRAIL_STROKE.width,
|
||||
'line-opacity': DAWARICH_TRAIL_STROKE.opacity,
|
||||
// Dashed, so the recording is distinguishable from the planned
|
||||
// route without relying on colour alone.
|
||||
'line-dasharray': [2, 1.6],
|
||||
'line-dasharray': [...DAWARICH_TRAIL_STROKE.dashGL],
|
||||
},
|
||||
},
|
||||
anchor,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende
|
||||
import { WeightInput } from './PackingListPanelWeightInput';
|
||||
|
||||
/** A row whose stored weight changes only once a save answers, as the store does. */
|
||||
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
|
||||
function SlowRow({ onSave, onStored }: { onSave: (grams: number | null) => void; onStored: () => void }) {
|
||||
const [stored, setStored] = useState<number | null>(null);
|
||||
return (
|
||||
<WeightInput
|
||||
@@ -13,7 +13,10 @@ function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
|
||||
width={36}
|
||||
onSave={(grams) => {
|
||||
onSave(grams);
|
||||
setTimeout(() => setStored(grams), 300);
|
||||
setTimeout(() => {
|
||||
setStored(grams);
|
||||
onStored();
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -22,7 +25,8 @@ function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
|
||||
describe('WeightInput', () => {
|
||||
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SlowRow onSave={onSave} />);
|
||||
const onStored = vi.fn();
|
||||
render(<SlowRow onSave={onSave} onStored={onStored} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.focus(input);
|
||||
@@ -35,7 +39,9 @@ describe('WeightInput', () => {
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith(250);
|
||||
await waitFor(() => expect(input.value).toBe('250'));
|
||||
// Wait for the save to answer, so its update lands while the test still runs.
|
||||
await waitFor(() => expect(onStored).toHaveBeenCalled());
|
||||
expect(input.value).toBe('250');
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
|
||||
|
||||
@@ -254,3 +254,57 @@ describe('DayPlanSidebar timed booking drop', () => {
|
||||
expect(onReorder).toHaveBeenCalledWith(10, [11, 12, 13, 14]);
|
||||
});
|
||||
});
|
||||
|
||||
// #3041: Firefox sends dragstart while it handles the mouse move that starts the
|
||||
// drag, and delivers that move to the page right after. Taken as "the drag is over",
|
||||
// it dropped the drag data at pick-up, so a stop could only be moved by its arrows,
|
||||
// and the day list played its enter animation again.
|
||||
describe('DayPlanSidebar drag in Firefox', () => {
|
||||
const renderDay = () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const stops = [buildPlace({ id: 1, name: 'Breakfast' }), buildPlace({ id: 2, name: 'Museum' })];
|
||||
const assignments = {
|
||||
'10': stops.map((place, i) => buildAssignment({ id: 11 + i, day_id: 10, order_index: i, place })),
|
||||
};
|
||||
const onReorder = vi.fn(async () => undefined);
|
||||
const view = render(
|
||||
<DayPlanSidebar {...makeDefaultProps({ days: [day], places: stops, assignments, onReorder })} />
|
||||
);
|
||||
const row = (text: string) => screen.getByText(text).closest('[draggable="true"]') as HTMLElement;
|
||||
const list = view.container.querySelector('.scroll-container') as HTMLElement;
|
||||
return { onReorder, row, list };
|
||||
};
|
||||
const dataTransfer = () => ({ setData: vi.fn(), effectAllowed: '', getData: vi.fn(() => '') });
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-239: the mouse move that starts a drag, with the button still held, keeps the drag', async () => {
|
||||
const { onReorder, row } = renderDay();
|
||||
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
|
||||
// Firefox order: dragstart first, then the move that triggered it. The touch
|
||||
// polyfill on hybrid laptops sends the same held-button moves throughout a drag.
|
||||
fireEvent.mouseMove(document, { buttons: 1 });
|
||||
expect(row('Museum').style.opacity).toBe('0.4');
|
||||
fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() });
|
||||
expect(row('Breakfast').style.borderTop).toContain('2px');
|
||||
fireEvent.drop(row('Breakfast'), { dataTransfer: dataTransfer() });
|
||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11]));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-240: a plain mouse move after a drag that never ended still clears it', () => {
|
||||
const { row } = renderDay();
|
||||
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
|
||||
fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() });
|
||||
fireEvent.mouseMove(document, { buttons: 0 });
|
||||
expect(row('Museum').style.opacity).toBe('1');
|
||||
expect(row('Breakfast').style.borderTop).toBe('');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-241: the day list plays its enter animation once, not again when a drag ends', () => {
|
||||
const { row, list } = renderDay();
|
||||
expect(list.classList.contains('trek-stagger')).toBe(true);
|
||||
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
|
||||
expect(list.classList.contains('trek-stagger')).toBe(false);
|
||||
fireEvent.dragEnd(row('Museum'));
|
||||
expect(row('Museum').style.opacity).toBe('1');
|
||||
expect(list.classList.contains('trek-stagger')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { useDayPlanDragState } from './useDayPlanDragState'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { TourOverrunLine } from '../Tours/TourDayTimeParts'
|
||||
import { dayItemOverrun } from '../Tours/tourDayTime'
|
||||
@@ -246,12 +247,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// distances refresh instead of showing stale cached text (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
@@ -268,6 +264,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||
} | null>(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const {
|
||||
draggingId, setDraggingId, hasDragged, dropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId,
|
||||
} = useDayPlanDragState(dragDataRef)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const dayRefs = useRef<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
@@ -374,28 +373,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
document.addEventListener('dragend', cleanup)
|
||||
return () => document.removeEventListener('dragend', cleanup)
|
||||
}, [])
|
||||
|
||||
// A drag whose source row re-rendered away never sends its dragend to the
|
||||
// document, and the drop line then stayed on a row until the next reload. The
|
||||
// browser sends no mousemove during a drag, so the first plain mouse move or
|
||||
// press after one means it is over.
|
||||
useEffect(() => {
|
||||
if (!draggingId && !dropTargetKey && !dragOverDayId) return
|
||||
const settle = () => {
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
setDragOverDayId(null)
|
||||
dragDataRef.current = null
|
||||
window.__dragData = null
|
||||
}
|
||||
document.addEventListener('mousemove', settle)
|
||||
document.addEventListener('pointerdown', settle, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', settle)
|
||||
document.removeEventListener('pointerdown', settle, true)
|
||||
}
|
||||
}, [draggingId, dropTargetKey, dragOverDayId])
|
||||
}, [setDraggingId, setDropTargetKey, setDragOverDayId])
|
||||
|
||||
// Initialize missing transport positions outside of render to avoid setState-during-render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1148,6 +1126,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
hotelLegs,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
hasDragged,
|
||||
lockedIds,
|
||||
dropTargetKey,
|
||||
dropTargetRef,
|
||||
@@ -1316,6 +1295,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
hotelLegs,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
hasDragged,
|
||||
lockedIds,
|
||||
dropTargetKey,
|
||||
dropTargetRef,
|
||||
@@ -1476,7 +1456,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className={`scroll-container flex flex-col gap-2 p-2${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
<div className={`scroll-container flex flex-col gap-2 p-2${hasDragged ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
// Shared by the click and the key handler so the two can never drift
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { MapPin, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
|
||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows: (T | LocationPoint)[] = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
|
||||
: results
|
||||
return { showPicks, rows }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
|
||||
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import PlaceFormModal from './PlaceFormModal';
|
||||
|
||||
vi.mock('../shared/CustomTimePicker', async () => ({
|
||||
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedPlaceFormStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PlaceFormModal with a place from a collection', () => {
|
||||
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
|
||||
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
|
||||
incomingInvites: [],
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
|
||||
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
|
||||
places: [
|
||||
{
|
||||
id: 10,
|
||||
collection_id: 1,
|
||||
name: 'Zebra Cafe',
|
||||
status: 'idea',
|
||||
lat: 35.66,
|
||||
lng: 139.7,
|
||||
category_id: 7,
|
||||
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
|
||||
const categories = [
|
||||
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
|
||||
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||
];
|
||||
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
|
||||
fireEvent.click(await screen.findByText('Zebra Cafe'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
|
||||
});
|
||||
});
|
||||
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
/**
|
||||
* The category a picked place already carries, as the form's value, when this server
|
||||
* has it. A place saved in a collection keeps its category_id, and categories are the
|
||||
* server's own, so it fits the trip as it stands. Guessing from the place's kind
|
||||
* instead dropped it (#3074).
|
||||
*/
|
||||
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
|
||||
const id = result.category_id
|
||||
if (typeof id !== 'number') return null
|
||||
return categories.some(c => c.id === id) ? String(id) : null
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
||||
prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
|
||||
} from './PlaceFormModal.helpers'
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// the next pick may replace it and a hand choice is never overwritten (#2282).
|
||||
const categoryGuessedRef = useRef(false)
|
||||
const guessedCategory = (result: Record<string, unknown>): string => {
|
||||
const id = guessCategoryId(result, categories || [])
|
||||
const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
|
||||
const id = saved ? null : guessCategoryId(result, categories || [])
|
||||
categoryGuessedRef.current = id != null
|
||||
return id != null ? String(id) : ''
|
||||
return saved ?? (id != null ? String(id) : '')
|
||||
}
|
||||
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
||||
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-109
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-110
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1829,4 +1829,13 @@ describe('ReservationModal', () => {
|
||||
expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-110: a refresh of the trip while the dialog is open keeps what the user typed (#3069)', async () => {
|
||||
const { rerender } = render(<ReservationModal {...defaultProps} />);
|
||||
const title = screen.getByPlaceholderText(/e\.g\. Lufthansa/i) as HTMLInputElement;
|
||||
await userEvent.type(title, 'Le Jules Verne');
|
||||
// A socket reconnect re-reads the trip: days, places and stays arrive as new arrays.
|
||||
rerender(<ReservationModal {...defaultProps} days={[]} places={[]} accommodations={[]} />);
|
||||
expect(title).toHaveValue('Le Jules Verne');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,8 +132,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setForm({ ...EMPTY_FORM, assignment_id: defaultAssignmentId ?? '' })
|
||||
setPendingFiles([])
|
||||
}
|
||||
// Keyed only on what opens a different form. Days, places and stays are read
|
||||
// when the form is seeded; a refresh of the trip while the dialog is open (a
|
||||
// socket reconnect re-reads it) hands in new arrays of the same data and must
|
||||
// not reseed it and wipe what the user is typing (#3069).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
|
||||
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId])
|
||||
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-083
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-085
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
buildAssignment,
|
||||
buildReservation,
|
||||
buildTripFile,
|
||||
buildBudgetItem,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { TransportModal } from './TransportModal';
|
||||
import type { Day, Reservation } from '../../types';
|
||||
@@ -1725,4 +1726,22 @@ describe('TransportModal', () => {
|
||||
const legs = onSave.mock.calls[0][0].metadata.legs as { confirmation_number?: string }[];
|
||||
expect(legs.map(l => l.confirmation_number ?? null)).toEqual([null, 'SEG-TWO']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-084: a store refresh while the dialog is open keeps what the user typed (#3069)', async () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
const title = screen.getByPlaceholderText(/e\.g\. Lufthansa/i) as HTMLInputElement;
|
||||
await userEvent.type(title, 'LH 400');
|
||||
// A socket reconnect re-reads the trip: the costs arrive as a new array.
|
||||
act(() => { useTripStore.setState({ budgetItems: [buildBudgetItem()] }); });
|
||||
expect(title).toHaveValue('LH 400');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-085: opening a different transport still fills the form with it', () => {
|
||||
const first = buildReservation({ title: 'First flight', type: 'flight' });
|
||||
const second = buildReservation({ title: 'Second flight', type: 'flight' });
|
||||
const { rerender } = render(<TransportModal {...defaultProps} reservation={first} />);
|
||||
expect(screen.getByDisplayValue('First flight')).toBeInTheDocument();
|
||||
rerender(<TransportModal {...defaultProps} reservation={second} />);
|
||||
expect(screen.getByDisplayValue('Second flight')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_seat: meta.seat || '',
|
||||
}),
|
||||
})
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
// Keyed only on what opens a different form. A store refresh while the dialog
|
||||
// is open (a socket reconnect re-reads the trip, a fellow traveller's cost) must
|
||||
// not reseed it and wipe what the user is typing (#3069).
|
||||
}, [isOpen, reservation, prefill, selectedDayId])
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-022
|
||||
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-024
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildDay, buildReservation } from '../../../../tests/helpers/factories';
|
||||
import type { ReservationEndpoint } from '../../../types';
|
||||
@@ -83,6 +83,26 @@ describe('bookingFacts: day', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingFacts: hotel times', () => {
|
||||
it('FE-PLANNER-BKFACTS-023: a hotel without times of its own shows the stay check-in and check-out', () => {
|
||||
const f = bookingFacts(buildReservation({
|
||||
type: 'hotel', accommodation_start_day_id: 701, accommodation_end_day_id: 702,
|
||||
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
|
||||
}), ctx());
|
||||
expect(cell(f, 'reservations.meta.checkIn')).toBe('15:00 → 18:00');
|
||||
expect(cell(f, 'reservations.meta.checkOut')).toBe('11:00');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BKFACTS-024: the booking own times win over the stay times', () => {
|
||||
const f = bookingFacts(buildReservation({
|
||||
type: 'hotel', metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
|
||||
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
|
||||
}), ctx());
|
||||
expect(cell(f, 'reservations.meta.checkIn')).toBe('14:00');
|
||||
expect(cell(f, 'reservations.meta.checkOut')).toBe('10:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingFacts: time', () => {
|
||||
it('FE-PLANNER-BKFACTS-008: start and end time with an arrow', () => {
|
||||
const f = bookingFacts(buildReservation({ reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T11:30' }), ctx());
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Day, Reservation, ReservationEndpoint } from '../../../types'
|
||||
import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../utils/flightLegs'
|
||||
import { safeExternalHref } from '../../../utils/safeUrl'
|
||||
import { daySpan, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
|
||||
import { daySpan, hotelStayTimes, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
|
||||
|
||||
export interface FactsContext {
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
@@ -105,10 +105,11 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
if (!c.hasLinkedCost && meta.price != null && meta.price !== '') {
|
||||
cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) })
|
||||
}
|
||||
if (meta.check_in_time) {
|
||||
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` → ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
const stay = hotelStayTimes(r, meta)
|
||||
if (stay.checkIn) {
|
||||
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(stay.checkIn, locale, timeFormat) + (stay.checkInEnd ? ` → ${formatTime(stay.checkInEnd, locale, timeFormat)}` : '') })
|
||||
}
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (stay.checkOut) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
|
||||
|
||||
// Per-segment codes (#1943), only on a real stopover booking.
|
||||
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : []
|
||||
|
||||
@@ -103,6 +103,27 @@ export interface DaySpan {
|
||||
}
|
||||
|
||||
/** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */
|
||||
/**
|
||||
* A hotel's check-in window and check-out: the booking's own metadata first,
|
||||
* then the stay it is linked to. Times often live only on the stay, and the
|
||||
* edit form already falls back the same way (reservationFormModel.ts).
|
||||
*/
|
||||
export function hotelStayTimes(
|
||||
r: Reservation,
|
||||
meta: { check_in_time?: string | null; check_in_end_time?: string | null; check_out_time?: string | null },
|
||||
): { checkIn: string | null; checkInEnd: string | null; checkOut: string | null } {
|
||||
if (r.type !== 'hotel') {
|
||||
return { checkIn: meta.check_in_time || null, checkInEnd: meta.check_in_end_time || null, checkOut: meta.check_out_time || null }
|
||||
}
|
||||
const checkIn = meta.check_in_time || r.accommodation_check_in || null
|
||||
return {
|
||||
checkIn,
|
||||
// A window end belongs to the check-in it came with.
|
||||
checkInEnd: meta.check_in_time ? meta.check_in_end_time || null : checkIn ? r.accommodation_check_in_end || null : null,
|
||||
checkOut: meta.check_out_time || r.accommodation_check_out || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function daySpan(r: Reservation, days: Day[]): DaySpan {
|
||||
const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined)
|
||||
if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSPORTEP-001 to -021: the transport booking form both the desktop
|
||||
// FE-PLANNER-TRANSPORTEP-001 to -022: the transport booking form both the desktop
|
||||
// TransportModal and the phone's transport sheet save through.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -499,6 +499,28 @@ describe('buildTransportPayload', () => {
|
||||
expect(payload.reservation_time).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSPORTEP-022: a flight without airports keeps its day, times and flight details on save (#3070)', () => {
|
||||
const payload = buildTransportPayload(
|
||||
draft({
|
||||
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Fly', type: 'flight', notes: 'only the notes changed' },
|
||||
waypoints: [
|
||||
{ ...emptyWaypoint(1), depTime: '09:40', airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' },
|
||||
{ ...emptyWaypoint(2), arrTime: '11:30' },
|
||||
],
|
||||
}),
|
||||
days,
|
||||
DESKTOP_SAVE
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
day_id: 1,
|
||||
end_day_id: 2,
|
||||
reservation_time: '2026-05-01T09:40',
|
||||
reservation_end_time: '2026-05-02T11:30',
|
||||
endpoints: [],
|
||||
});
|
||||
expect(payload.metadata).toEqual({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSPORTEP-017: the desktop anchors a train on its raw rows, the phone on the picked stations', () => {
|
||||
const station = (name: string) => ({ name, lat: 1, lng: 1 });
|
||||
const trainWaypoints = [
|
||||
|
||||
@@ -548,8 +548,12 @@ export function buildTransportPayload(
|
||||
const dayDate = (id: string | number): string | null => days.find((d) => d.id === Number(id))?.date ?? null;
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter((w) => w.airport) : [];
|
||||
const firstWp = flightWps[0];
|
||||
const lastWp = flightWps[flightWps.length - 1];
|
||||
// A flight without a single airport (one created over MCP with a day and times
|
||||
// but no endpoints) takes its day, times, airline and seat from the raw rows, as
|
||||
// a train without stations does; otherwise a save wiped them (#3070).
|
||||
const flightAnchors = flightWps.length > 0 || form.type !== 'flight' ? flightWps : waypoints;
|
||||
const firstWp = flightAnchors[0];
|
||||
const lastWp = flightAnchors[flightAnchors.length - 1];
|
||||
// Only geocoded stations become map endpoints and legs.
|
||||
const trainWps = stationRoute ? trainWaypoints : [];
|
||||
const trainStations = trainWps.filter((w) => w.location);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||
|
||||
/** A stop's or a day's id, as the day plan's drag handlers pass them around. */
|
||||
type DragKey = string | number | null;
|
||||
|
||||
/**
|
||||
* The drag state of the day plan (DayPlanSidebar): what is being dragged, which
|
||||
* row the drop line stands on, which day the drag is over, and whether a drag
|
||||
* happened yet.
|
||||
*
|
||||
* The day list fades in once, on mount. Switching its stagger back on when a
|
||||
* drag ended played that entrance again on every drop, so the first drag turns
|
||||
* it off for good (#3041).
|
||||
*
|
||||
* A drag whose source row re-rendered away never sends its dragend to the
|
||||
* document, and the drop line then stayed on a row until the next reload. The
|
||||
* browser sends no mousemove during a drag, so the first plain mouse move or
|
||||
* press after one means it is over. Plain means no button held: Firefox fires
|
||||
* dragstart while it handles the move that starts the drag and hands that move
|
||||
* to the page afterwards, button still down. Read as the end, it threw the drag
|
||||
* away at pick-up and left only the arrows to reorder a day (#3041).
|
||||
*/
|
||||
export function useDayPlanDragState(dragDataRef: MutableRefObject<unknown>) {
|
||||
const [draggingId, setDraggingIdState] = useState<DragKey>(null);
|
||||
const [hasDragged, setHasDragged] = useState(false);
|
||||
const [dropTargetKey, setDropTargetKeyState] = useState<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
const [dragOverDayId, setDragOverDayId] = useState<DragKey>(null);
|
||||
|
||||
// Stable, so the sidebar's listeners that call them are set up once.
|
||||
const setDraggingId = useCallback((id: DragKey) => {
|
||||
if (id != null) setHasDragged(true);
|
||||
setDraggingIdState(id);
|
||||
}, []);
|
||||
const setDropTargetKey = useCallback((key: string | null) => {
|
||||
dropTargetRef.current = key;
|
||||
setDropTargetKeyState(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingId && !dropTargetKey && !dragOverDayId) return;
|
||||
const settle = (e: MouseEvent | PointerEvent) => {
|
||||
if (e.type === 'mousemove' && e.buttons !== 0) return;
|
||||
setDraggingIdState(null);
|
||||
dropTargetRef.current = null;
|
||||
setDropTargetKeyState(null);
|
||||
setDragOverDayId(null);
|
||||
dragDataRef.current = null;
|
||||
window.__dragData = null;
|
||||
};
|
||||
document.addEventListener('mousemove', settle);
|
||||
document.addEventListener('pointerdown', settle, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', settle);
|
||||
document.removeEventListener('pointerdown', settle, true);
|
||||
};
|
||||
}, [draggingId, dropTargetKey, dragOverDayId, dragDataRef]);
|
||||
|
||||
return {
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
hasDragged,
|
||||
dropTargetKey,
|
||||
dropTargetRef,
|
||||
setDropTargetKey,
|
||||
dragOverDayId,
|
||||
setDragOverDayId,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
Luggage, Backpack, Zap, Croissant, ShoppingBasket,
|
||||
LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -22,7 +22,7 @@ export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
Luggage, Backpack, Zap, Croissant, ShoppingBasket,
|
||||
}
|
||||
|
||||
export const ICON_LABELS: Record<string, string> = {
|
||||
@@ -37,6 +37,7 @@ export const ICON_LABELS: Record<string, string> = {
|
||||
Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
|
||||
Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
||||
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
|
||||
Croissant: 'Bakery', ShoppingBasket: 'Groceries',
|
||||
}
|
||||
|
||||
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { pagesFor } from './dayPagerPages';
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[];
|
||||
const LONG = Array.from({ length: 10 }, (_, k) => ({ id: k + 1 })) as unknown as Day[];
|
||||
|
||||
function setup(selectedDayId: number | null, over: Partial<Parameters<typeof MDayPager>[0]> = {}) {
|
||||
const onSelectDay = vi.fn();
|
||||
@@ -39,19 +40,37 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('pagesFor', () => {
|
||||
it('windows the selection with two days on either side, room for a second swipe', () => {
|
||||
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active])).toEqual([
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, true],
|
||||
[4, false],
|
||||
[5, false],
|
||||
it('has a page for every day and draws the selection with two days on either side', () => {
|
||||
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active, p.near])).toEqual([
|
||||
[1, false, true],
|
||||
[2, false, true],
|
||||
[3, true, true],
|
||||
[4, false, true],
|
||||
[5, false, true],
|
||||
[6, false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no page beyond the first and last day', () => {
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2, 3]);
|
||||
expect(pagesFor(DAYS, 6).map((p) => p.dayId)).toEqual([4, 5, 6]);
|
||||
it('keeps the same pages whichever day is selected, so a run of swipes never meets an end', () => {
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.key)).toEqual(pagesFor(DAYS, 6).map((p) => p.key));
|
||||
expect(
|
||||
pagesFor(DAYS, 6)
|
||||
.filter((p) => p.near)
|
||||
.map((p) => p.dayId)
|
||||
).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it('also draws the days around the page a swipe heads for, so a run of swipes never meets blank pages', () => {
|
||||
const drawn = (focusDayId: number | null) =>
|
||||
pagesFor(LONG, 1, focusDayId)
|
||||
.filter((p) => p.near)
|
||||
.map((p) => p.dayId);
|
||||
expect(drawn(6)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
// Still only the selection's own window where nothing is previewed or the preview is gone.
|
||||
expect(drawn(null)).toEqual([1, 2, 3]);
|
||||
expect(drawn(99)).toEqual([1, 2, 3]);
|
||||
// The focus never changes which page is current.
|
||||
expect(pagesFor(LONG, 1, 6).find((p) => p.active)?.dayId).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a page for a selection that points at no day, with the first day as the way back', () => {
|
||||
@@ -69,7 +88,9 @@ describe('MDayPager', () => {
|
||||
|
||||
expect(screen.getByText('page 2 live')).toBeInTheDocument();
|
||||
const pages = Array.from(strip.children) as HTMLElement[];
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true]);
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true, true, true]);
|
||||
// Day 6 is three away: its page is there to swipe onto, its content is not drawn yet.
|
||||
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
|
||||
expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(pages[1]).not.toHaveAttribute('aria-hidden');
|
||||
});
|
||||
@@ -122,6 +143,19 @@ describe('MDayPager', () => {
|
||||
expect(onPreview).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('draws the days ahead of a swipe while the strip still moves, before the selection lands', () => {
|
||||
const { strip, onSelectDay } = setup(1);
|
||||
expect(screen.queryByText('page 5')).not.toBeInTheDocument();
|
||||
|
||||
place(strip, 3, true);
|
||||
fireEvent.scroll(strip);
|
||||
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('page 1 live')).toBeInTheDocument();
|
||||
expect(screen.getByText('page 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks once, however many events report the same landing', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
@@ -166,7 +200,7 @@ describe('MDayPager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('puts the strip back on the current page when the window moves', () => {
|
||||
it('puts the strip on the current page when the selection moves', () => {
|
||||
const { strip, rerender, onSelectDay } = setup(2);
|
||||
// jsdom keeps no scroll offset, so give the strip one to move.
|
||||
let scrollLeft = 0;
|
||||
@@ -199,6 +233,39 @@ describe('MDayPager', () => {
|
||||
expect(strip.scrollLeft).toBe(390);
|
||||
});
|
||||
|
||||
it('moves the strip to a day tapped inside the same window, as on a two-day trip', () => {
|
||||
const twoDays = DAYS.slice(0, 2);
|
||||
const { strip, rerender, onSelectDay } = setup(1, { days: twoDays });
|
||||
let scrollLeft = 0;
|
||||
Object.defineProperty(strip, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (v) => {
|
||||
scrollLeft = v;
|
||||
},
|
||||
});
|
||||
// Both days are in the window either way; only the current page changes.
|
||||
const realQuery = strip.querySelector.bind(strip);
|
||||
vi.spyOn(strip, 'querySelector').mockImplementation((sel: string) => {
|
||||
const el = realQuery(sel) as HTMLElement | null;
|
||||
if (el && sel === '[data-page-active]') el.getBoundingClientRect = () => ({ left: 390 }) as DOMRect;
|
||||
return el;
|
||||
});
|
||||
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
|
||||
rerender(
|
||||
<MDayPager
|
||||
days={twoDays}
|
||||
selectedDayId={2}
|
||||
onSelectDay={onSelectDay}
|
||||
describeDay={() => ''}
|
||||
renderDay={(dayId) => <p>{`page ${dayId}`}</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(strip.scrollLeft).toBe(390);
|
||||
});
|
||||
|
||||
it('holds still while locked', () => {
|
||||
const { strip } = setup(2, { locked: true });
|
||||
expect(strip).toHaveClass('overflow-x-hidden');
|
||||
|
||||
@@ -4,11 +4,11 @@ import { tickHaptic } from '../../native/haptics';
|
||||
import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
|
||||
/**
|
||||
* Pages through the trip's days the way a native pager does: the current day
|
||||
* and two on either side sit side by side in a horizontal scroll-snap strip
|
||||
* (dayPagerPages.ts), so the finger drags real paper, the neighbour slides in
|
||||
* with it, and a second swipe made while the first still snaps carries on to
|
||||
* the day after. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
|
||||
* Pages through the trip's days the way a native pager does: every day is a
|
||||
* page of a horizontal scroll-snap strip (dayPagerPages.ts), the current one
|
||||
* and two on either side drawn, so the finger drags real paper, the neighbour
|
||||
* slides in with it, and swipes made while the last one still snaps carry on
|
||||
* as far as the trip goes. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
|
||||
* and last day, axis locking against the vertical scroll and the swallowed tap
|
||||
* after a swipe are all the platform's own, in the PWA and the app alike.
|
||||
*
|
||||
@@ -16,8 +16,10 @@ import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
* the swipe is decided, not once the snap has settled, which on iOS comes
|
||||
* a good while later. The day only changes once the strip has come to rest on
|
||||
* another page. Then
|
||||
* the selection moves, the window of three shifts by one and the strip is put
|
||||
* back on the centre page before the next paint, so nothing visibly jumps.
|
||||
* the selection moves and the days around it draw their content. The days
|
||||
* around the page a swipe is heading for draw theirs as soon as it becomes the
|
||||
* nearer one, so a run of quick swipes that never rests finds them drawn
|
||||
* instead of blank until the snap lands (#3067).
|
||||
* Keys follow the day ids, so the page that slid in keeps its state (scroll
|
||||
* position, fetched route and weather) as it becomes the current one.
|
||||
*
|
||||
@@ -70,8 +72,14 @@ export default function MDayPager({
|
||||
}: MDayPagerProps) {
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const pages = pagesFor(days, selectedDayId);
|
||||
const windowKey = pages.map((p) => p.key).join(',');
|
||||
/** The page a swipe last headed for. It stays after the landing, so swiping
|
||||
* back over the same days finds them drawn too. */
|
||||
const [focusDayId, setFocusDayId] = useState<number | null>(null);
|
||||
const pages = pagesFor(days, selectedDayId, focusDayId);
|
||||
// Which page is current belongs in the key: on a trip of up to five days a
|
||||
// chip tap can keep the same pages, and the strip then stayed on the old day
|
||||
// while the rail already showed the new one.
|
||||
const windowKey = pages.map((p) => (p.active ? `*${p.key}` : p.key)).join(',');
|
||||
|
||||
// The handlers below are attached once, so they read the props through here.
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
|
||||
@@ -91,7 +99,7 @@ export default function MDayPager({
|
||||
if (Math.abs(offset) > 0.5) strip.scrollLeft += offset;
|
||||
}, []);
|
||||
|
||||
// Before paint whenever the window moves: a landed swipe or a chip tap.
|
||||
// Before paint whenever the window or its current page moves: a landed swipe or a chip tap.
|
||||
useLayoutEffect(() => {
|
||||
requested.current = null;
|
||||
recentre();
|
||||
@@ -133,7 +141,11 @@ export default function MDayPager({
|
||||
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
|
||||
if (next === previewed) return;
|
||||
previewed = next;
|
||||
if (next !== null) tickHaptic();
|
||||
if (next !== null) {
|
||||
// Draw ahead of the finger: the days around this one, before it lands.
|
||||
setFocusDayId(next);
|
||||
tickHaptic();
|
||||
}
|
||||
latest.current.onPreview?.(next);
|
||||
};
|
||||
|
||||
@@ -212,7 +224,7 @@ export default function MDayPager({
|
||||
aria-hidden={page.active ? undefined : true}
|
||||
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
|
||||
>
|
||||
{renderDay(page.dayId, page.active)}
|
||||
{page.near ? renderDay(page.dayId, page.active) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,12 @@ describe('MPushPrompt', () => {
|
||||
it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
|
||||
// Asked once answered, not once shown.
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
|
||||
expect(env.toggle).toHaveBeenCalledTimes(1);
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
|
||||
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -77,6 +79,18 @@ describe('MPushPrompt', () => {
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-PUSHPROMPT-005: a prompt left unanswered asks again on the next launch', async () => {
|
||||
const first = render(<MPushPrompt />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
|
||||
});
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||
await renderAndWait();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BellRing } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useWebPush } from '../../hooks/useWebPush';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { nativePushActive, nativePushEndpoint } from '../../native/push';
|
||||
import { pushChannelOffered } from '../../push/webPush';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -36,9 +37,14 @@ function rememberAsked(): void {
|
||||
* server offers push, and only while this device is not signed up yet. Turning
|
||||
* it on goes the way the settings' push card goes (useWebPush), which brings up
|
||||
* the system's question.
|
||||
*
|
||||
* It counts as asked once the user answers it, not when it opens: a sheet that
|
||||
* went under another one, or an app closed while it was up, asks again. And it
|
||||
* waits for the app's capabilities, which can arrive after the sign-in.
|
||||
*/
|
||||
export default function MPushPrompt(): React.ReactElement | null {
|
||||
const signedIn = useAuthStore((s) => s.isAuthenticated);
|
||||
const { caps } = useNativeCapabilities();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,8 +52,7 @@ export default function MPushPrompt(): React.ReactElement | null {
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void pushChannelOffered().then((offered) => {
|
||||
if (cancelled || !offered || nativePushEndpoint()) return;
|
||||
rememberAsked();
|
||||
if (cancelled || !offered || nativePushEndpoint() || alreadyAsked()) return;
|
||||
setOpen(true);
|
||||
});
|
||||
}, PUSH_PROMPT_DELAY_MS);
|
||||
@@ -55,9 +60,13 @@ export default function MPushPrompt(): React.ReactElement | null {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [signedIn]);
|
||||
}, [signedIn, caps]);
|
||||
|
||||
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null;
|
||||
const answered = () => {
|
||||
rememberAsked();
|
||||
setOpen(false);
|
||||
};
|
||||
return open ? <PushPromptSheet onClose={answered} /> : null;
|
||||
}
|
||||
|
||||
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface MSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* The heading the section stands under. Consecutive sections with the same
|
||||
* group share one heading; a screen whose sections name none shows none.
|
||||
*/
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface MSectionRowProps {
|
||||
@@ -33,6 +38,50 @@ export function MSectionRow({ section, active, current = false, onPick }: MSecti
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections in order, each run of one group under its heading: the same list
|
||||
* in the dropdown of a phone and in the list beside the page.
|
||||
*/
|
||||
export function MSectionList({
|
||||
sections,
|
||||
activeId,
|
||||
markCurrent = false,
|
||||
onPick,
|
||||
}: {
|
||||
sections: MSection[];
|
||||
activeId: string;
|
||||
/** Marks the active row as the page on show (the list beside the page). */
|
||||
markCurrent?: boolean;
|
||||
onPick: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{sections.map((section, i) => {
|
||||
const heading = section.group && section.group !== sections[i - 1]?.group ? section.group : null;
|
||||
return (
|
||||
<div key={section.id} role="presentation">
|
||||
{heading && (
|
||||
<div
|
||||
className={`px-[10px] pb-1 font-geist text-caption font-bold uppercase tracking-[.09em] text-m-faint ${
|
||||
i === 0 ? 'pt-1' : 'pt-3'
|
||||
}`}
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<MSectionRow
|
||||
section={section}
|
||||
active={section.id === activeId}
|
||||
current={markCurrent && section.id === activeId}
|
||||
onPick={() => onPick(section.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame of a settings-like screen (settings, administration), one rule for
|
||||
* both: the page column on a phone, a centred column on a wide window, and from
|
||||
@@ -88,15 +137,7 @@ export default function MSectionNav({
|
||||
<span className="min-w-0 flex-1 truncate text-subtitle font-bold text-m-ink">{title}</span>
|
||||
</div>
|
||||
{action && <div className="mb-1 px-[4px]">{action}</div>}
|
||||
{sections.map((section) => (
|
||||
<MSectionRow
|
||||
key={section.id}
|
||||
section={section}
|
||||
active={section.id === activeId}
|
||||
current={section.id === activeId}
|
||||
onPick={() => onPick(section.id)}
|
||||
/>
|
||||
))}
|
||||
<MSectionList sections={sections} activeId={activeId} markCurrent onPick={onPick} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ const SHORT_FORM_SHAPE = 'm-form-sheet short:flex-1 short:border-b-0 short:[--m-
|
||||
// behind read through as sharp ghost text: the frosting it counted on is
|
||||
// dropped whenever an ancestor animates its opacity (the scrim fading in),
|
||||
// which WebKit and Chromium both do, so the fields floated over the list behind.
|
||||
// For the same reason the dim fades on a layer of its own, beside the sheet and
|
||||
// not around it: opacity multiplies down the tree, so a fading parent made even
|
||||
// the opaque sheet see-through for its first frames.
|
||||
const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]'
|
||||
|
||||
// The docked sheet rises from below the screen edge like a native sheet; the
|
||||
@@ -283,12 +286,17 @@ export default function MSheet({
|
||||
// has to fall back to document.body (sheet mounted in the same commit as
|
||||
// the shell).
|
||||
<div
|
||||
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
|
||||
className="m-root fixed inset-0 z-[60]"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) requestClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-m-scrim=""
|
||||
className={`pointer-events-none absolute inset-0 ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
|
||||
/>
|
||||
<div className={regionClass(variant, wideAnchor, form)}>
|
||||
<div className={boxClass(variant, wideWidth, form)}>
|
||||
<PopoverArrow variant={variant} anchor={wideAnchor} />
|
||||
|
||||
@@ -35,8 +35,8 @@ function setup() {
|
||||
describe('MSwipeRow', () => {
|
||||
it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
expect(screen.queryByText('Done')).toBeNull();
|
||||
expect(screen.queryByLabelText('Delete')).toBeNull();
|
||||
expect(screen.queryByLabelText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
|
||||
@@ -45,7 +45,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchmove', 160);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
fireEvent.click(screen.getByLabelText('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchstart', 200, 100);
|
||||
touch(row, 'touchmove', 190, 160);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
expect(screen.queryByLabelText('Delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
|
||||
@@ -72,7 +72,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchstart', 10);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Done')).toBeNull();
|
||||
expect(screen.queryByLabelText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
|
||||
|
||||
@@ -34,10 +34,18 @@ interface MSwipeRowProps {
|
||||
className?: string;
|
||||
/** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */
|
||||
surface?: string;
|
||||
/**
|
||||
* How far the buttons sit in from the row's top and bottom edge. A row that is a
|
||||
* card of its own leaves it at 0, so the buttons are as tall as the card; a row
|
||||
* inside a grouped card insets them, so they do not touch its dividers.
|
||||
*/
|
||||
inset?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BUTTON_W = 76;
|
||||
/** One button, and the gap before it: the buttons are cards of their own beside the row. */
|
||||
const BUTTON_W = 70;
|
||||
const GAP = 6;
|
||||
const EDGE_PX = 24;
|
||||
const CLAIM_PX = 10;
|
||||
const AXIS_BIAS = 1.5;
|
||||
@@ -59,6 +67,7 @@ export default function MSwipeRow({
|
||||
disabled = false,
|
||||
className = '',
|
||||
surface = 'bg-[color:var(--m-card)]',
|
||||
inset = 0,
|
||||
children,
|
||||
}: MSwipeRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
@@ -91,7 +100,11 @@ export default function MSwipeRow({
|
||||
// Positive offset reveals the leading side in LTR; RTL mirrors it.
|
||||
const leadSign = () => (rtl() ? -1 : 1);
|
||||
const openWidth = (which: 'leading' | 'trailing') =>
|
||||
which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W;
|
||||
which === 'leading'
|
||||
? latest.current.leading
|
||||
? BUTTON_W + GAP
|
||||
: 0
|
||||
: latest.current.trailing.length * (BUTTON_W + GAP);
|
||||
|
||||
const close = () => {
|
||||
place(0, true);
|
||||
@@ -194,17 +207,18 @@ export default function MSwipeRow({
|
||||
action.onAction();
|
||||
};
|
||||
const shown = open ?? side;
|
||||
// The icon alone, as in Mail; the label names the button for a screen reader.
|
||||
const button = (action: SwipeAction) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
data-swipe-action=""
|
||||
tabIndex={open ? 0 : -1}
|
||||
aria-label={action.label}
|
||||
onClick={() => run(action)}
|
||||
className={`flex h-full w-[76px] flex-none flex-col items-center justify-center gap-1 text-caption font-semibold ${TONE[action.tone]}`}
|
||||
className={`flex h-full w-[70px] flex-none items-center justify-center ${inset ? 'rounded-xl' : 'rounded-2xl'} ${TONE[action.tone]}`}
|
||||
>
|
||||
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
{action.label}
|
||||
<action.icon size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -213,7 +227,8 @@ export default function MSwipeRow({
|
||||
{shown && (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`}
|
||||
className={`absolute inset-x-0 flex gap-[6px] ${shown === 'leading' ? 'justify-start pe-[6px]' : 'justify-end ps-[6px]'}`}
|
||||
style={{ top: inset, bottom: inset }}
|
||||
>
|
||||
{shown === 'leading' ? leading && button(leading) : trailing.map(button)}
|
||||
</div>
|
||||
|
||||
@@ -7,29 +7,37 @@ interface Page {
|
||||
key: string;
|
||||
dayId: number | null;
|
||||
active: boolean;
|
||||
/** Drawn with its content; the pages further out are empty paper of the same width. */
|
||||
near: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages kept either side of the selection. Two, not one: a second swipe that
|
||||
* starts while the first is still snapping has to find a page beyond the
|
||||
* neighbour, or it runs into the end of the strip and springs back instead of
|
||||
* reaching the day after.
|
||||
*/
|
||||
/** Where a day stands in the strip, -1 for none. */
|
||||
const indexOfDay = (days: PagerItem[], id: number | null): number =>
|
||||
id == null ? -1 : days.findIndex((d) => d.id === id);
|
||||
|
||||
/** Pages drawn with their content either side of the selection, and of the page a swipe heads for. */
|
||||
const REACH = 2;
|
||||
|
||||
/** The window of pages around the selection. A selection that points at no
|
||||
* day (the whole-trip view, or a day deleted remotely) still gets its page,
|
||||
* with the first day next to it as a way back. */
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
|
||||
/**
|
||||
* A page for every day, so quick swipes in a row never run into the end of the
|
||||
* strip before the first has settled (they used to spring back after two days).
|
||||
* Only the selection and REACH days either side draw their content, and as
|
||||
* many either side of `focusDayId`, the page a swipe is heading for: a run of
|
||||
* quick swipes that has not landed yet then meets drawn pages instead of blank
|
||||
* paper (#3067). Two windows at most, however long the trip. A selection
|
||||
* that points at no day (the whole-trip view, or a day deleted remotely) still
|
||||
* gets its page, with the first day next to it as a way back.
|
||||
*/
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null, focusDayId: number | null = null): Page[] {
|
||||
const i = days.findIndex((d) => d.id === selectedDayId);
|
||||
if (i < 0) {
|
||||
const first = days[0];
|
||||
return [
|
||||
{ key: 'none', dayId: selectedDayId, active: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
{ key: 'none', dayId: selectedDayId, active: true, near: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []),
|
||||
];
|
||||
}
|
||||
return days
|
||||
.slice(Math.max(0, i - REACH), i + REACH + 1)
|
||||
.map((d) => ({ key: `d${d.id}`, dayId: d.id, active: d.id === days[i].id }));
|
||||
const f = indexOfDay(days, focusDayId);
|
||||
const near = (j: number) => Math.abs(j - i) <= REACH || (f >= 0 && Math.abs(j - f) <= REACH);
|
||||
return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: near(j) }));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,14 @@
|
||||
* 52px/26px offsets stand in for the phone status bar / home indicator, so
|
||||
* real devices derive them from the safe-area insets. */
|
||||
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
|
||||
--m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px);
|
||||
/* The safe area the iOS app measures (TrekBridgeViewController
|
||||
* publishSafeArea) is a floor under env(): where a release lays the web view
|
||||
* out below the status bar, WebKit reports 0 while a camera in a corner still
|
||||
* reaches into the page. In a browser the variables are unset: env() alone. */
|
||||
--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));
|
||||
--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));
|
||||
--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));
|
||||
--m-safe-top: calc(var(--m-safe-t) + 12px);
|
||||
/* The navigation rail's share of the leading edge (wide windows only), what
|
||||
* every full-width fixed bar and the page column start after. */
|
||||
--m-rail-w: 0px;
|
||||
@@ -75,10 +82,10 @@
|
||||
* reads the same values as the shell. */
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
|
||||
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px);
|
||||
--m-rail-w: calc(var(--m-safe-l) + 100px);
|
||||
}
|
||||
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px);
|
||||
--m-rail-w: calc(var(--m-safe-r) + 100px);
|
||||
}
|
||||
|
||||
.dark .m-root {
|
||||
@@ -123,9 +130,11 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-
|
||||
|
||||
/* ── Keyframes ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The floating card pops in place, the way a native alert does. */
|
||||
/* The floating card pops in place, the way a native alert does. It is opaque
|
||||
halfway through the pop, so the page behind never reads through its fields. */
|
||||
@keyframes m-sheet-in {
|
||||
from { opacity: 0; transform: translateY(10px) scale(.96); }
|
||||
45% { opacity: 1; }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes m-sheet-out {
|
||||
@@ -568,6 +577,17 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-
|
||||
inset-inline-start: var(--m-rail-w, 0px);
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
/* Without the rail the floating bars keep clear of the sides' safe area too:
|
||||
a camera in a corner can come as a side inset. The insets are the screen's,
|
||||
not the text direction's, so right to left takes them the other way round.
|
||||
On a wide window the rail takes the leading side and a bar may centre itself
|
||||
(mx-auto). */
|
||||
html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
|
||||
margin-inline: var(--m-safe-l, 0px) var(--m-safe-r, 0px);
|
||||
}
|
||||
html[dir='rtl']:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
|
||||
margin-inline: var(--m-safe-r, 0px) var(--m-safe-l, 0px);
|
||||
}
|
||||
/* A screen whose background is a map reaches under the rail: the map runs to
|
||||
the window's edge and the rail floats over it like the top controls do. Its
|
||||
own bars then keep to the page column with m-bar-x / m-span-x. */
|
||||
@@ -641,10 +661,10 @@ html:has(.m-shell) {
|
||||
does; the fixed bars stay where they are. The rule above is for the browser,
|
||||
where a pull at the top reloads an installed PWA, which the app cannot do.
|
||||
Not on Android, whose stretch scales the bars too (MainActivity turns it
|
||||
off), not sideways, and not on a screen with pull-to-refresh, whose pull
|
||||
WebKit's bounce would race (mobile/usePullToRefresh.ts). */
|
||||
html[data-native-ios]:not([data-pull-refresh]),
|
||||
html[data-native-ios]:not([data-pull-refresh]):has(.m-shell) {
|
||||
off), and not sideways. A screen with pull-to-refresh bounces too; its pull
|
||||
holds only the move down from the top (mobile/usePullToRefresh.ts). */
|
||||
html[data-native-ios],
|
||||
html[data-native-ios]:has(.m-shell) {
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
html:has(.m-shell) body {
|
||||
@@ -664,6 +684,15 @@ html:has(.m-shell) {
|
||||
html:has(.m-shell) [data-desktop-navbar] {
|
||||
display: none;
|
||||
}
|
||||
/* A plugin's page inside the touch shell: below the status bar, above the dock,
|
||||
after the rail, and exactly the visible height (100dvh), so the plugin's own
|
||||
document is what scrolls instead of the app stretching past the screen. */
|
||||
html:has(.m-shell) [data-plugin-page] {
|
||||
--plugin-page-h: calc(100dvh - var(--bottom-nav-h, 0px));
|
||||
box-sizing: border-box;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-inline-start: var(--m-rail-w, 0px);
|
||||
}
|
||||
html:has(.m-shell) .leaflet-control-zoom {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Puzzle,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
@@ -26,8 +27,11 @@ import MSettingsIntegrations from './MSettingsIntegrations'
|
||||
import MSettingsAccount from './MSettingsAccount'
|
||||
import MSettingsAbout from './MSettingsAbout'
|
||||
import MSettingsServer from './MSettingsServer'
|
||||
import MAppLockCard from './MAppLockCard'
|
||||
import MClientCertCard from './MClientCertCard'
|
||||
import { isNativeApp } from '../../../native/platform'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import MSectionNav, { MSectionRow, SECTION_SCREEN, type MSection } from '../../components/MSectionNav'
|
||||
import MSectionNav, { MSectionList, SECTION_SCREEN, type MSection } from '../../components/MSectionNav'
|
||||
|
||||
/**
|
||||
* Mobile settings screen: back + section-switcher pill on top, the section
|
||||
@@ -44,21 +48,33 @@ export default function MSettings() {
|
||||
// the page, the way Settings on an iPad does, instead of behind a dropdown.
|
||||
const split = useIsSplit()
|
||||
|
||||
// Two groups, so it is clear what a setting reaches: the account's settings
|
||||
// follow the user to every device; this device's stay on it (the server the
|
||||
// app opens, the app lock, a client certificate, the offline copy).
|
||||
const accountGroup = t('settings.groups.account')
|
||||
const deviceGroup = t('settings.groups.device')
|
||||
const app = isNativeApp()
|
||||
const tabs: MSection[] = [
|
||||
{ id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal },
|
||||
{ id: 'appearance', label: t('settings.tabs.appearance'), icon: Palette },
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []),
|
||||
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User },
|
||||
{ id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal, group: accountGroup },
|
||||
{ id: 'appearance', label: t('settings.tabs.appearance'), icon: Palette, group: accountGroup },
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map, group: accountGroup },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell, group: accountGroup },
|
||||
...(hasIntegrations
|
||||
? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug, group: accountGroup }]
|
||||
: []),
|
||||
...(hasPlugins && !managed
|
||||
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle, group: accountGroup }]
|
||||
: []),
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User, group: accountGroup },
|
||||
...(app ? [{ id: 'device', label: t('settings.tabs.device'), icon: Smartphone, group: deviceGroup }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff, group: deviceGroup },
|
||||
// Same call as the desktop page: About is about the project — what TREK is,
|
||||
// where to file a bug, where to support it — and a customer of a hosted
|
||||
// instance is the audience for none of it. The version and the source link
|
||||
// move to the footer below, because AGPL §13 wants those offered wherever
|
||||
// people reach the software over a network.
|
||||
...(appVersion && !managed ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }] : []),
|
||||
// people reach the software over a network. Under the product's name, not
|
||||
// under either group.
|
||||
...(appVersion && !managed ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info, group: 'TREK' }] : []),
|
||||
]
|
||||
|
||||
const active = tabs.find((tab) => tab.id === activeTab) || tabs[0]
|
||||
@@ -105,17 +121,14 @@ export default function MSettings() {
|
||||
{/* Section dropdown, in flow (pushes the content down like the demo) */}
|
||||
{dropOpen && !split && (
|
||||
<div className="m-pop-in -mt-[6px] mb-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
|
||||
{tabs.map((tab) => (
|
||||
<MSectionRow
|
||||
key={tab.id}
|
||||
section={tab}
|
||||
active={tab.id === active.id}
|
||||
onPick={() => {
|
||||
setActiveTab(tab.id)
|
||||
setDropOpen(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<MSectionList
|
||||
sections={tabs}
|
||||
activeId={active.id}
|
||||
onPick={(id) => {
|
||||
setActiveTab(id)
|
||||
setDropOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,8 +139,15 @@ export default function MSettings() {
|
||||
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
|
||||
{/* Per-plugin settings still reuse the existing responsive tab. */}
|
||||
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
|
||||
{/* Each card hides itself where it has nothing to offer; their own margins give way to one gap. */}
|
||||
{active.id === 'device' && app && (
|
||||
<div className="flex flex-col gap-3 [&>*]:m-0">
|
||||
<MSettingsServer />
|
||||
<MAppLockCard />
|
||||
<MClientCertCard />
|
||||
</div>
|
||||
)}
|
||||
{active.id === 'offline' && <MSettingsOffline />}
|
||||
{active.id === 'account' && <MSettingsServer />}
|
||||
{active.id === 'account' && <MSettingsAccount />}
|
||||
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MAppLockCard from './MAppLockCard'
|
||||
import MClientCertCard from './MClientCertCard'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
@@ -181,11 +179,7 @@ export default function MSettingsAccount() {
|
||||
)}
|
||||
|
||||
{/* ── Two-factor authentication ───────────────────────────── */}
|
||||
{/* The app lock, inside the app only (Face ID, Touch ID or the passcode). */}
|
||||
<MAppLockCard />
|
||||
{/* The client certificate for a server behind mutual TLS, once one is stored. */}
|
||||
<MClientCertCard />
|
||||
|
||||
{/* The app lock and the client certificate belong to this device: MSettings, "This device". */}
|
||||
<MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="mb-3 flex gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, Ca
|
||||
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta, timeRange } from './transportsModel'
|
||||
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
|
||||
import { BOOKING_TYPE_COLOR, hotelStayTimes } from '../../../../components/Planner/bookings/bookingsModel'
|
||||
import { useReservationListFilter } from '../../../../components/Planner/bookings/useReservationListFilter'
|
||||
import { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'
|
||||
import { orderedEndpoints } from '../../../../utils/flightLegs'
|
||||
@@ -119,13 +119,18 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
|
||||
if (meta.price != null && meta.price !== '') {
|
||||
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
|
||||
}
|
||||
if (meta.check_in_time) {
|
||||
metaCells.push({
|
||||
const stay = hotelStayTimes(res, meta)
|
||||
// A hotel's times are its check-in and check-out; they come first, so a price
|
||||
// or a seat cannot push them off the card.
|
||||
const stayCells: { label: string; value: string }[] = []
|
||||
if (stay.checkIn) {
|
||||
stayCells.push({
|
||||
label: t('reservations.meta.checkIn'),
|
||||
value: timeRange(meta.check_in_time, meta.check_in_end_time ?? null, locale, timeFormat),
|
||||
value: timeRange(stay.checkIn, stay.checkInEnd, locale, timeFormat),
|
||||
})
|
||||
}
|
||||
if (meta.check_out_time) metaCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (stay.checkOut) stayCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
|
||||
metaCells.unshift(...stayCells)
|
||||
|
||||
const files = filesFor(res, planner.files || [])
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
|
||||
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow'
|
||||
import { CountPill, TabPanes } from './tabChrome'
|
||||
import { AddPaymentSheet, MemberAvatar } from './MCostsPayment'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
@@ -595,17 +594,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
const members = item.members || []
|
||||
const note = readUserNote(item)
|
||||
|
||||
// A swipe offers what the buttons beside the card do; delete still asks first.
|
||||
const swipe: SwipeAction[] = canEdit
|
||||
? [
|
||||
{ label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete },
|
||||
{ label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit },
|
||||
]
|
||||
: []
|
||||
|
||||
// No swipe here: edit and delete stand beside the card already, and a swipe
|
||||
// put a second, larger pair of the same buttons next to them.
|
||||
return (
|
||||
<MSwipeRow trailing={swipe} disabled={!canEdit} surface="bg-[color:var(--m-bg)]" className="mt-2 rounded-2xl">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
<div className="relative min-w-0 flex-1 rounded-2xl bg-m-card px-3 pb-[10px] pt-[22px]" style={{ border: `1.5px solid ${borderColor}` }}>
|
||||
<span
|
||||
className="absolute -start-[1.5px] -top-[1.5px] flex items-center gap-1 rounded-es-none rounded-ee-[12px] rounded-ss-[15px] rounded-se-none px-[11px] pb-[4px] pt-[3px] font-geist text-[0.5625rem] font-extrabold uppercase tracking-[.05em] text-white"
|
||||
@@ -688,7 +680,6 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MSwipeRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -714,6 +714,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
leading={swipeLeading}
|
||||
trailing={swipeTrailing}
|
||||
surface="bg-m-sheetop"
|
||||
inset={4}
|
||||
className="border-t border-[color:var(--m-rowbr)] first:border-t-0"
|
||||
>
|
||||
<div className="flex items-center gap-[9px] py-2">
|
||||
|
||||
@@ -26,7 +26,7 @@ interface MVacaySettingsSheetProps {
|
||||
|
||||
/**
|
||||
* Vacay settings sheet: weekend blocking (+ selectable weekend days), week
|
||||
* start, carry-over, company holidays, public-holiday calendars (nager.at —
|
||||
* start, carry-over, company holidays, public-holiday calendars (Nager.Holidays —
|
||||
* label/color/country/region, several) and dissolving the Fusion.
|
||||
*/
|
||||
/** Each setting is a card on the inner surface, as the trip settings' sections. */
|
||||
|
||||
@@ -92,4 +92,23 @@ describe('usePullToRefresh', () => {
|
||||
touch('touchend');
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds a clearly downward move at the top from its first pixel, so the bounce does not start under the pull', () => {
|
||||
setup();
|
||||
touch('touchstart', 100, 100);
|
||||
expect(touch('touchmove', 100, 103).defaultPrevented).toBe(true);
|
||||
touch('touchend');
|
||||
});
|
||||
|
||||
it('leaves the bounce everywhere else: a sideways start, and the page once it is scrolled', () => {
|
||||
setup();
|
||||
touch('touchstart', 100, 100);
|
||||
expect(touch('touchmove', 104, 102).defaultPrevented).toBe(false);
|
||||
touch('touchend');
|
||||
Object.defineProperty(window, 'scrollY', { value: 300, configurable: true });
|
||||
touch('touchstart', 100, 100);
|
||||
expect(touch('touchmove', 100, 103).defaultPrevented).toBe(false);
|
||||
touch('touchend');
|
||||
expect(document.documentElement.hasAttribute('data-pull-refresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,18 +24,13 @@ function refreshFailed(): void {}
|
||||
* React render per frame; `state` only changes at the edges (armed, refreshing).
|
||||
*/
|
||||
/*
|
||||
* The iOS app lets the page bounce at its ends (mobile.css). A pull is only told
|
||||
* from a scroll after 8 px, and by then WebKit's own bounce has started and can no
|
||||
* longer be stopped, so the two would pull at once. While a screen offers
|
||||
* pull-to-refresh, <html> carries data-pull-refresh and the page does not bounce.
|
||||
* The iOS app lets the page bounce at its ends (mobile.css), as every iOS list
|
||||
* does. Only the pull down from the very top is this hook's: WebKit starts its
|
||||
* own bounce on the first move, long before a pull is told from a scroll at 8 px,
|
||||
* so a move that clearly heads down from the top is held from the first pixel.
|
||||
* Everywhere else, the bottom of the page included, the bounce stays (TestFlight
|
||||
* feedback: the screens with pull-to-refresh used not to bounce at all).
|
||||
*/
|
||||
let bounceHolds = 0;
|
||||
function holdBounce(): void {
|
||||
if (bounceHolds++ === 0) document.documentElement.setAttribute('data-pull-refresh', '');
|
||||
}
|
||||
function releaseBounce(): void {
|
||||
if (--bounceHolds === 0) document.documentElement.removeAttribute('data-pull-refresh');
|
||||
}
|
||||
|
||||
export function usePullToRefresh(
|
||||
contentRef: RefObject<HTMLElement | null>,
|
||||
@@ -77,6 +72,8 @@ export function usePullToRefresh(
|
||||
const dx = e.touches[0].clientX - start.x;
|
||||
const dy = e.touches[0].clientY - start.y;
|
||||
if (!pulling) {
|
||||
// Clearly downward at the top: WebKit's bounce must not start under the pull.
|
||||
if (dy > 0 && dy >= 2 * Math.abs(dx) && scrolled() <= 0 && e.cancelable) e.preventDefault();
|
||||
// Decide once: a sideways or upward start belongs to whatever is under the finger.
|
||||
if (Math.abs(dy) < 8 && Math.abs(dx) < 8) return;
|
||||
if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || scrolled() > 0) {
|
||||
@@ -127,7 +124,6 @@ export function usePullToRefresh(
|
||||
});
|
||||
};
|
||||
|
||||
holdBounce();
|
||||
window.addEventListener('touchstart', onStart, { passive: true });
|
||||
// Not passive: once a pull has started, the page must not also scroll or bounce.
|
||||
window.addEventListener('touchmove', onMove, { passive: false });
|
||||
@@ -139,7 +135,6 @@ export function usePullToRefresh(
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
window.removeEventListener('touchcancel', onEnd);
|
||||
place(0, false);
|
||||
releaseBounce();
|
||||
};
|
||||
}, [contentRef, enabled, scrollerRef]);
|
||||
|
||||
|
||||
@@ -8,21 +8,28 @@ import {
|
||||
BrowserSignInCancelled,
|
||||
createHandoffSecret,
|
||||
handoffUrl,
|
||||
isHandoffReturn,
|
||||
rememberHandoffReturn,
|
||||
requestHandoffCode,
|
||||
resumeBrowserSignIn,
|
||||
signInThroughBrowser,
|
||||
signInWithPasskey,
|
||||
takeHandoffReturn,
|
||||
} from './browserSignIn';
|
||||
import { TrekShell } from './trekShell';
|
||||
|
||||
vi.mock('./trekShell', () => ({ APP_URL_SCHEME: 'com.liketrek.trek', TrekShell: { authenticate: vi.fn() } }));
|
||||
vi.mock('./trekShell', () => ({
|
||||
APP_URL_SCHEME: 'com.liketrek.trek',
|
||||
TrekShell: { authenticate: vi.fn(), takeAuthCallback: vi.fn() },
|
||||
}));
|
||||
vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() }));
|
||||
vi.mock('../api/client', () => ({ authApi: { passkey: { loginOptions: vi.fn(), loginVerify: vi.fn() } } }));
|
||||
vi.mock('../api/nativeAuth', () => ({ nativeAuthApi: { exchange: vi.fn(), handoff: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(TrekShell.authenticate).mockReset();
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockReset().mockResolvedValue({ url: null });
|
||||
localStorage.clear();
|
||||
vi.mocked(nativeAuthApi.exchange).mockReset().mockResolvedValue();
|
||||
});
|
||||
|
||||
@@ -44,10 +51,14 @@ describe('requestHandoffCode', () => {
|
||||
});
|
||||
|
||||
describe('handoffUrl', () => {
|
||||
it('points at the hand-off page with the challenge and an optional start', () => {
|
||||
expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c');
|
||||
it('starts at the server page in front of the hand-off page, with the challenge and an optional start', () => {
|
||||
// Under /api, which every TREK service worker passes to the server: an older
|
||||
// version's worker must not answer the hand-off page from its cache.
|
||||
expect(handoffUrl('https://trek.example.com', 'c')).toBe(
|
||||
'https://trek.example.com/api/auth/native/start?challenge=c'
|
||||
);
|
||||
expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe(
|
||||
'https://trek.example.com/native-handoff?challenge=c&start=oidc'
|
||||
'https://trek.example.com/api/auth/native/start?challenge=c&start=oidc'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +70,7 @@ describe('signInThroughBrowser', () => {
|
||||
await signInThroughBrowser('oidc');
|
||||
|
||||
const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0][0].url);
|
||||
expect(opened.pathname).toBe('/native-handoff');
|
||||
expect(opened.pathname).toBe('/api/auth/native/start');
|
||||
expect(opened.searchParams.get('start')).toBe('oidc');
|
||||
expect(vi.mocked(TrekShell.authenticate).mock.calls[0][0].callbackScheme).toBe('com.liketrek.trek');
|
||||
const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0];
|
||||
@@ -85,6 +96,81 @@ describe('signInThroughBrowser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeBrowserSignIn', () => {
|
||||
// The app was ended while the browser was in front: the sign-in started, then
|
||||
// the page that waited for it was gone. Only the stored verifier is left.
|
||||
const startThenLoseThePage = async () => {
|
||||
let finish: (value: { url: string }) => void = () => {};
|
||||
vi.mocked(TrekShell.authenticate).mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
void signInThroughBrowser('oidc');
|
||||
await Promise.resolve();
|
||||
const url = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0][0].url);
|
||||
return { challenge: url.searchParams.get('challenge') ?? '', finish };
|
||||
};
|
||||
|
||||
it('redeems a return that reached an app started anew, with the verifier of that sign-in, once', async () => {
|
||||
const { challenge } = await startThenLoseThePage();
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-9' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(true);
|
||||
const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0];
|
||||
expect(code).toBe('c-9');
|
||||
expect(bytesToHex(sha256(new TextEncoder().encode(verifier)))).toBe(challenge);
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-9' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(false);
|
||||
expect(nativeAuthApi.exchange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing without a kept return, and keeps the verifier for one that may still come', async () => {
|
||||
await startThenLoseThePage();
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(false);
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-2' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing on a build without takeAuthCallback', async () => {
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockRejectedValueOnce({ code: 'UNIMPLEMENTED' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(false);
|
||||
expect(nativeAuthApi.exchange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a verifier older than the code it waits for', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
await startThenLoseThePage();
|
||||
vi.setSystemTime(Date.now() + 3 * 60 * 1000);
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-3' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('a sign-in that ends leaves the verifier of a newer one alone', async () => {
|
||||
let rejectFirst: (err: unknown) => void = () => {};
|
||||
vi.mocked(TrekShell.authenticate)
|
||||
.mockReturnValueOnce(new Promise((_, reject) => (rejectFirst = reject)))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
const first = signInThroughBrowser('oidc').catch((err: unknown) => err);
|
||||
void signInThroughBrowser('oidc');
|
||||
await Promise.resolve();
|
||||
const second = new URL(vi.mocked(TrekShell.authenticate).mock.calls[1][0].url).searchParams.get('challenge');
|
||||
rejectFirst({ code: 'CANCELLED' });
|
||||
await expect(first).resolves.toBeInstanceOf(BrowserSignInCancelled);
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-5' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(true);
|
||||
const { verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0];
|
||||
expect(bytesToHex(sha256(new TextEncoder().encode(verifier)))).toBe(second);
|
||||
});
|
||||
|
||||
it('leaves nothing behind once the running app redeemed the return itself', async () => {
|
||||
const { finish } = await startThenLoseThePage();
|
||||
finish({ url: 'com.liketrek.trek://auth?code=c-4' });
|
||||
await vi.waitFor(() => expect(nativeAuthApi.exchange).toHaveBeenCalledTimes(1));
|
||||
vi.mocked(TrekShell.takeAuthCallback).mockResolvedValueOnce({ url: 'com.liketrek.trek://auth?code=c-4' });
|
||||
await expect(resumeBrowserSignIn()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the remembered handoff return', () => {
|
||||
const path = `/native-handoff?challenge=${'a'.repeat(64)}`;
|
||||
|
||||
@@ -114,6 +200,14 @@ describe('the remembered handoff return', () => {
|
||||
localStorage.setItem('trek_native_handoff_return', 'not json');
|
||||
expect(takeHandoffReturn()).toBeNull();
|
||||
});
|
||||
|
||||
it('tells a way back to the handoff page from any other stored path', () => {
|
||||
expect(isHandoffReturn(path)).toBe(true);
|
||||
expect(isHandoffReturn('/native-handoff')).toBe(false);
|
||||
expect(isHandoffReturn('/oauth/consent?client_id=foo')).toBe(false);
|
||||
expect(isHandoffReturn(null)).toBe(false);
|
||||
expect(isHandoffReturn(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInWithPasskey', () => {
|
||||
|
||||
@@ -12,15 +12,22 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell';
|
||||
* opens the server's /native-handoff page in the system browser, lets the user
|
||||
* sign in there, and carries the session back with a one-time code.
|
||||
*
|
||||
* The verifier never leaves this function. Only its sha256 travels into the
|
||||
* browser, and the code that comes back through the URL scheme is useless to
|
||||
* anyone who does not also hold the verifier. The hash is computed in JS
|
||||
* The verifier stays in the app. Only its sha256 travels into the browser, and
|
||||
* the code that comes back through the URL scheme is useless to anyone who does
|
||||
* not also hold the verifier. The hash is computed in JS
|
||||
* because crypto.subtle is missing on a plain-http server, which is how many
|
||||
* home networks reach theirs.
|
||||
*/
|
||||
|
||||
export const HANDOFF_PATH = '/native-handoff';
|
||||
|
||||
/**
|
||||
* Where the browser is sent first: a page of the server's that takes an older
|
||||
* TREK's service worker off the browser and then opens HANDOFF_PATH with the
|
||||
* same query. Under /api, which every TREK service worker passes to the server.
|
||||
*/
|
||||
const HANDOFF_START_PATH = '/api/auth/native/start';
|
||||
|
||||
/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */
|
||||
export type BrowserSignInStart = 'oidc' | 'passkey';
|
||||
|
||||
@@ -43,6 +50,11 @@ export function rememberHandoffReturn(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A path back to the handoff page itself, and nothing else a stored value could point at. */
|
||||
export function isHandoffReturn(path: unknown): path is string {
|
||||
return typeof path === 'string' && path.startsWith(`${HANDOFF_PATH}?challenge=`);
|
||||
}
|
||||
|
||||
/** The remembered handoff page, once: only a fresh path to the handoff page itself. */
|
||||
export function takeHandoffReturn(): string | null {
|
||||
try {
|
||||
@@ -50,9 +62,9 @@ export function takeHandoffReturn(): string | null {
|
||||
localStorage.removeItem(HANDOFF_RETURN_KEY);
|
||||
if (!raw) return null;
|
||||
const { path, at } = JSON.parse(raw) as { path?: unknown; at?: unknown };
|
||||
if (typeof path !== 'string' || typeof at !== 'number') return null;
|
||||
if (!isHandoffReturn(path) || typeof at !== 'number') return null;
|
||||
if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null;
|
||||
return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null;
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -65,7 +77,7 @@ export function createHandoffSecret(): { verifier: string; challenge: string } {
|
||||
}
|
||||
|
||||
export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string {
|
||||
const url = new URL(HANDOFF_PATH, origin);
|
||||
const url = new URL(HANDOFF_START_PATH, origin);
|
||||
url.searchParams.set('challenge', challenge);
|
||||
if (start) url.searchParams.set('start', start);
|
||||
if (invite) url.searchParams.set('invite', invite);
|
||||
@@ -92,9 +104,76 @@ export class BrowserSignInCancelled extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const PENDING_SIGN_IN_KEY = 'trek_native_pending_sign_in';
|
||||
// The server keeps the code a minute (NATIVE_HANDOFF_CODE_TTL_MS); two leave room for a slow start.
|
||||
const PENDING_SIGN_IN_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Android may end the app while the system browser is in front, a Samsung
|
||||
* phone short of memory readily so. The browser's return then starts a new
|
||||
* app, and the call that waited for it is gone with the verifier it held, so
|
||||
* the user came back to the sign-in page, signed in to the server but not to
|
||||
* the app. The verifier is kept in this server's storage in the app until the
|
||||
* return is redeemed, or for two minutes at most.
|
||||
*/
|
||||
function keepPendingSignIn(verifier: string): void {
|
||||
try {
|
||||
localStorage.setItem(PENDING_SIGN_IN_KEY, JSON.stringify({ verifier, at: Date.now() }));
|
||||
} catch {
|
||||
/* storage blocked: only the return to a running app can be redeemed */
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the kept verifier, if it is still this sign-in's: a newer one may have taken its place. */
|
||||
function dropPendingSignIn(verifier: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_SIGN_IN_KEY);
|
||||
if (raw && (JSON.parse(raw) as { verifier?: unknown }).verifier === verifier) {
|
||||
localStorage.removeItem(PENDING_SIGN_IN_KEY);
|
||||
}
|
||||
} catch {
|
||||
/* storage blocked or the entry unreadable: nothing of ours to drop */
|
||||
}
|
||||
}
|
||||
|
||||
/** The verifier of a sign-in still open, once. */
|
||||
function takePendingSignIn(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_SIGN_IN_KEY);
|
||||
localStorage.removeItem(PENDING_SIGN_IN_KEY);
|
||||
if (!raw) return null;
|
||||
const { verifier, at } = JSON.parse(raw) as { verifier?: unknown; at?: unknown };
|
||||
if (typeof verifier !== 'string' || typeof at !== 'number') return null;
|
||||
return Date.now() - at <= PENDING_SIGN_IN_TTL_MS ? verifier : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems a sign-in whose return reached an app started anew (see
|
||||
* keepPendingSignIn). True when the WebView now holds a session; false when
|
||||
* there was nothing to redeem, or the app is a build that cannot tell.
|
||||
*/
|
||||
export async function resumeBrowserSignIn(): Promise<boolean> {
|
||||
let returned: string | null;
|
||||
try {
|
||||
({ url: returned } = await TrekShell.takeAuthCallback());
|
||||
} catch {
|
||||
return false; // a build without takeAuthCallback
|
||||
}
|
||||
if (!returned) return false;
|
||||
const verifier = takePendingSignIn();
|
||||
const code = new URL(returned).searchParams.get('code');
|
||||
if (!verifier || !code) return false;
|
||||
await nativeAuthApi.exchange({ code, verifier });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Resolves once the WebView holds a session cookie of its own. */
|
||||
export async function signInThroughBrowser(start?: BrowserSignInStart, invite?: string): Promise<void> {
|
||||
const { verifier, challenge } = createHandoffSecret();
|
||||
keepPendingSignIn(verifier);
|
||||
let returned: string;
|
||||
try {
|
||||
({ url: returned } = await TrekShell.authenticate({
|
||||
@@ -102,9 +181,11 @@ export async function signInThroughBrowser(start?: BrowserSignInStart, invite?:
|
||||
callbackScheme: APP_URL_SCHEME,
|
||||
}));
|
||||
} catch (err) {
|
||||
dropPendingSignIn(verifier);
|
||||
if ((err as { code?: string })?.code === 'CANCELLED') throw new BrowserSignInCancelled();
|
||||
throw err;
|
||||
}
|
||||
dropPendingSignIn(verifier);
|
||||
const code = new URL(returned).searchParams.get('code');
|
||||
if (!code) throw new Error('The browser returned without a sign-in code');
|
||||
await nativeAuthApi.exchange({ code, verifier });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BROWSER_SIGN_IN_KEY,
|
||||
BROWSER_SIGN_IN_TTL_MS,
|
||||
browserSignInRunning,
|
||||
endBrowserSignIn,
|
||||
} from './browserSignInFlag';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('browserSignInRunning', () => {
|
||||
it('FE-NATIVE-SIGNINFLAG-001: counts a mark from public/native-handoff.js for as long as a sign-in may take', () => {
|
||||
const now = 1_000_000_000;
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - 1000));
|
||||
expect(browserSignInRunning(now)).toBe(true);
|
||||
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - BROWSER_SIGN_IN_TTL_MS));
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
// A clock set back, or anything that is no time at all.
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now + 60_000));
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, 'soon');
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SIGNINFLAG-002: ends with the hand-back to the app', () => {
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(Date.now()));
|
||||
endBrowserSignIn();
|
||||
expect(localStorage.getItem(BROWSER_SIGN_IN_KEY)).toBeNull();
|
||||
expect(browserSignInRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user