Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9442ea716d | ||
|
|
b23cec410f | ||
|
|
6f2ce234d9 | ||
|
|
1e50c1897a | ||
|
|
db43827513 | ||
|
|
f4e34f2720 | ||
|
|
7fffdd3bff | ||
|
|
f162a2619c | ||
|
|
875d5679b3 | ||
|
|
d2cdd5785e | ||
|
|
5beace2822 | ||
|
|
11dbb115e3 | ||
|
|
4ef0a51c6a | ||
|
|
59f503d90a | ||
|
|
7133c4d574 | ||
|
|
d14843622d | ||
|
|
6fb4314b3a | ||
|
|
cbbf9d7bc0 | ||
|
|
150c69a025 | ||
|
|
a271cd1434 | ||
|
|
7bf36b0845 | ||
|
|
beda33d60c | ||
|
|
ba83111d84 | ||
|
|
ba712468a0 | ||
|
|
8f3da91f21 | ||
|
|
f41a807ab3 | ||
|
|
aa4103a233 | ||
|
|
d6ccf0e4ae |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
# Web (development)
|
# Web (development)
|
||||||
|
|
||||||
npm run build:web
|
npm run dev
|
||||||
|
|
||||||
# Mobile
|
# Mobile
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
- **File System**: Limited browser APIs
|
- **File System**: Limited browser APIs
|
||||||
|
|
||||||
- **Build**: `npm run build:web` (development build)
|
- **Build**: `npm run dev` (development server), `npm run build:web` (production build into `dist/`)
|
||||||
|
|
||||||
### Mobile (Capacitor)
|
### Mobile (Capacitor)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
# Web (development)
|
# Web (development)
|
||||||
|
|
||||||
npm run build:web
|
npm run dev
|
||||||
|
|
||||||
# Mobile
|
# Mobile
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ follow proper review, testing, and documentation procedures.
|
|||||||
|
|
||||||
- **CI/CD Pipeline**: GitHub Actions, build validation scripts
|
- **CI/CD Pipeline**: GitHub Actions, build validation scripts
|
||||||
|
|
||||||
- **Service Worker Assembly**: `sw_scripts/`, `sw_combine.js`, WASM copy steps
|
- **WASM Assembly**: WASM copy steps
|
||||||
|
|
||||||
## Change Authorization Requirements
|
## Change Authorization Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
### Rollback Verification
|
### Rollback Verification
|
||||||
|
|
||||||
- **Web**: `npm run build:web:dev` and `npm run build:web:prod`
|
- **Web**: `npm run dev` and `npm run build:web:prod`
|
||||||
|
|
||||||
- **Mobile**: `npm run build:android:test` and `npm run build:ios:test`
|
- **Mobile**: `npm run build:android:test` and `npm run build:ios:test`
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
### After Build System Change
|
### After Build System Change
|
||||||
|
|
||||||
- [ ] **Web Platform**: Does `npm run build:web:dev` work?
|
- [ ] **Web Platform**: Does `npm run dev` work?
|
||||||
|
|
||||||
- [ ] **Mobile Platforms**: Do iOS/Android builds succeed?
|
- [ ] **Mobile Platforms**: Do iOS/Android builds succeed?
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
|
|
||||||
### Web Platform
|
### Web Platform
|
||||||
|
|
||||||
- **Development**: `npm run build:web:dev` - serve and load app
|
- **Development**: `npm run dev` - serve and load app
|
||||||
|
|
||||||
- **Production**: `npm run build:web:prod` - verify SW and WASM present
|
- **Production**: `npm run build:web:prod` - verify WASM present
|
||||||
|
|
||||||
### Mobile Platforms
|
### Mobile Platforms
|
||||||
|
|
||||||
@@ -98,13 +98,11 @@
|
|||||||
verify single-instance,
|
verify single-instance,
|
||||||
icons, signing
|
icons, signing
|
||||||
|
|
||||||
### Service Worker / WASM
|
### WASM
|
||||||
|
|
||||||
- **Trigger**: `sw_combine.js`, WASM copy path
|
- **Trigger**: WASM copy path
|
||||||
|
|
||||||
- **Validation**: Verify combined SW exists and is injected; page loads offline;
|
- **Validation**: WASM present
|
||||||
|
|
||||||
WASM present
|
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ VITE_LOG_LEVEL=info
|
|||||||
|
|
||||||
# iOS doesn't like spaces in the app title.
|
# iOS doesn't like spaces in the app title.
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
|
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
|
||||||
VITE_APP_SERVER=https://test.timesafari.app
|
VITE_APP_SERVER=https://test.`
|
||||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
|
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
|
||||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
|
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ dist-electon-build
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
signature.bin
|
signature.bin
|
||||||
# generated during `npm run build`
|
|
||||||
sw_scripts-combined.js
|
|
||||||
*.pem
|
*.pem
|
||||||
verified.txt
|
verified.txt
|
||||||
myenv
|
myenv
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
## Android Build — Google Play Services / FOSS Compatibility
|
## Android Build — Google Play Services / FOSS Compatibility
|
||||||
|
|
||||||
**Firebase is opt-in. Do NOT enable it accidentally.**
|
**Firebase is on by default for Android and iOS builds.** Do not turn it off unless the build is an exceptional FOSS/F-Droid artifact.
|
||||||
|
|
||||||
`android/google-services.json` is gitignored and may be present on disk for push notification development, but Firebase is only activated when you explicitly pass `-PfirebaseEnabled` to Gradle:
|
`android/app/google-services.json` is required when Firebase is enabled. It does not decide the switch: a missing file fails the build, and a file on disk is ignored when Firebase is explicitly disabled.
|
||||||
|
|
||||||
- **FOSS / APK / Aurora / Zapstore / F-Droid builds**: just `./gradlew assembleRelease` — Firebase stays off even if `google-services.json` is on disk.
|
- **Default (local, test, production, Play Store)**: `./gradlew assembleRelease` or `./gradlew bundleRelease` — do not pass `-PfirebaseEnabled`.
|
||||||
- **Firebase / FCM / Play Store builds**: `./gradlew bundleRelease -PfirebaseEnabled` — explicitly opt in.
|
- **Opt out (F-Droid / FOSS)**: `./gradlew assembleRelease -PfirebaseEnabled=false`, or an npm `:nostore` script such as `npm run build:android:test:nostore:run`. Those set `FIREBASE_ENABLED=false`, which forwards `-PfirebaseEnabled=false`. `:nostore` disables Firebase only; it does not remove every Play Services dependency.
|
||||||
|
|
||||||
This guard is in `android/app/build.gradle`. Do NOT change this conditional to activate Firebase unconditionally based on file presence alone — that was the bug that broke FOSS distribution in June 2026.
|
iOS builds bundle `ios/App/App/GoogleService-Info.plist` by default (`ios/App/scripts/bundle-firebase-config.sh`, run from `scripts/build-ios.sh` and as an Xcode build phase). Opt out with `FIREBASE_ENABLED=false` (the build script forwards that to both the build and archive `xcodebuild` invocations), `npm run build:ios:prod:nostore`, or the Xcode build setting `FIREBASE_ENABLED=NO`. A missing plist fails an enabled build; the file's presence does not enable Firebase on its own.
|
||||||
|
|
||||||
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
|
The guard is in `android/app/build.gradle`. Do NOT activate Firebase from file presence alone — that was the bug that broke FOSS distribution in June 2026.
|
||||||
|
|
||||||
|
Never commit `google-services.json`.
|
||||||
|
|
||||||
Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
|
Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
|
||||||
|
|
||||||
|
|||||||
+87
-240
@@ -9,9 +9,9 @@ This guide explains how to build TimeSafari for different platforms using the co
|
|||||||
```bash
|
```bash
|
||||||
# 🖥️ Web Development
|
# 🖥️ Web Development
|
||||||
npm install # setup -- and pkgx.dev `dev` command before this will set environment with npm, etc
|
npm install # setup -- and pkgx.dev `dev` command before this will set environment with npm, etc
|
||||||
npm run build:web:serve -- --test # Start with test endorser server
|
|
||||||
npm run dev # Start development server with hot reload with local endorser server
|
npm run dev # Start development server with hot reload with local endorser server
|
||||||
npm run build:web:prod # Production build
|
npm run build:web:serve:test # Test build served from dist/ (test endorser server)
|
||||||
|
npm run build:web # Production build into dist/
|
||||||
|
|
||||||
# 📱 Mobile Development
|
# 📱 Mobile Development
|
||||||
npm run build:ios # iOS build (opens Xcode)
|
npm run build:ios # iOS build (opens Xcode)
|
||||||
@@ -219,36 +219,41 @@ Install dependencies:
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Web Development
|
## Web Development & Deployment
|
||||||
|
|
||||||
### Web Development Commands
|
### Web Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Local development
|
# Local development
|
||||||
npm run dev # Start development server with hot reload (shortcut for `build:web:dev`)
|
npm run dev # Start development server with hot reload (writes nothing to dist/)
|
||||||
|
|
||||||
# Web builds
|
# Web builds (each one writes dist/)
|
||||||
npm run build:web # Development build (starts dev server with hot reload)
|
npm run build:web # Production build (same as build:web:prod)
|
||||||
npm run build:web:test # Test environment build (optimized for testing)
|
npm run build:web:test # Test environment build (optimized for testing)
|
||||||
npm run build:web:prod # Production build (optimized for production)
|
npm run build:web:prod # Production build (optimized for production)
|
||||||
npm run build:web:serve # Build and serve locally for production testing
|
|
||||||
|
# Build, then serve dist/ locally
|
||||||
|
npm run build:web:serve # Production build, then serve
|
||||||
|
npm run build:web:serve:test # Test build, then serve
|
||||||
|
|
||||||
# Docker builds
|
# Docker builds
|
||||||
npm run build:web:docker # Development build with Docker containerization
|
npm run build:web:docker # Production build with Docker containerization
|
||||||
npm run build:web:docker:test # Test build with Docker containerization
|
npm run build:web:docker:test # Test build with Docker containerization
|
||||||
npm run build:web:docker:prod # Production build with Docker containerization
|
npm run build:web:docker:prod # Production build with Docker containerization
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
Start the development server using `npm run dev` (a shortcut for `npm run build:web:dev`) or `npm run build:web` (both start the development server with hot reload).
|
Start the development server with hot reload using `npm run dev`. Open it at `http://localhost:8080/`, not at a wildcard address such as `http://[::]:8080/` or `http://0.0.0.0:8080/`: browsers only allow the cryptography the app needs on HTTPS, `localhost`, `127.0.0.1`, or `[::1]`, so any other plain-HTTP address shows a "Cannot Start Here" page.
|
||||||
|
|
||||||
### Web Build for Server
|
### Web Build for Server
|
||||||
|
|
||||||
1. Run the production build using `npm run build:web:prod`
|
1. Run the production build using `npm run build:web` (or `npm run build:web:test` for the test server)
|
||||||
2. The built files will be in the `dist` directory
|
2. The built files will be in the `dist` directory
|
||||||
3. To test the production build locally, use `npm run build:web:serve` (builds then serves)
|
3. To test the production build locally, use `npm run build:web:serve` (builds then serves)
|
||||||
|
|
||||||
|
`dist/` is shared with the Android, iOS, Electron, and Capacitor builds, which overwrite it with non-web output. Run the web build immediately before deploying, and confirm that the browser console reports `Loading TimeSafari for platform: web`.
|
||||||
|
|
||||||
**Why Use `serve`?**
|
**Why Use `serve`?**
|
||||||
- **Production Testing**: Test your optimized production build locally before deployment
|
- **Production Testing**: Test your optimized production build locally before deployment
|
||||||
- **SPA Routing Validation**: Verify deep linking and navigation work correctly (handles routes like `/discover`, `/account`)
|
- **SPA Routing Validation**: Verify deep linking and navigation work correctly (handles routes like `/discover`, `/account`)
|
||||||
@@ -1069,6 +1074,9 @@ npm run build:ios:prod # Production build (builds for production en
|
|||||||
npm run build:ios:test:run # Test build with auto-run (builds then runs on simulator)
|
npm run build:ios:test:run # Test build with auto-run (builds then runs on simulator)
|
||||||
npm run build:ios:prod:run # Production build with auto-run (builds then runs on simulator)
|
npm run build:ios:prod:run # Production build with auto-run (builds then runs on simulator)
|
||||||
|
|
||||||
|
# Firebase off (`:nostore` sets FIREBASE_ENABLED=false, forwarded to xcodebuild as FIREBASE_ENABLED=NO)
|
||||||
|
npm run build:ios:prod:nostore # Same as prod, without Firebase
|
||||||
|
|
||||||
# Debug and release builds
|
# Debug and release builds
|
||||||
npm run build:ios:debug # Debug build (builds debug app bundle)
|
npm run build:ios:debug # Debug build (builds debug app bundle)
|
||||||
npm run build:ios:debug:run # Debug build with auto-run (builds then runs on simulator)
|
npm run build:ios:debug:run # Debug build with auto-run (builds then runs on simulator)
|
||||||
@@ -1122,15 +1130,15 @@ The script handles all the necessary steps including:
|
|||||||
|
|
||||||
If you need to build manually or want to understand the individual steps:
|
If you need to build manually or want to understand the individual steps:
|
||||||
|
|
||||||
#### First-time iOS Configuration
|
##### First-time iOS Configuration
|
||||||
|
|
||||||
- Generate certificates inside XCode.
|
- Generate certificates inside XCode.
|
||||||
- Right-click on App and under Signing & Capabilities set the Team.
|
- Right-click on App and under Signing & Capabilities set the Team.
|
||||||
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
|
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
|
||||||
|
|
||||||
#### Each Release
|
##### Each Release
|
||||||
|
|
||||||
##### 0. First time (or if dependencies change)
|
###### 0. First time (or if dependencies change)
|
||||||
|
|
||||||
- `pkgx +rubygems.org +pod /bin/zsh`
|
- `pkgx +rubygems.org +pod /bin/zsh`
|
||||||
|
|
||||||
@@ -1143,15 +1151,15 @@ export GEM_HOME=$shortened_path
|
|||||||
export GEM_PATH=$shortened_path
|
export GEM_PATH=$shortened_path
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
###### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 72 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.6;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 76 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.10;/g" App.xcodeproj/project.pbxproj && cd -
|
||||||
# Unfortunately this edits Info.plist directly.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
###### 2. Build
|
||||||
|
|
||||||
Here's prod. Also available: test, dev
|
Here's prod. Also available: test, dev
|
||||||
|
|
||||||
@@ -1332,6 +1340,10 @@ npm run build:android:test:run # Test build with auto-run (builds then run
|
|||||||
npm run build:android:prod:run # Production build with auto-run (builds then runs on emulator)
|
npm run build:android:prod:run # Production build with auto-run (builds then runs on emulator)
|
||||||
# Auto-run targets the one connected device; with none or several it prompts, or exits 9 without a terminal
|
# Auto-run targets the one connected device; with none or several it prompts, or exits 9 without a terminal
|
||||||
|
|
||||||
|
# Firebase off (`:nostore` sets FIREBASE_ENABLED=false, which passes -PfirebaseEnabled=false)
|
||||||
|
npm run build:android:test:nostore:run # Same as test:run, without Firebase
|
||||||
|
npm run build:android:prod:nostore # Same as prod, without Firebase
|
||||||
|
|
||||||
# Debug and release builds
|
# Debug and release builds
|
||||||
npm run build:android:debug # Debug build (builds debug APK)
|
npm run build:android:debug # Debug build (builds debug APK)
|
||||||
npm run build:android:debug:run # Debug build with auto-run (builds then runs on emulator)
|
npm run build:android:debug:run # Debug build with auto-run (builds then runs on emulator)
|
||||||
@@ -1434,8 +1446,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
##### 1. Bump the version in package.json, then update these versions & run:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 73/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 76/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.4.7"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.4.10"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
@@ -1468,9 +1480,11 @@ To create a signed `aab` release:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# if you use pkgx: pkgx +openjdk.org /bin/zsh
|
# if you use pkgx: pkgx +openjdk.org /bin/zsh
|
||||||
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
|
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Firebase is on by default (`firebaseEnabled=true` in `android/gradle.properties`). Do not pass `-PfirebaseEnabled` for a normal release. F-Droid and other builds that must omit Firebase use `-PfirebaseEnabled=false`, or an npm `:nostore` script (`FIREBASE_ENABLED=false`). `:nostore` only disables Firebase. It does not remove other store-linked dependencies, such as the ML Kit barcode scanner's Play Services library, and it does not by itself make the app run on every device outside the Play Store.
|
||||||
|
|
||||||
... and find your `aab` file at app/build/outputs/bundle/release
|
... and find your `aab` file at app/build/outputs/bundle/release
|
||||||
|
|
||||||
To create an unsigned debugging bundle (not usable in Play Store):
|
To create an unsigned debugging bundle (not usable in Play Store):
|
||||||
@@ -1486,8 +1500,6 @@ To create an unsigned debugging bundle (not usable in Play Store):
|
|||||||
* `build-android.sh` runs `./gradlew clean`, which deletes any earlier `aab`, so
|
* `build-android.sh` runs `./gradlew clean`, which deletes any earlier `aab`, so
|
||||||
run `bundleRelease` after the build script finishes.
|
run `bundleRelease` after the build script finishes.
|
||||||
|
|
||||||
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
|
|
||||||
|
|
||||||
At play.google.com/console:
|
At play.google.com/console:
|
||||||
|
|
||||||
- Go to Production or the Closed Testing and either Create Track or Manage Track.
|
- Go to Production or the Closed Testing and either Create Track or Manage Track.
|
||||||
@@ -1603,8 +1615,7 @@ Use the commands above to check and fix code quality issues.
|
|||||||
|
|
||||||
1. **Environment Setup**: Load platform-specific environment variables
|
1. **Environment Setup**: Load platform-specific environment variables
|
||||||
2. **Asset Building**: Vite builds optimized web assets
|
2. **Asset Building**: Vite builds optimized web assets
|
||||||
3. **PWA Generation**: Service worker and manifest creation
|
3. **Output**: Production-ready files in `dist/` directory
|
||||||
4. **Output**: Production-ready files in `dist/` directory
|
|
||||||
|
|
||||||
### Electron Build Process
|
### Electron Build Process
|
||||||
|
|
||||||
@@ -1856,38 +1867,21 @@ npm run build:android:assets
|
|||||||
|
|
||||||
### Build Process Overview
|
### Build Process Overview
|
||||||
|
|
||||||
TimeSafari's build system follows a multi-stage process that prepares assets,
|
TimeSafari's build system follows a multi-stage process that prepares assets
|
||||||
combines scripts, and generates platform-specific outputs.
|
and generates platform-specific outputs.
|
||||||
|
|
||||||
#### Pre-Build Preparation
|
#### Pre-Build Script
|
||||||
|
|
||||||
##### 1. Service Worker Script Preparation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optional: Format third-party service worker scripts
|
|
||||||
npx prettier --write ./sw_scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
**What this does:**
|
|
||||||
|
|
||||||
- Formats cryptographic libraries (`nacl.js`, `noble-curves.js`,
|
|
||||||
`noble-hashes.js`, etc.)
|
|
||||||
- These scripts are automatically combined during the build process
|
|
||||||
- Improves readability and makes version control diffs cleaner
|
|
||||||
- **Note**: This is optional and only needed when updating third-party scripts
|
|
||||||
|
|
||||||
**2. Automatic Pre-Build Steps**
|
|
||||||
The `prebuild` script automatically runs before any build:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js"
|
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node scripts/copy-wasm.js"
|
||||||
```
|
```
|
||||||
|
|
||||||
**What happens automatically:**
|
npm runs `prebuild` only before a script named `build`. This project defines
|
||||||
|
no `build` script, so none of the `build:*` commands trigger it.
|
||||||
|
|
||||||
- **ESLint**: Checks and fixes code formatting in `src/`
|
**What it does when run:**
|
||||||
- **Script Combination**: `sw_combine.js` combines all `sw_scripts/*.js` files
|
|
||||||
into `sw_scripts-combined.js`
|
- **ESLint**: Checks code formatting in `src/`
|
||||||
- **WASM Copy**: `copy-wasm.js` copies SQLite WASM files to `public/wasm/`
|
- **WASM Copy**: `copy-wasm.js` copies SQLite WASM files to `public/wasm/`
|
||||||
|
|
||||||
#### Build Process Architecture
|
#### Build Process Architecture
|
||||||
@@ -1895,11 +1889,10 @@ The `prebuild` script automatically runs before any build:
|
|||||||
**Web Build Process:**
|
**Web Build Process:**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Pre-Build: ESLint + Script Combination + WASM Copy
|
1. Environment Setup: Load .env files, set NODE_ENV
|
||||||
2. Environment Setup: Load .env files, set NODE_ENV
|
2. Type Check: tsc --noEmit (test and production modes)
|
||||||
3. Vite Build: Bundle web assets with PWA support
|
3. Vite Build: Bundle web assets
|
||||||
4. Service Worker: Inject combined scripts into PWA
|
4. Output: Production-ready files in dist/
|
||||||
5. Output: Production-ready files in dist/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Electron Build Process:**
|
**Electron Build Process:**
|
||||||
@@ -1922,34 +1915,6 @@ The `prebuild` script automatically runs before any build:
|
|||||||
5. Package Creation: APK/IPA generation
|
5. Package Creation: APK/IPA generation
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Service Worker Architecture
|
|
||||||
|
|
||||||
**Script Organization:**
|
|
||||||
|
|
||||||
- `sw_scripts/` - Individual third-party scripts
|
|
||||||
- `sw_combine.js` - Combines scripts into single file
|
|
||||||
- `sw_scripts-combined.js` - Combined service worker (317KB, 10K+ lines)
|
|
||||||
- `vite.config.utils.mts` - PWA configuration using combined script
|
|
||||||
|
|
||||||
**PWA Integration:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// vite.config.utils.mts
|
|
||||||
pwaConfig: {
|
|
||||||
strategies: "injectManifest",
|
|
||||||
filename: "sw_scripts-combined.js", // Uses our combined script
|
|
||||||
// ... manifest configuration
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**What Gets Combined:**
|
|
||||||
|
|
||||||
- `nacl.js` - NaCl cryptographic library
|
|
||||||
- `noble-curves.js` - Elliptic curve cryptography (177KB)
|
|
||||||
- `noble-hashes.js` - Cryptographic hash functions (91KB)
|
|
||||||
- `safari-notifications.js` - Safari-specific notifications
|
|
||||||
- `additional-scripts.js` - Additional service worker functionality
|
|
||||||
|
|
||||||
#### Process Environment Configuration
|
#### Process Environment Configuration
|
||||||
|
|
||||||
**Environment Files:**
|
**Environment Files:**
|
||||||
@@ -2002,10 +1967,13 @@ device)
|
|||||||
```folders
|
```folders
|
||||||
dist/
|
dist/
|
||||||
├── index.html # Main HTML file
|
├── index.html # Main HTML file
|
||||||
├── assets/ # Bundled JavaScript/CSS
|
├── assets/ # Bundled JavaScript, CSS, and SQLite WASM
|
||||||
├── sw.js # Service worker (injected)
|
├── img/ # Static images (copied from public/)
|
||||||
├── manifest.webmanifest # PWA manifest
|
├── models/ # Static model files (copied from public/)
|
||||||
└── wasm/ # SQLite WASM files
|
├── favicon.ico
|
||||||
|
├── manifest.webmanifest # Web app manifest, incl. share_target (copied from public/)
|
||||||
|
├── robots.txt
|
||||||
|
└── sw.js # Service worker for the Web Share Target (copied from public/)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Electron Build:**
|
**Electron Build:**
|
||||||
@@ -2027,17 +1995,14 @@ electron/
|
|||||||
**Manual Steps (Developer Responsibility):**
|
**Manual Steps (Developer Responsibility):**
|
||||||
|
|
||||||
- Database migration testing
|
- Database migration testing
|
||||||
- Service worker script formatting (optional)
|
|
||||||
- Version updates and changelog
|
- Version updates and changelog
|
||||||
- Environment-specific builds
|
- Environment-specific builds
|
||||||
|
|
||||||
**Automatic Steps (Build System):**
|
**Automatic Steps (Build System):**
|
||||||
|
|
||||||
- Code linting and formatting
|
- Type checking (test and production builds)
|
||||||
- Script combination
|
|
||||||
- Asset optimization
|
- Asset optimization
|
||||||
- Package creation
|
- Package creation
|
||||||
- Service worker injection
|
|
||||||
|
|
||||||
This architecture ensures consistent builds across all platforms while providing
|
This architecture ensures consistent builds across all platforms while providing
|
||||||
flexibility for platform-specific optimizations and manual quality assurance steps.
|
flexibility for platform-specific optimizations and manual quality assurance steps.
|
||||||
@@ -2062,31 +2027,34 @@ environment setup, Vite build, and optional Docker containerization.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Direct script usage
|
# Direct script usage
|
||||||
./scripts/build-web.sh # Development build
|
./scripts/build-web.sh # Production build into dist/
|
||||||
./scripts/build-web.sh --dev # Development build (explicit)
|
./scripts/build-web.sh --prod # Production build (explicit)
|
||||||
./scripts/build-web.sh --test # Test environment build
|
./scripts/build-web.sh --test # Test environment build
|
||||||
./scripts/build-web.sh --prod # Production environment build
|
./scripts/build-web.sh --dev # Start Vite dev server (no dist/)
|
||||||
./scripts/build-web.sh --docker # Build with Docker containerization
|
./scripts/build-web.sh --docker # Production build + Docker image
|
||||||
./scripts/build-web.sh --docker:test # Test environment + Docker
|
./scripts/build-web.sh --docker:test # Test environment + Docker
|
||||||
./scripts/build-web.sh --docker:prod # Production environment + Docker
|
./scripts/build-web.sh --docker:prod # Production environment + Docker
|
||||||
./scripts/build-web.sh --serve # Build and serve locally
|
./scripts/build-web.sh --serve # Production build, then serve dist/
|
||||||
./scripts/build-web.sh --help # Show help
|
./scripts/build-web.sh --help # Show help
|
||||||
./scripts/build-web.sh --verbose # Enable verbose logging
|
./scripts/build-web.sh --verbose # Enable verbose logging
|
||||||
|
|
||||||
# NPM Script Equivalents
|
# NPM Script Equivalents
|
||||||
npm run build:web # Development build
|
npm run dev # Vite dev server
|
||||||
|
npm run build:web # Production build
|
||||||
npm run build:web:test # Test environment build
|
npm run build:web:test # Test environment build
|
||||||
npm run build:web:prod # Production environment build
|
npm run build:web:prod # Production environment build
|
||||||
npm run build:web:docker # Docker build
|
npm run build:web:docker # Production Docker build
|
||||||
npm run build:web:docker:test # Test Docker build
|
npm run build:web:docker:test # Test Docker build
|
||||||
npm run build:web:docker:prod # Production Docker build
|
npm run build:web:docker:prod # Production Docker build
|
||||||
|
npm run build:web:serve # Production build, then serve
|
||||||
|
npm run build:web:serve:test # Test build, then serve
|
||||||
```
|
```
|
||||||
|
|
||||||
**Build Modes**:
|
**Build Modes**:
|
||||||
|
|
||||||
- **Development**: Starts Vite dev server with hot reload (default)
|
- **Production**: Optimized for production with full minification (default)
|
||||||
- **Test**: Optimized for testing with minimal minification
|
- **Test**: Optimized for testing with minimal minification
|
||||||
- **Production**: Optimized for production with full minification
|
- **Development**: Starts Vite dev server with hot reload (`--dev` alone); with `--serve` or `--docker`, builds `dist/` in development mode
|
||||||
|
|
||||||
**Script Features**:
|
**Script Features**:
|
||||||
|
|
||||||
@@ -2439,9 +2407,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
const appConfig = await loadAppConfig();
|
const appConfig = await loadAppConfig();
|
||||||
const isCapacitor = platform === "capacitor";
|
const isCapacitor = platform === "capacitor";
|
||||||
const isElectron = platform === "electron";
|
const isElectron = platform === "electron";
|
||||||
const isNative = isCapacitor || isElectron;
|
|
||||||
|
|
||||||
// Set platform - PWA is always enabled for web platforms
|
|
||||||
process.env.VITE_PLATFORM = platform;
|
process.env.VITE_PLATFORM = platform;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2457,7 +2423,6 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
chunkSizeWarningLimit: 1000,
|
chunkSizeWarningLimit: 1000,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: isNative ? ['@capacitor/app'] : [],
|
|
||||||
output: {
|
output: {
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
generatedCode: { preset: 'es2015' },
|
generatedCode: { preset: 'es2015' },
|
||||||
@@ -2489,12 +2454,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
'@jlongster/sql.js',
|
'@jlongster/sql.js',
|
||||||
'absurd-sql',
|
'absurd-sql',
|
||||||
// ... additional dependencies
|
// ... additional dependencies
|
||||||
],
|
]
|
||||||
exclude: isNative ? [
|
|
||||||
'register-service-worker',
|
|
||||||
'workbox-window',
|
|
||||||
// ... native exclusions
|
|
||||||
] : []
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2517,98 +2477,23 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
|
|
||||||
**File**: `vite.config.web.mts`
|
**File**: `vite.config.web.mts`
|
||||||
**Author**: Matthew Raymer
|
**Author**: Matthew Raymer
|
||||||
**Description**: Vite configuration for web platform with PWA support
|
**Description**: Vite configuration for the web platform
|
||||||
|
|
||||||
**Purpose**: Configures Vite for web builds with environment-specific
|
**Purpose**: Configures Vite for web builds by delegating to the common
|
||||||
optimizations and PWA features.
|
configuration with the `web` platform.
|
||||||
|
|
||||||
|
**Configuration Structure**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineConfig(async () => createBuildConfig('web'));
|
||||||
|
```
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- **Environment-Specific Configuration**: Different settings for dev/test/prod
|
- **Minimal Configuration**: Inherits all settings from `vite.config.common.mts`
|
||||||
- **PWA Integration**: Progressive Web App support with service worker
|
- **Mode Selection**: `--mode test` or `--mode production` (passed by
|
||||||
- **Build Optimization**: Manual chunk splitting for better caching
|
`scripts/build-web.sh`) selects the matching `.env.*` file
|
||||||
- **Source Maps**: Environment-specific source map configuration
|
- **Output**: Writes to `dist/`
|
||||||
- **Caching Strategies**: Production-specific API caching
|
|
||||||
|
|
||||||
**Environment Configurations**:
|
|
||||||
|
|
||||||
**Development Mode**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
build: {
|
|
||||||
minify: false,
|
|
||||||
sourcemap: true,
|
|
||||||
rollupOptions: { output: { manualChunks: undefined } }
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__DEV__: true,
|
|
||||||
__TEST__: false,
|
|
||||||
__PROD__: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Mode**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
build: {
|
|
||||||
minify: false,
|
|
||||||
sourcemap: true,
|
|
||||||
rollupOptions: { output: { manualChunks: undefined } }
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__DEV__: false,
|
|
||||||
__TEST__: true,
|
|
||||||
__PROD__: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Production Mode**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
build: {
|
|
||||||
minify: 'terser',
|
|
||||||
sourcemap: false,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: {
|
|
||||||
vendor: ['vue', 'vue-router', 'pinia'],
|
|
||||||
utils: ['luxon', 'ramda', 'zod'],
|
|
||||||
crypto: ['@ethersproject/wallet', '@ethersproject/hdnode'],
|
|
||||||
sql: ['@jlongster/sql.js', 'absurd-sql']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__DEV__: false,
|
|
||||||
__TEST__: false,
|
|
||||||
__PROD__: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**PWA Configuration**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
VitePWA({
|
|
||||||
registerType: 'autoUpdate',
|
|
||||||
manifest: appConfig.pwaConfig?.manifest,
|
|
||||||
devOptions: { enabled: true, type: 'classic' },
|
|
||||||
workbox: {
|
|
||||||
cleanupOutdatedCaches: true,
|
|
||||||
skipWaiting: true,
|
|
||||||
clientsClaim: true,
|
|
||||||
sourcemap: mode !== 'production',
|
|
||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
|
||||||
runtimeCaching: mode === 'production' ? [/* API caching */] : []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### B.3 vite.config.electron.mts
|
### B.3 vite.config.electron.mts
|
||||||
|
|
||||||
@@ -2699,7 +2584,6 @@ export default defineConfig(async () => createBuildConfig('capacitor'));
|
|||||||
|
|
||||||
- **Minimal Configuration**: Inherits all settings from common config
|
- **Minimal Configuration**: Inherits all settings from common config
|
||||||
- **Mobile Platform**: Automatically configures for mobile-specific settings
|
- **Mobile Platform**: Automatically configures for mobile-specific settings
|
||||||
- **PWA Disabled**: Progressive Web App features disabled for native apps
|
|
||||||
- **Native Dependencies**: Handles Capacitor-specific dependencies
|
- **Native Dependencies**: Handles Capacitor-specific dependencies
|
||||||
|
|
||||||
### B.5 vite.config.utils.mts
|
### B.5 vite.config.utils.mts
|
||||||
@@ -2708,51 +2592,15 @@ export default defineConfig(async () => createBuildConfig('capacitor'));
|
|||||||
**Author**: Matthew Raymer
|
**Author**: Matthew Raymer
|
||||||
**Description**: Utility functions for Vite configuration
|
**Description**: Utility functions for Vite configuration
|
||||||
|
|
||||||
**Purpose**: Provides shared configuration loading and PWA manifest generation.
|
**Purpose**: Provides the shared alias configuration and strict ESBuild settings.
|
||||||
|
|
||||||
**Key Functions**:
|
**Key Functions**:
|
||||||
|
|
||||||
**loadPackageJson()**: Loads and parses package.json for app configuration
|
**loadAppConfig()**: Returns the alias configuration used by the common and
|
||||||
|
optimized configs
|
||||||
|
|
||||||
**loadAppConfig()**: Loads complete application configuration including PWA settings
|
**getStrictESBuildConfig()**: Returns ESBuild settings that fail the build on
|
||||||
|
syntax and duplicate-declaration errors
|
||||||
**PWA Configuration**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PWAConfig {
|
|
||||||
registerType: string;
|
|
||||||
strategies: string;
|
|
||||||
srcDir: string;
|
|
||||||
filename: string;
|
|
||||||
manifest: {
|
|
||||||
name: string;
|
|
||||||
short_name: string;
|
|
||||||
theme_color: string;
|
|
||||||
background_color: string;
|
|
||||||
icons: ManifestIcon[];
|
|
||||||
share_target: ShareTarget;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manifest Icons**:
|
|
||||||
|
|
||||||
- Android Chrome 192x192 PNG
|
|
||||||
- Android Chrome 512x512 PNG
|
|
||||||
- Maskable icons for adaptive UI
|
|
||||||
|
|
||||||
**Share Target Configuration**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
share_target: {
|
|
||||||
action: "/share-target",
|
|
||||||
method: "POST",
|
|
||||||
enctype: "multipart/form-data",
|
|
||||||
params: {
|
|
||||||
files: [{ name: "photo", accept: ["image/*"] }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Alias Configuration**:
|
**Alias Configuration**:
|
||||||
|
|
||||||
@@ -2820,8 +2668,7 @@ All scripts use consistent error handling:
|
|||||||
|
|
||||||
**Web Platform**:
|
**Web Platform**:
|
||||||
|
|
||||||
- PWA features enabled
|
- Requires a secure context (HTTPS, `localhost`, `127.0.0.1`, or `[::1]`)
|
||||||
- Service worker injection
|
|
||||||
- Browser-specific optimizations
|
- Browser-specific optimizations
|
||||||
|
|
||||||
**Electron Platform**:
|
**Electron Platform**:
|
||||||
|
|||||||
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.4.10] - 2026.10.17
|
||||||
|
### Changed
|
||||||
|
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
|
||||||
|
- `npm run build:web` builds production into `dist/` instead of starting the dev server.
|
||||||
|
- `npm run build:web:serve` builds production (instead of development) before serving.
|
||||||
|
- `npm run build:web:docker` builds production (instead of development) before creating the image.
|
||||||
|
- `npm run build:native` builds with the Capacitor Vite config instead of a bare `vite build`.
|
||||||
|
- The claim and profile pages show "Who Can Introduce You" as a collapsed section, loading the next-closer people only when opened.
|
||||||
|
- Messages about hidden people point to who can introduce you, instead of saying nobody can.
|
||||||
|
- "Who Can Introduce You" labels a person whom Endorser hides in its results as outside your view.
|
||||||
|
### Removed
|
||||||
|
- `npm run build:web:dev` is removed; use `npm run dev` for the dev server.
|
||||||
|
- Leftover service-worker build code (`sw_scripts/`, the unused PWA config, and the `register-service-worker` package) is removed.
|
||||||
|
### Fixed
|
||||||
|
- The daily starred-project check (Android and iOS) remembers the newest change it found, so it no longer reports the same changes again the next day.
|
||||||
|
- Sharing a photo into the installed web app (Web Share Target) works again.
|
||||||
|
- `npm run docker:up:test` and `npm run docker:up:prod` run, instead of failing on a missing `build:web:build` script.
|
||||||
|
- Affirm Delivery on an offer to a project waits for the project to load, so a quick tap no longer makes the offerer both giver and recipient.
|
||||||
|
- When the browser cannot run WebAssembly ("compiler"), the start-up error screen explains it and points to the settings that block it.
|
||||||
|
- An unregistered user no longer sees every person on a claim described as unknown to this server.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.4.9] - 2026.10.11
|
||||||
|
### Fixed
|
||||||
|
- Startup DB error for new installations
|
||||||
|
### Changed
|
||||||
|
- Allow in-app notifications on production (as an advanced feature)
|
||||||
|
- Allow modification of image server URL
|
||||||
|
- Terms & conditions and privacy policy
|
||||||
|
|
||||||
|
|
||||||
## [1.4.6] - 2026.09.27
|
## [1.4.6] - 2026.09.27
|
||||||
### Fixed
|
### Fixed
|
||||||
- Incorrect 'test' server URLs on production.
|
- Incorrect 'test' server URLs on production.
|
||||||
|
|||||||
+3
-3
@@ -21,13 +21,13 @@
|
|||||||
# Using npm scripts (recommended):
|
# Using npm scripts (recommended):
|
||||||
# Production: npm run build:web:docker:prod
|
# Production: npm run build:web:docker:prod
|
||||||
# Test: npm run build:web:docker:test
|
# Test: npm run build:web:docker:test
|
||||||
# Development: npm run build:web:docker
|
# Development: ./scripts/build-web.sh --docker --dev
|
||||||
#
|
#
|
||||||
# Manual workflow:
|
# Manual workflow:
|
||||||
# 1. Build web assets: npm run build:web:build -- --mode production
|
# 1. Build web assets: npm run build:web:prod
|
||||||
# 2. Build Docker: docker build -t timesafari:latest .
|
# 2. Build Docker: docker build -t timesafari:latest .
|
||||||
#
|
#
|
||||||
# Note: For development, use npm run build:web directly (no Docker needed)
|
# Note: For development, use npm run dev directly (no Docker needed)
|
||||||
#
|
#
|
||||||
# Build Arguments:
|
# Build Arguments:
|
||||||
# BUILD_MODE: development, test, or production (default: production)
|
# BUILD_MODE: development, test, or production (default: production)
|
||||||
|
|||||||
@@ -33,45 +33,6 @@ npm run build:android:test:run
|
|||||||
|
|
||||||
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
||||||
|
|
||||||
#### Building an APK for Android 6 (API 23)
|
|
||||||
|
|
||||||
App versions up to 1.4.6 support Android 6.0 (API 23); later versions require Android 7.0 (API 24) or newer. To run the app on an Android 6 device, build your own APK from the 1.4.6 release commit (`6183afc2`, "bump version to 1.4.6").
|
|
||||||
|
|
||||||
Prerequisites: git, Node.js 20 (see `.nvmrc`), Java 21, and the Android SDK with `ANDROID_HOME` set (see [Android Emulator Without Android Studio](BUILDING.md#android-emulator-without-android-studio-command-line-only) for an SDK setup that skips Android Studio). With [pkgx](https://pkgx.dev), prefix the commands with `pkgx`, e.g. `pkgx npm ci`.
|
|
||||||
|
|
||||||
1. Get the 1.4.6 source:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa.git
|
|
||||||
cd crowd-funder-for-time-pwa
|
|
||||||
git checkout 6183afc2
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Confirm the minimum SDK is 23:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep minSdkVersion android/variables.gradle # expect: minSdkVersion = 23
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Install dependencies and build a production APK:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm ci
|
|
||||||
npm run build:android:prod -- --apk
|
|
||||||
```
|
|
||||||
|
|
||||||
`npm ci` installs the exact dependency versions locked for 1.4.6. `build:android:prod -- --apk` builds the web app with production server settings, syncs it into the Android project, and runs `./gradlew assembleDebug`. The result is signed with your local Android debug key, so it installs on any device without a release keystore.
|
|
||||||
|
|
||||||
4. Find the APK at `android/app/build/outputs/apk/debug/app-debug.apk` and install it, either with `adb install android/app/build/outputs/apk/debug/app-debug.apk` (USB debugging enabled on the phone) or by copying the file to the phone and opening it (allow "Install unknown apps" for the file manager).
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
* **Back up your identity first.** A self-built APK has a different signature from the Play Store app, so Android refuses to install it over the store version. Uninstalling the store version deletes the app's local data, including your identifier keys. Back up your seed phrase (Profile page → "Backup Identifier Seed") before uninstalling, then import it into the self-built app.
|
|
||||||
* This build has Firebase disabled, so push notifications through Google's FCM service are off.
|
|
||||||
* The daily notification plugin calls `java.time`, which Android provides only from 8.0 (API 26). On Android 6, scheduling a daily reminder can crash the app; leave daily reminders off. See [doc/plugin-feedback-android-6-api23-zoneid-fix.md](doc/plugin-feedback-android-6-api23-zoneid-fix.md).
|
|
||||||
* Barcode scanning uses Google Play Services; on devices without them, scanning fails while the rest of the app works.
|
|
||||||
* A self-built APK does not auto-update.
|
|
||||||
|
|
||||||
### iOS
|
### iOS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -378,7 +339,7 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
|
|||||||
|
|
||||||
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
|
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
|
||||||
|
|
||||||
## 📁 Project Structure
|
### 📁 Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
timesafari/
|
timesafari/
|
||||||
@@ -393,6 +354,45 @@ timesafari/
|
|||||||
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building an APK for Android 6 (API 23)
|
||||||
|
|
||||||
|
App versions up to 1.4.6 support Android 6.0 (API 23); later versions require Android 7.0 (API 24) or newer. To run the app on an Android 6 device, build your own APK from the 1.4.6 release commit (`6183afc2`, "bump version to 1.4.6").
|
||||||
|
|
||||||
|
Prerequisites: git, Node.js 20 (see `.nvmrc`), Java 21, and the Android SDK with `ANDROID_HOME` set (see [Android Emulator Without Android Studio](BUILDING.md#android-emulator-without-android-studio-command-line-only) for an SDK setup that skips Android Studio). With [pkgx](https://pkgx.dev), prefix the commands with `pkgx`, e.g. `pkgx npm ci`.
|
||||||
|
|
||||||
|
1. Get the 1.4.6 source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa.git
|
||||||
|
cd crowd-funder-for-time-pwa
|
||||||
|
git checkout 6183afc2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm the minimum SDK is 23:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep minSdkVersion android/variables.gradle # expect: minSdkVersion = 23
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies and build a production APK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build:android:prod -- --apk
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm ci` installs the exact dependency versions locked for 1.4.6. `build:android:prod -- --apk` builds the web app with production server settings, syncs it into the Android project, and runs `./gradlew assembleDebug`. The result is signed with your local Android debug key, so it installs on any device without a release keystore.
|
||||||
|
|
||||||
|
4. Find the APK at `android/app/build/outputs/apk/debug/app-debug.apk` and install it, either with `adb install android/app/build/outputs/apk/debug/app-debug.apk` (USB debugging enabled on the phone) or by copying the file to the phone and opening it (allow "Install unknown apps" for the file manager).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* **Back up your identity first.** A self-built APK has a different signature from the Play Store app, so Android refuses to install it over the store version. Uninstalling the store version deletes the app's local data, including your identifier keys. Back up your seed phrase (Profile page → "Backup Identifier Seed") before uninstalling, then import it into the self-built app.
|
||||||
|
* This build has Firebase disabled, so push notifications through Google's FCM service are off.
|
||||||
|
* The daily notification plugin calls `java.time`, which Android provides only from 8.0 (API 26). On Android 6, scheduling a daily reminder can crash the app; leave daily reminders off. See [doc/plugin-feedback-android-6-api23-zoneid-fix.md](doc/plugin-feedback-android-6-api23-zoneid-fix.md).
|
||||||
|
* Barcode scanning uses Google Play Services; on devices without them, scanning fails while the rest of the app works.
|
||||||
|
* A self-built APK does not auto-update.
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
||||||
|
|||||||
+32
-17
@@ -29,6 +29,8 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
// Backports java.time (API 26+) to older Android; the daily notification plugin uses it.
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
@@ -37,8 +39,8 @@ android {
|
|||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 73
|
versionCode 76
|
||||||
versionName "1.4.7"
|
versionName "1.4.10"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -101,6 +103,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
@@ -130,20 +133,32 @@ dependencies {
|
|||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
// Firebase / Google Play Services are opt-in. Pass -PfirebaseEnabled to any Gradle command
|
// Firebase is on for every build. Opt out only with -PfirebaseEnabled=false
|
||||||
// to activate Firebase (FCM push notifications). Without this flag the build works on
|
// (F-Droid / other builds that must not apply the google-services plugin).
|
||||||
// F-Droid, Aurora, Zapstore, and plain APK sideloading even when google-services.json
|
// The config file does not decide the switch: when Firebase is on, a missing
|
||||||
// is present on disk (it is gitignored; see AGENTS.md for the full story).
|
// file fails the build; when it is off, a file on disk is ignored.
|
||||||
try {
|
def firebaseEnabledRaw = project.findProperty('firebaseEnabled')
|
||||||
def servicesJSON = file('google-services.json')
|
def firebaseEnabled = true
|
||||||
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
|
if (firebaseEnabledRaw != null) {
|
||||||
apply plugin: 'com.google.gms.google-services'
|
def normalized = firebaseEnabledRaw.toString().trim().toLowerCase()
|
||||||
logger.info("Firebase enabled: google-services plugin applied")
|
if (normalized == 'false' || normalized == '0' || normalized == 'no') {
|
||||||
} else if (servicesJSON.exists() && !project.hasProperty('firebaseEnabled')) {
|
firebaseEnabled = false
|
||||||
logger.info("google-services.json present but firebaseEnabled not set — skipping Firebase plugin (pass -PfirebaseEnabled to enable)")
|
|
||||||
} else {
|
|
||||||
logger.info("google-services.json not found — Firebase plugin not applied")
|
|
||||||
}
|
}
|
||||||
} catch(Exception e) {
|
}
|
||||||
logger.info("google-services plugin not applied: ${e.message}")
|
|
||||||
|
if (!firebaseEnabled) {
|
||||||
|
logger.lifecycle('Firebase disabled (-PfirebaseEnabled=false): google-services plugin not applied')
|
||||||
|
} else {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
def servicesBody = servicesJSON.isFile() ? servicesJSON.getText('UTF-8') : ''
|
||||||
|
if (!servicesBody.trim() || !servicesBody.contains('mobilesdk_app_id')) {
|
||||||
|
throw new GradleException(
|
||||||
|
'Firebase is enabled, but android/app/google-services.json is missing, empty, or has no mobilesdk_app_id.\n' +
|
||||||
|
'Download it from the Firebase console (Project settings → Your apps → Android app)\n' +
|
||||||
|
'and place it at android/app/google-services.json.\n' +
|
||||||
|
'To build without Firebase (F-Droid / FOSS), pass -PfirebaseEnabled=false.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
logger.lifecycle('Firebase enabled: google-services plugin applied')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".TimeSafariApplication"
|
android:name=".TimeSafariApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"splashFullScreen": true,
|
"splashFullScreen": true,
|
||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -311,23 +311,25 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
|||||||
return prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
|
return prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the newest JWT ID in the response (the highest ULID), so the next fetch
|
||||||
|
* asks only for later changes. The server lists them newest first, so the last
|
||||||
|
* item is the oldest.
|
||||||
|
*/
|
||||||
private void updateLastAckedJwtIdFromResponse(String responseBody) {
|
private void updateLastAckedJwtIdFromResponse(String responseBody) {
|
||||||
try {
|
try {
|
||||||
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||||
if (!root.has("data")) return;
|
if (!root.has("data")) return;
|
||||||
JsonArray dataArray = root.getAsJsonArray("data");
|
JsonArray dataArray = root.getAsJsonArray("data");
|
||||||
if (dataArray == null || dataArray.size() == 0) return;
|
if (dataArray == null || dataArray.size() == 0) return;
|
||||||
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
|
|
||||||
String jwtId = null;
|
String jwtId = null;
|
||||||
if (lastItem.has("jwtId")) {
|
for (int i = 0; i < dataArray.size(); i++) {
|
||||||
jwtId = lastItem.get("jwtId").getAsString();
|
String itemJwtId = extractJwtIdFromItem(dataArray.get(i).getAsJsonObject());
|
||||||
} else if (lastItem.has("plan")) {
|
if (itemJwtId != null && !itemJwtId.isEmpty() && (jwtId == null || itemJwtId.compareTo(jwtId) > 0)) {
|
||||||
JsonObject plan = lastItem.getAsJsonObject("plan");
|
jwtId = itemJwtId;
|
||||||
if (plan.has("jwtId")) {
|
|
||||||
jwtId = plan.get("jwtId").getAsString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (jwtId != null && !jwtId.isEmpty()) {
|
if (jwtId != null) {
|
||||||
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ public class NotificationInspectorPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this build carries a Firebase config, i.e. was built with
|
* Whether this build carries a Firebase config. Normal builds do.
|
||||||
* -PfirebaseEnabled. Without it, PushNotifications.register() throws on the
|
* -PfirebaseEnabled=false omits it. Without it, PushNotifications.register() throws on the
|
||||||
* Capacitor plugin thread and kills the app, so JS must check this first.
|
* Capacitor plugin thread and kills the app, so JS must check this first.
|
||||||
* The google-services Gradle plugin generates the google_app_id resource,
|
* The google-services Gradle plugin generates the google_app_id resource,
|
||||||
* which is what Firebase's own auto-initialization looks for.
|
* which is what Firebase's own auto-initialization looks for.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup rules for Android 11 and lower; data_extraction_rules.xml has the same
|
||||||
|
exclusions for Android 12 and higher. Everything not excluded is backed up.
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys are never backed up. Restoring the preferences without the
|
||||||
|
key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup and device-transfer rules for Android 12 and higher; backup_rules.xml
|
||||||
|
has the same exclusions for Android 11 and lower. Everything not excluded is
|
||||||
|
backed up and transferred, including the database (which holds the user's keys).
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys never leave the device. Restoring the preferences without
|
||||||
|
the key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -22,3 +22,8 @@ org.gradle.jvmargs=-Xmx1536m --add-opens=jdk.compiler/com.sun.tools.javac.api=AL
|
|||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.suppressUnsupportedCompileSdk=36
|
android.suppressUnsupportedCompileSdk=36
|
||||||
|
|
||||||
|
# Firebase/FCM is on for every Gradle build (Android Studio, ./gradlew, npm scripts).
|
||||||
|
# Opt out with -PfirebaseEnabled=false. android/app/google-services.json does not
|
||||||
|
# toggle this; a missing file fails the build while Firebase is enabled.
|
||||||
|
firebaseEnabled=true
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
+4
-2
@@ -34,8 +34,10 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
// Installed iOS apps keep their database in Documents; changing this strands existing data.
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Plan: Contacts Without a DID ("Placeholder Contacts")
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a user record a gift (or offer) given by or to a person who has not
|
||||||
|
installed the app, under that person's name, and later swap that person's real
|
||||||
|
DID into those claims once they join.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A locally saved name for a person with no DID, plus everything a real
|
||||||
|
contact can hold locally: phone numbers and other contact methods, notes,
|
||||||
|
and labels, edited through the same screens.
|
||||||
|
2. That person can be picked as the giver or recipient in the gift dialogs,
|
||||||
|
the same way a real contact is.
|
||||||
|
3. The app keeps a local list of every claim that names the person, so the
|
||||||
|
claims can be found again without a server search.
|
||||||
|
4. When the person joins, the user links the placeholder to the person's real
|
||||||
|
contact, and the app edits each linked claim so the real DID appears
|
||||||
|
where the placeholder was.
|
||||||
|
5. Nothing that identifies a placeholder — its name or its local key — is
|
||||||
|
sent to the endorser server or shared with other users.
|
||||||
|
6. When the app links contacts to the device's address book, a placeholder
|
||||||
|
links the same way a real contact does, and the link survives the
|
||||||
|
placeholder becoming a real contact.
|
||||||
|
|
||||||
|
## Relevant constraints in the current schema
|
||||||
|
|
||||||
|
These come from `src/db-sql/migration.ts` and
|
||||||
|
`src/utils/PlatformServiceMixin.ts`.
|
||||||
|
|
||||||
|
| Constraint | Where | Effect on this feature |
|
||||||
|
|---|---|---|
|
||||||
|
| `contacts.did TEXT NOT NULL` | migration 008 | A contact row must have some DID value. |
|
||||||
|
| `UNIQUE INDEX idx_contacts_did ON contacts(did)` | migration 008 | Every contact's DID must be distinct. (SQLite allows multiple NULLs in a unique index, so the index alone would not block NULL DIDs; `NOT NULL` does.) |
|
||||||
|
| `contact_labels.did ... REFERENCES contacts(did) ON DELETE CASCADE` | migration 008 | Labels are keyed by DID. There is no `ON UPDATE` action, so `UPDATE contacts SET did = ...` fails with `FOREIGN KEY constraint failed` while the contact has labels (verified with the `sqlite3` CLI), when foreign keys are enforced on the connection. |
|
||||||
|
| `$insertContact` uses `INSERT OR REPLACE` | `PlatformServiceMixin.ts` | REPLACE deletes the conflicting row before inserting. With foreign keys enforced, that delete cascades and wipes the contact's labels. The new row also gets a new `id`, because `id` is not in the insert's column list (both verified with the `sqlite3` CLI). Any table that references `contacts(did)` with `ON DELETE CASCADE` inherits the first hazard, and anything that references `contacts.id` inherits the second. Callers: `ContactsView`, `InviteOneView`, `MeetingMembersList`, `BulkMembersDialog`. |
|
||||||
|
| `Contact.did: string` (required) | `src/db/tables/contacts.ts` | Every view, lookup (`contactForDid`, `didInfo`), and mixin method (`$getContact`, `$updateContact`, `$deleteContact`, label methods) is keyed by DID string. |
|
||||||
|
| `isDid(s)` is `s.startsWith("did:")` | `src/libs/endorserServer.ts` | Any string starting with `did:` is treated as a real DID by import validation, display logic, and server calls. |
|
||||||
|
|
||||||
|
Whether foreign keys are enforced at runtime depends on whether a
|
||||||
|
`PRAGMA foreign_keys = ON` took effect on the current connection; nothing
|
||||||
|
outside the migrations sets it. The plan below assumes they may be enforced
|
||||||
|
and must work either way.
|
||||||
|
|
||||||
|
## Recommended: local placeholder key in `contacts`, no DID in the claim
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
- A placeholder is an ordinary row in `contacts` whose `did` column holds a
|
||||||
|
local-only key: `local:` followed by a random UUID, e.g.
|
||||||
|
`local:3f0c9a8e-5d2b-4c1e-9b7a-2e6f1d0c4a11`.
|
||||||
|
- The key deliberately does **not** start with `did:`, so `isDid()` is false
|
||||||
|
for it and every existing DID-only code path rejects it instead of treating
|
||||||
|
it as real.
|
||||||
|
- Claims naming a placeholder carry **no** agent/recipient DID in that slot —
|
||||||
|
the same shape the gift dialog already sends for "Unnamed".
|
||||||
|
- A new table records which claims involve which placeholder, so they can be
|
||||||
|
edited when the real DID is known.
|
||||||
|
|
||||||
|
The only schema change is one additive `CREATE TABLE`. The `contacts` and
|
||||||
|
`contact_labels` tables are untouched, so no table rebuild is needed (a
|
||||||
|
rebuild is the risky part of migration 008).
|
||||||
|
|
||||||
|
### Schema change (migration 012)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Claims that name a placeholder contact (contacts.did LIKE 'local:%') in place of a DID,
|
||||||
|
-- kept so they can be edited to carry the real DID once that person joins.
|
||||||
|
CREATE TABLE placeholder_claims (
|
||||||
|
contactKey TEXT NOT NULL, -- contacts.did: the placeholder key, or the real DID after linking
|
||||||
|
claimHandleId TEXT NOT NULL, -- handleId from the server; stays the same across edits
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('agent', 'recipient')),
|
||||||
|
claimType TEXT NOT NULL, -- eg. 'GiveAction', 'Offer'
|
||||||
|
issuerDid TEXT NOT NULL, -- only this identity can edit the claim
|
||||||
|
apiServer TEXT NOT NULL, -- the server the claim was sent to
|
||||||
|
dateCreated TEXT NOT NULL,
|
||||||
|
dateAssigned TEXT, -- set when the claim has been edited to carry the real DID
|
||||||
|
PRIMARY KEY (contactKey, claimHandleId, role)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_placeholder_claims_key ON placeholder_claims(contactKey);
|
||||||
|
```
|
||||||
|
|
||||||
|
The column is named for the contact rather than the placeholder because
|
||||||
|
linking rekeys it to the real DID along with every other contact-keyed column (see
|
||||||
|
*Linking when the person joins*). A row with a real DID and
|
||||||
|
`dateAssigned IS NULL` is a claim still waiting to be edited.
|
||||||
|
|
||||||
|
No foreign key to `contacts`: a cascade would let any `INSERT OR REPLACE` of
|
||||||
|
the placeholder contact silently delete its claim links (see the
|
||||||
|
`$insertContact` row above), and those links are the only record of which
|
||||||
|
claims to fix. Orphan cleanup happens in code when a placeholder is deleted
|
||||||
|
on purpose.
|
||||||
|
|
||||||
|
The `role` column is part of the key because one claim can name the same
|
||||||
|
placeholder as both giver and recipient.
|
||||||
|
|
||||||
|
### Code changes
|
||||||
|
|
||||||
|
**Prerequisite: make `$insertContact` an upsert that keeps the row**
|
||||||
|
|
||||||
|
Change `INSERT OR REPLACE` to an `UPDATE ... WHERE did = ?`, followed by an
|
||||||
|
`INSERT` only when no row was updated. That keeps the row's `id` and leaves
|
||||||
|
its labels and other contact-keyed rows alone. An `INSERT ... ON CONFLICT(did)
|
||||||
|
DO UPDATE` does the same in one statement, but needs SQLite 3.24+ on every
|
||||||
|
platform's driver, and that hasn't been checked. This fixes an existing
|
||||||
|
label-loss hazard and is worth shipping first, on its own.
|
||||||
|
|
||||||
|
**Identity helpers** (`src/libs/endorserServer.ts`)
|
||||||
|
|
||||||
|
- `PLACEHOLDER_KEY_PREFIX = "local:"`, `isPlaceholderKey(s)`, and
|
||||||
|
`newPlaceholderKey()` (uses `crypto.randomUUID()`).
|
||||||
|
- `didInfoForContact`: a placeholder contact displays its name, with a marker
|
||||||
|
such as "(not on Time Safari yet)", and `known: true`.
|
||||||
|
|
||||||
|
**Creating a placeholder**
|
||||||
|
|
||||||
|
- Contacts view: an "Add someone without the app" action that asks only for
|
||||||
|
a name (notes and contact methods optional) and saves a contact with a new
|
||||||
|
placeholder key.
|
||||||
|
- Gift dialog entity picker: the same action inline, so a user can create a
|
||||||
|
placeholder while recording a gift.
|
||||||
|
- Device address book (once that integration exists): pick a person from the
|
||||||
|
phone's contacts. The placeholder is created with that name and phone
|
||||||
|
number filled in and already linked to the device contact.
|
||||||
|
|
||||||
|
**Phone numbers, notes, and labels**
|
||||||
|
|
||||||
|
These need no new storage. `ContactEditView` (`/contact-edit/:did`) edits
|
||||||
|
`contactMethods` (the JSON column holding phone numbers, emails, and so
|
||||||
|
on), `notes`, and labels through `contact_labels`, all keyed by the
|
||||||
|
`contacts.did` value and with no server calls. A placeholder key in that
|
||||||
|
route parameter works as-is. Changes needed:
|
||||||
|
|
||||||
|
- Hide the server-backed parts of contact pages (visibility, registration,
|
||||||
|
the DID/key display) for placeholders. The edit form itself stays as it
|
||||||
|
is.
|
||||||
|
- Store phone numbers in a normalized form (E.164, e.g. `+18015550100`)
|
||||||
|
alongside what the user typed, so they can be compared for matching when
|
||||||
|
the person joins (see *Linking*). Normalizing on save is cheaper than
|
||||||
|
normalizing on every comparison.
|
||||||
|
|
||||||
|
**Device address-book links**
|
||||||
|
|
||||||
|
Whatever table holds the link between an app contact and a device contact
|
||||||
|
(Android lookup key / iOS `CNContact` identifier) is keyed by the
|
||||||
|
`contacts.did` value, the same as `contact_labels`. That gives real contacts
|
||||||
|
and placeholders one shared mechanism, and linking rekeys the link along
|
||||||
|
with everything else. Two rules for that table:
|
||||||
|
|
||||||
|
- No `ON DELETE CASCADE` foreign key to `contacts`, for the same reason as
|
||||||
|
`placeholder_claims`. Code cleans up these rows when a contact is
|
||||||
|
deleted on purpose.
|
||||||
|
- Device identifiers mean nothing on another device, so a backup restored
|
||||||
|
elsewhere either leaves these rows out or re-checks them against that
|
||||||
|
device's address book.
|
||||||
|
|
||||||
|
**One contact-keyed registry**
|
||||||
|
|
||||||
|
Every table with a column holding a `contacts.did` value is listed in one
|
||||||
|
constant, e.g. `CONTACT_KEYED_COLUMNS = [["contact_labels", "did"],
|
||||||
|
["placeholder_claims", "contactKey"], ...]`, which `rekeyContact` and
|
||||||
|
`deleteContact` iterate over. A test compares that list with the live
|
||||||
|
schema: every foreign key to `contacts(did)` (from `PRAGMA
|
||||||
|
foreign_key_list`), plus any column named `contactKey`. A table added later
|
||||||
|
and left out of the list fails the test, so it can't be orphaned by a
|
||||||
|
rekey.
|
||||||
|
|
||||||
|
**Recording a claim** (`GiftedDialog`, offer dialog, `hydrateGive`, `hydrateOffer` callers)
|
||||||
|
|
||||||
|
- When the chosen giver or recipient is a placeholder, pass `undefined` for
|
||||||
|
that DID to `createAndSubmitGive` / `createAndSubmitOffer`.
|
||||||
|
- On success, insert a `placeholder_claims` row with the returned `handleId`,
|
||||||
|
the role, the active DID as issuer, and the API server.
|
||||||
|
- If the submission succeeds but the local insert fails, show an error that
|
||||||
|
names the claim, because the link would otherwise be lost silently.
|
||||||
|
- A placeholder on both sides is allowed: the server accepts a give with no
|
||||||
|
agent, no recipient, and no project.
|
||||||
|
|
||||||
|
**Choosing a giver or recipient** (`EntityGrid`, `EntitySelectionStep`, `PersonCard`)
|
||||||
|
|
||||||
|
Placeholders appear in the picker in alphabetical order, searchable by name,
|
||||||
|
mixed in with real contacts. Because a placeholder is a row in `contacts`,
|
||||||
|
this needs almost no list-merging work:
|
||||||
|
|
||||||
|
- `GiftedDialog` passes `$contactsByDateAdded()` to `EntityGrid`, which
|
||||||
|
already builds both sections from that one array: "Recently Added" (the
|
||||||
|
first rows, newest by `id`) and "Everyone" (sorted by
|
||||||
|
`(name || did).toLowerCase()` with `localeCompare`). Placeholders land in
|
||||||
|
both sections with no extra query and no merge step.
|
||||||
|
- `performContactSearch` filters the same array by name and DID substring
|
||||||
|
and sorts it the same way, so placeholders are searchable automatically.
|
||||||
|
|
||||||
|
What does need care:
|
||||||
|
|
||||||
|
- **Require a name.** Both sorts fall back to the DID when the name is blank,
|
||||||
|
which would put a nameless placeholder under "L" for `local:`. Saving a
|
||||||
|
placeholder requires a non-blank, trimmed name.
|
||||||
|
- **Don't match on the key.** Searching "local" or a UUID fragment would
|
||||||
|
match every placeholder. In `performContactSearch`, compare against the
|
||||||
|
DID only when `!isPlaceholderKey(contact.did)`.
|
||||||
|
- **One comparator.** The sort is written out twice (`alphabeticalContacts`
|
||||||
|
and `performContactSearch`). Pull it into one exported function, e.g.
|
||||||
|
`compareContactsByName`, so a placeholder tiebreak only has to be added
|
||||||
|
once. For a tie, put real contacts before placeholders and then compare
|
||||||
|
keys, so a real "Bob" and a placeholder "Bob" always come out in the same
|
||||||
|
order.
|
||||||
|
- **Mark placeholders on the card.** `PersonCard` shows a small badge or
|
||||||
|
subtitle ("not on Time Safari yet"), and `EntityIcon` shows a fixed
|
||||||
|
"person without the app" icon rather than an identicon built from the
|
||||||
|
key. This matters most when two people share a name.
|
||||||
|
- **Create from search.** When a contact search finds nothing (the
|
||||||
|
`filteredEntities.length === 0` message), offer "Add “<term>” as someone
|
||||||
|
without the app". The new placeholder is selected immediately and the
|
||||||
|
dialog moves to the next step. Next to the "Unnamed" `SpecialEntityCard`,
|
||||||
|
an "Add someone without the app" card covers the case where nothing has
|
||||||
|
been typed yet.
|
||||||
|
- **Keep the selection key-based.** `handlePersonSelected` and
|
||||||
|
`isPersonConflicted` compare `did` strings, and the placeholder key works
|
||||||
|
there unchanged. Convert key → `undefined` only at submission time (see
|
||||||
|
*Recording a claim*), never in the picker, or two placeholders would
|
||||||
|
look identical to the conflict check.
|
||||||
|
- **Filter where a real DID is required.** `ProjectRepresentativeDialog`
|
||||||
|
uses the same grid to pick someone who will act on the server, so it gets
|
||||||
|
`allContacts.filter(c => !isPlaceholderKey(c.did))`. The same goes for any
|
||||||
|
other picker whose choice is sent to the server as a DID.
|
||||||
|
|
||||||
|
The `EntityGrid` doc comment says arrays must be sorted by date added
|
||||||
|
(newest first). That holds for placeholders because they get ordinary
|
||||||
|
autoincrement `id`s.
|
||||||
|
|
||||||
|
**Guards: a placeholder key must never reach the server or another user**
|
||||||
|
|
||||||
|
Every place that sends a contact's DID somewhere needs to skip or refuse
|
||||||
|
placeholders. From the current code, that includes:
|
||||||
|
|
||||||
|
- Visibility toggles and checks (`setVisibilityUtil`, "sees me" refresh)
|
||||||
|
- Registration (`register`) and invite flows
|
||||||
|
- Contact sharing: QR/JWT URL generation, CSV export (`CONTACT_CSV_HEADER`),
|
||||||
|
"copy selected contacts", `contactImportPayload`
|
||||||
|
- Server-side give/offer totals per contact in `ContactsView` (`loadGives`)
|
||||||
|
- The `/did/:did` route (`DIDView`), which queries the server: placeholders
|
||||||
|
open a local-only detail page instead
|
||||||
|
- Meeting member flows (`MeetingMembersList`, `BulkMembersDialog`)
|
||||||
|
|
||||||
|
As a backstop, `createAndSubmitClaim` throws if the serialized claim contains
|
||||||
|
any `local:` key, so a missed guard fails loudly rather than publishing the
|
||||||
|
key.
|
||||||
|
|
||||||
|
**Backups**: the full data export (DataExportSection / backup) **includes**
|
||||||
|
placeholders and the `placeholder_claims` table, since they exist only on
|
||||||
|
this device. Contact sharing with other users **excludes** them.
|
||||||
|
|
||||||
|
**Linking when the person joins**
|
||||||
|
|
||||||
|
The placeholder's row **becomes** the real contact: its `did` value is
|
||||||
|
rekeyed from the `local:` key to the real DID. Its `id`, phone numbers,
|
||||||
|
notes, labels, device link, and claim rows all stay with it. Two contact
|
||||||
|
rows are never kept for one person.
|
||||||
|
|
||||||
|
1. The person's DID arrives the usual way (QR scan, contact link). Before
|
||||||
|
inserting it, the app checks for placeholders that match: the same
|
||||||
|
normalized phone number, the same device-contact link, or the same name.
|
||||||
|
It asks "Is this <name>, whom you recorded without the app?" The user can
|
||||||
|
also start from the placeholder's page ("This person joined") and pick a
|
||||||
|
contact that already exists.
|
||||||
|
2. `rekeyContact(fromKey, toDid)`, in one transaction:
|
||||||
|
1. Read every contact-keyed row (from the registry) for both `fromKey`
|
||||||
|
and `toDid`.
|
||||||
|
2. If a contact row with `toDid` already exists, copy its server-derived
|
||||||
|
fields (`publicKeyBase64`, `nextPubKeyHashB64`, `seesMe`,
|
||||||
|
`registered`, `profileImageUrl`) onto the placeholder row, and fill
|
||||||
|
any other field the placeholder left empty. Then delete that row's
|
||||||
|
dependent rows and the row itself.
|
||||||
|
3. Delete the dependent rows for `fromKey`.
|
||||||
|
4. `UPDATE contacts SET did = toDid WHERE did = fromKey`.
|
||||||
|
5. Re-insert the union of both sets of dependent rows under `toDid`
|
||||||
|
(`INSERT OR IGNORE`, so a label both rows had is kept once).
|
||||||
|
|
||||||
|
Dependent rows are deleted before the update and re-inserted after it
|
||||||
|
because `UPDATE contacts SET did` fails while `contact_labels` rows
|
||||||
|
reference the old value and foreign keys are enforced. This order works
|
||||||
|
whether or not they are enforced, so it doesn't rely on
|
||||||
|
`PRAGMA defer_foreign_keys`. (That pragma also works, verified with the
|
||||||
|
`sqlite3` CLI, but only inside an explicit transaction.)
|
||||||
|
3. Before any edits, warn about confirmations. Editing a claim does not
|
||||||
|
carry its confirmations over to the edited version, so the app lists the
|
||||||
|
linked claims that have confirmations (from the same data the claim
|
||||||
|
detail view shows) and says those confirmations will have to be given
|
||||||
|
again. The user can go ahead or cancel.
|
||||||
|
4. For each `placeholder_claims` row for this contact with
|
||||||
|
`dateAssigned IS NULL`:
|
||||||
|
- skip it, with a message, if `issuerDid` is not the active identity
|
||||||
|
(the server accepts edits only from the claim's issuer; the message
|
||||||
|
names which of this device's accounts to switch to, if any), or if its
|
||||||
|
`apiServer` differs from the active one;
|
||||||
|
- fetch the current claim by handle ID, call `editAndSubmitGive` /
|
||||||
|
`editAndSubmitOffer` with the real DID in the row's role, keeping every
|
||||||
|
other field;
|
||||||
|
- set `dateAssigned` on success.
|
||||||
|
5. When every row is assigned, its `placeholder_claims` rows can be
|
||||||
|
deleted; the contact itself stays, as an ordinary real contact. Any
|
||||||
|
unassigned rows stay listed on the contact's page with a retry button,
|
||||||
|
since some edits may need another account or a network connection.
|
||||||
|
6. Prompt the user to tell the person. Once at least one claim is assigned,
|
||||||
|
offer to share a message (through the share sheet, or a contact method
|
||||||
|
saved on the placeholder) saying their earlier gifts are recorded under
|
||||||
|
their identity, with a link to their gifts.
|
||||||
|
|
||||||
|
`hydrateGive` / `hydrateOffer` only set agent/recipient when a DID is passed
|
||||||
|
and never clear them. That is correct for this flow (it only ever adds a
|
||||||
|
DID), but a test should pin it so a later "clear when blank" change does not
|
||||||
|
break assignment.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Migration test (`src/test/migrations.test.ts`): 012 applies on a database
|
||||||
|
that already has contacts and labels, and leaves them unchanged.
|
||||||
|
- `createAndSubmitClaim` rejects a claim containing a `local:` key.
|
||||||
|
- `$insertContact` on an existing DID keeps the row's `id` and its labels,
|
||||||
|
with foreign keys both on and off.
|
||||||
|
- `rekeyContact`, with foreign keys both on and off: labels, claim rows,
|
||||||
|
and device links move to the real DID; the row's `id` is unchanged; when
|
||||||
|
a row for the real DID already existed, it is folded in and its labels
|
||||||
|
are kept.
|
||||||
|
- Registry coverage: every foreign key to `contacts(did)` and every
|
||||||
|
`contactKey` column in the migrated schema appears in
|
||||||
|
`CONTACT_KEYED_COLUMNS`.
|
||||||
|
- Each guard listed above returns early (or hides the control) for a
|
||||||
|
placeholder.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
### Make `contacts.did` nullable and key contacts by `contacts.id`
|
||||||
|
|
||||||
|
The relationally clean model: a contact without a DID is just a row with
|
||||||
|
`did IS NULL`.
|
||||||
|
|
||||||
|
- Requires rebuilding `contacts` (dropping `NOT NULL` is not possible with
|
||||||
|
`ALTER TABLE` in SQLite) and rebuilding `contact_labels` to reference
|
||||||
|
`contacts.id` — the same backup/drop/recreate pattern that made migration
|
||||||
|
008 fail on every startup for some users.
|
||||||
|
- `Contact.did` becomes optional, which breaks type-checking at every lookup,
|
||||||
|
route, and mixin method keyed by DID across roughly 70 files, and every
|
||||||
|
`WHERE did = ?` method needs an `id` variant.
|
||||||
|
- Gains nothing functional over the placeholder key: the key is a surrogate
|
||||||
|
ID that happens to live in the existing column.
|
||||||
|
- Its one real advantage is that linking a DID never rekeys child rows. That
|
||||||
|
advantage depends on `id` being stable, and it isn't: `INSERT OR REPLACE`
|
||||||
|
gives the row a new `id`, and contact export/import doesn't keep `id`, so
|
||||||
|
ids don't match across devices. The `rekeyContact` registry gets the same
|
||||||
|
safety without a table rebuild.
|
||||||
|
|
||||||
|
Worth revisiting only if contacts get rekeyed by `id` for some other reason.
|
||||||
|
|
||||||
|
### A separate, permanent `contactKey` column on `contacts`
|
||||||
|
|
||||||
|
Add `contacts.contactKey TEXT UNIQUE` that never changes (the `local:` key
|
||||||
|
for placeholders, the DID for everyone else), and key every child table by
|
||||||
|
it, so linking only sets `did`. It is additive for new tables, but
|
||||||
|
`contact_labels` references `contacts(did)`, so moving it to the new key is
|
||||||
|
a table rebuild. Every DID-keyed lookup would also need to decide which key
|
||||||
|
it means. It costs more than the rekey function it replaces.
|
||||||
|
|
||||||
|
### Separate `placeholder_people` table outside `contacts`
|
||||||
|
|
||||||
|
No risk to existing tables, but every picker and list (gift dialog entity
|
||||||
|
grid, contacts list, labels, search) must merge two sources of different
|
||||||
|
types, and labels need a second mapping table. The recommended option gets
|
||||||
|
those UIs for free because a placeholder is a contact.
|
||||||
|
|
||||||
|
### Fake DID written into the published claim
|
||||||
|
|
||||||
|
E.g. `did:none:<uuid>` or a random `did:ethr:` with no key behind it, sent
|
||||||
|
to the server as the agent/recipient.
|
||||||
|
|
||||||
|
- Publishes a dangling identifier that other users see as "Someone Visible
|
||||||
|
But Not In Your Contact List"; it never resolves.
|
||||||
|
- `did:none:` is the server's hidden-DID namespace (`did:none:HIDDEN`);
|
||||||
|
reusing the prefix risks the server treating it as hidden or rejecting it.
|
||||||
|
- The claim still has to be edited when the person joins, so it saves no
|
||||||
|
work; the only gain is finding claims by server search, which the local
|
||||||
|
table already provides.
|
||||||
|
|
||||||
|
### Custodial key: generate a real identity for the person
|
||||||
|
|
||||||
|
The app creates a real keypair for the placeholder and uses its DID in claims;
|
||||||
|
when the person joins, they import that key, so no claims need editing.
|
||||||
|
|
||||||
|
- The user's device holds another person's private key, and every claim
|
||||||
|
"about" them was made while someone else held their identity.
|
||||||
|
- The person may already have an identity, or may install on a device
|
||||||
|
without a way to receive the key; then the claims need editing anyway.
|
||||||
|
- Handing over a seed phrase is a security-sensitive step this app's
|
||||||
|
onboarding does not have.
|
||||||
|
|
||||||
|
### Put the person's name in the claim (`agent: { name: "..." }`)
|
||||||
|
|
||||||
|
- Publishes a name for someone who has not consented to being on the
|
||||||
|
platform; server visibility rules hide DIDs, not free-text names.
|
||||||
|
- Still needs the local record to assign the DID later.
|
||||||
|
- Server acceptance of an agent without `identifier` is unverified.
|
||||||
|
|
||||||
|
### Store linked claim IDs as a JSON column on `contacts`
|
||||||
|
|
||||||
|
An `ALTER TABLE contacts ADD COLUMN placeholderClaims TEXT` is also additive,
|
||||||
|
but role, issuer, server, and assignment status per claim fit a table
|
||||||
|
better, and any whole-row rewrite of the contact (such as `INSERT OR
|
||||||
|
REPLACE` from a stale copy) could overwrite the list.
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Plan: New Activity Screen on alertSearch
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
|
||||||
|
shows the same total the daily push or text reported, broken down by kind of
|
||||||
|
activity. Each kind opens a page with the exact list and count for that kind.
|
||||||
|
When a page's count differs from the number the person was told, the page
|
||||||
|
says why.
|
||||||
|
|
||||||
|
## The two endpoints
|
||||||
|
|
||||||
|
alertSearch has two halves. Each returns **lists**, not counts; a count is a
|
||||||
|
list's length.
|
||||||
|
|
||||||
|
| Bucket | Server | What it holds | Needs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `claims` | endorser `/api/v2/report/alertSearch` | Claims by others with you as `toEntity` or `fromEntity` (offers, gives, every other claim type) | — |
|
||||||
|
| `personalPlanContributions` | endorser | Gives and offers to or from projects you issued or are agent of (your own claims included) | — |
|
||||||
|
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
|
||||||
|
| `trackedPlanClaims` | endorser | Gives and offers to or from starred projects | `planHandleIds` |
|
||||||
|
| `plansNearby` | endorser | Projects created or edited in the search area | `location` |
|
||||||
|
| `profilesNearby` | partner `/api/partner/alertSearch` | Profiles created or edited in the search area | `location` |
|
||||||
|
| `profilesSimilar` | partner | Profiles close in meaning to your profile, best first, at most 50, not paged | a profile with text |
|
||||||
|
| `plansSimilar` | partner | Projects close in meaning to your profile, best first, at most 50, not paged | a profile with text |
|
||||||
|
|
||||||
|
Paging facts (from the endorser-ch handlers):
|
||||||
|
|
||||||
|
- Each server has one cursor covering all of its buckets. Endorser cursors
|
||||||
|
look like `1234567`; partner cursors look like `45678-1234567` (partner
|
||||||
|
position, then main position). Clients treat both as opaque text, compared
|
||||||
|
only for equality.
|
||||||
|
- `afterCursor` returns changes after it, oldest first; `beforeCursor` returns
|
||||||
|
changes before it, newest first. Both together bound a window.
|
||||||
|
- A page holds at most 50 rows per list, all cut at the same change point;
|
||||||
|
`hasMore` is one flag for the whole page.
|
||||||
|
- A row's `changeSeq` is not a cursor, so no cursor points at a single item.
|
||||||
|
|
||||||
|
## How the notification service counts
|
||||||
|
|
||||||
|
`notification-wakeup-service` runs one daily alertSearch per channel and
|
||||||
|
identity:
|
||||||
|
|
||||||
|
- `src/alertSearch/retrieve.ts` calls both endpoints with the channel's stored
|
||||||
|
`afterCursor`, `planHandleIds` and `location`, pages forward while `hasMore`
|
||||||
|
(at most 20 pages), and merges the pages.
|
||||||
|
- `src/alertSearch/digest.ts` totals all eight buckets.
|
||||||
|
- Cursors are stored per channel (`fcm` and `sms` in separate tables) and
|
||||||
|
advance after every run in which both sources complete, sent or not. Each
|
||||||
|
daily count therefore covers only changes after the previous run, whether or
|
||||||
|
not the person opened the app in between, so no item is counted in two
|
||||||
|
notifications. Rejected: subtracting the previous day's count, which would
|
||||||
|
subtract it twice.
|
||||||
|
- An upload carrying a cursor for a server replaces that server's stored
|
||||||
|
cursor, and the first run counts from it. An upload without one leaves the
|
||||||
|
stored cursor alone.
|
||||||
|
- A run with no stored cursor for a server only stores that server's cursor
|
||||||
|
and counts nothing from it. The app avoids this by sending cursors with
|
||||||
|
every upload that turns a channel on (see "Starting from the latest
|
||||||
|
change").
|
||||||
|
- The SMS body is `Gift Economies: you have N new updates.
|
||||||
|
https://giftopia.me/deep-link/new-activity?c=<start cursors> Reply STOP to
|
||||||
|
end.` The FCM payload carries title, body, `type: "alert_search"` and `c`.
|
||||||
|
|
||||||
|
## Read state
|
||||||
|
|
||||||
|
Stored as JSON in one settings column, `newActivityCursors`
|
||||||
|
(`NewActivityCursorState` in `src/libs/newActivity.ts`):
|
||||||
|
|
||||||
|
- **Visit window**, one cursor per server. The summary counts every bucket from
|
||||||
|
its start, the same window the notification service counts from once the
|
||||||
|
visit is uploaded to it, except a bucket whose list was opened after that
|
||||||
|
start was recorded: it counts from the end of that list (`summaryStart` in
|
||||||
|
`src/libs/newActivity.ts`, `countSummaryActivity` in
|
||||||
|
`src/services/newActivity.ts`).
|
||||||
|
- **Bucket window**, one per bucket, on that bucket's server. A detail page
|
||||||
|
lists everything since the end of its last load, so opening it marks the
|
||||||
|
bucket read; reopening it shows nothing new until more arrives, and "Show
|
||||||
|
earlier" lists what it showed before. A bucket never opened starts where the
|
||||||
|
summary does, so its first detail count equals the summary's.
|
||||||
|
- **Uploaded**, the cursors last sent to each notification channel.
|
||||||
|
|
||||||
|
A window records where a load counted from (`from`) and when that cursor was
|
||||||
|
recorded (`fromSeenAt`), where it ended (`to`) and when (`seenAt`), and when
|
||||||
|
the visit began (`at`). A summary load less than 30 minutes after `at` is the
|
||||||
|
same visit and counts from the same `from`, so reloading the summary keeps the
|
||||||
|
counts of unopened lists; a later load starts a new visit from the last
|
||||||
|
`to`. "Mark unread from this item" does not exist, since no cursor points at an
|
||||||
|
item.
|
||||||
|
|
||||||
|
The first load ever has no cursor. It only records where to start and shows
|
||||||
|
nothing as new:
|
||||||
|
|
||||||
|
1. The app sends each server a request with no cursor and no ID. The server
|
||||||
|
answers with its most recent rows and its latest cursors
|
||||||
|
(`nextAfterCursor`).
|
||||||
|
2. The app ignores the rows: every count is 0 and every list is empty
|
||||||
|
(`countServer` and `loadBucket` in `src/services/newActivity.ts` count
|
||||||
|
nothing without a start).
|
||||||
|
3. It stores a visit ending at those cursors (`seedIfNeeded`, `seededState`),
|
||||||
|
with a start time long past, so the next load counts from them.
|
||||||
|
|
||||||
|
Home and the detail pages seed before counting; the summary's own first load
|
||||||
|
does the same, since the end of a load with no start becomes the start of
|
||||||
|
the next visit. Turning on notifications seeds too, with the cursors it
|
||||||
|
uploads. Seeding needs both servers to answer; otherwise nothing is stored,
|
||||||
|
nothing shows as new, and the next load tries again. A detail page's "Show
|
||||||
|
earlier" still lists what came before the seed.
|
||||||
|
|
||||||
|
Arriving through a notification link with no visit yet counts from the
|
||||||
|
link's `c` instead.
|
||||||
|
|
||||||
|
Rejected: starting endorser after the oldest `lastAcked*JwtId` (`afterId`) so
|
||||||
|
items unread on the earlier screen stay unread; the app would then depend on
|
||||||
|
the ID path that endorser-ch plans to retire.
|
||||||
|
|
||||||
|
Opening a detail page also moves the `lastAcked*JwtId` read markers
|
||||||
|
(`claims` → offers to you, `personalPlanContributions` → offers to your
|
||||||
|
projects, `trackedPlanUpdates` → starred changes), which the Recent Offers
|
||||||
|
pages use to mark the last-read offer.
|
||||||
|
|
||||||
|
## Summary screen
|
||||||
|
|
||||||
|
- One alertSearch call per server from the visit start, paging forward while
|
||||||
|
`hasMore` up to 4 pages; past that a count shows "+".
|
||||||
|
- Same inputs as the notification service: `planHandleIds` from
|
||||||
|
`settings.starredPlanHandleIds`, `location` from the first
|
||||||
|
`settings.searchBoxes` entry converted from `BoundingBox` to
|
||||||
|
`minLocLat/maxLocLat/minLocLon/maxLocLon`.
|
||||||
|
- The total at the top is the sum of all eight buckets, as the service totals
|
||||||
|
them, so with no activity since the run it equals the notified number.
|
||||||
|
- With no search box, the location rows link to `/search-area`; with no
|
||||||
|
starred projects, the tracked rows show a hint.
|
||||||
|
- A server that fails shows "?" on its buckets and the error; the other
|
||||||
|
server's counts appear, and the visit does not move.
|
||||||
|
|
||||||
|
## Home screen
|
||||||
|
|
||||||
|
The Home screen shows one tile per bucket with something unseen, in the
|
||||||
|
summary's order, from the same eight alertSearch buckets
|
||||||
|
(`countUnseenActivity` in `src/services/newActivity.ts`).
|
||||||
|
|
||||||
|
- Each tile shows the count (with "+" past the page limit) and the bucket's
|
||||||
|
label, and opens that bucket's detail page with `?expected=` set to the
|
||||||
|
count. "View All New Activity For You" opens the summary.
|
||||||
|
- A bucket counts from the end of whichever load the person saw last: the
|
||||||
|
summary's visit or that bucket's own list (`unseenStart` in
|
||||||
|
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
|
||||||
|
uses a visit's start, so a summary just loaded shows no tiles, even inside
|
||||||
|
the 30-minute visit in which the summary keeps showing its counts.
|
||||||
|
- Buckets on one server with the same start share one request, so with no
|
||||||
|
list opened since the summary, Home makes one request per server.
|
||||||
|
- Home writes the cursor state only to seed it on the first load ever; it
|
||||||
|
never starts a visit otherwise, and never uploads to the notification
|
||||||
|
service.
|
||||||
|
- Tile test IDs are `newActivityNumber-<bucket>`.
|
||||||
|
|
||||||
|
## Detail pages
|
||||||
|
|
||||||
|
One view, `/new-activity/:bucket` (`NewActivityBucketView.vue`), calls the
|
||||||
|
same endpoint from the bucket start and shows only that bucket, so its count
|
||||||
|
is exact. A per-bucket legacy endpoint cannot match it: `/report/offers`
|
||||||
|
holds only offers, while `claims` holds every claim type that names you.
|
||||||
|
|
||||||
|
- The heading shows its own count. The summary and the Home tiles pass their
|
||||||
|
count as `?expected=`; when they differ, a note says how many came before
|
||||||
|
the tapped count started, or that the list has fewer than it showed.
|
||||||
|
Rejected: handing Home's rows to the detail page, which saves one request
|
||||||
|
while this query runs anyway after a deep link, a relaunch, a capped count
|
||||||
|
or a different bucket start.
|
||||||
|
- "Show more" continues forward; "Show earlier" pages back with
|
||||||
|
`beforeCursor` from the bucket start, listing items already seen.
|
||||||
|
- Rows (`NewActivityRow.vue`) link to `/claim/:id`, `/project/:handleId`,
|
||||||
|
`/user-profile/:rowId`; similarity matches show their match percentage.
|
||||||
|
- `trackedPlanUpdates` keeps the before/after diff (`PlanChangeDiff.vue`),
|
||||||
|
fed by `plansLastUpdatedBetween` for the page's handle IDs with `afterId`
|
||||||
|
just before the oldest edit, because alertSearch plan rows carry no earlier
|
||||||
|
claim.
|
||||||
|
- The `claims` and `personalPlanContributions` pages link to
|
||||||
|
`RecentOffersToUserView` and `RecentOffersToUserProjectsView`.
|
||||||
|
- endorser-ch returns `claim` on `claims` rows, so the page can say what was
|
||||||
|
offered or given.
|
||||||
|
|
||||||
|
## Re-registering notifications on visit
|
||||||
|
|
||||||
|
When the summary (`/new-activity`) opens a new visit and the push or text
|
||||||
|
channel is on, the app re-uploads that channel's alert authorization in the
|
||||||
|
background, after the counts are on screen: a fresh
|
||||||
|
JWT batch replacing the unused one, with the visit's end cursors, the starred
|
||||||
|
handle IDs and the search area. The next daily count then starts from this
|
||||||
|
visit and uses the person's current starred projects and area.
|
||||||
|
|
||||||
|
- It reuses `mintAlertAuthorizationBatch` and the POST
|
||||||
|
`/notifications/alert-authorization` and POST
|
||||||
|
`/notify-sms/alert-authorization` routes
|
||||||
|
(`src/services/notifications/newActivityRefresh.ts`).
|
||||||
|
- It is skipped inside the same visit, and when the channel already counts
|
||||||
|
from this visit's start and nothing is new.
|
||||||
|
- Detail pages and the Home screen never upload. A detail page's window is
|
||||||
|
narrower than the service's single cursor per server, so uploading it would
|
||||||
|
drop the other buckets' unseen items from the next notification.
|
||||||
|
- Changing the text time re-uploads without cursors, so nothing waiting for
|
||||||
|
the next text is dropped.
|
||||||
|
|
||||||
|
Notification service changes this needs:
|
||||||
|
|
||||||
|
1. `AlertAuthorizationRequestBody` accepts optional `endorserAfterCursor`,
|
||||||
|
`partnerAfterCursor`, `planHandleIds` and `location`; the routes write all
|
||||||
|
four into that channel's cursor table.
|
||||||
|
2. The daily run sends the stored `planHandleIds` and `location` to
|
||||||
|
alertSearch.
|
||||||
|
3. `replaceUnusedBatch` skips days that already hold a consumed JWT.
|
||||||
|
Without that, a re-upload after today's run installs an unused JWT for
|
||||||
|
today, and the scheduler runs and pushes a second time the same day (SMS
|
||||||
|
has a per-phone daily cap; FCM has none).
|
||||||
|
4. `profilesSimilar` and `plansSimilar` join the total on both sides:
|
||||||
|
`mergePartner` keeps them (deduplicated by profile `rowId` and plan
|
||||||
|
`handleId`) and `ALERT_SEARCH_DIGEST_BUCKETS` lists them.
|
||||||
|
5. Cursors advance only when both sources complete, so a half-failed run
|
||||||
|
repeats the whole window on its retry.
|
||||||
|
6. A run with no stored cursor for a source seeds that cursor and counts
|
||||||
|
nothing from it.
|
||||||
|
|
||||||
|
## Starting from the latest change
|
||||||
|
|
||||||
|
Turning on push or text notifications (the toggles in `AccountViewView.vue`)
|
||||||
|
sends the latest cursors with the first upload, so the first notification
|
||||||
|
counts only what happens after the person turned it on. The app gets those
|
||||||
|
cursors from one alertSearch call per server, made just before the upload,
|
||||||
|
and stores them as its visit when it has none. Each server stands alone: one
|
||||||
|
that does not answer gets the end of the last visit instead, and with neither
|
||||||
|
its cursor is left out of the upload. Service change 6 covers only that last
|
||||||
|
case.
|
||||||
|
|
||||||
|
## Notification links carry the window
|
||||||
|
|
||||||
|
Each run holds the cursors it counted from (`startCursors` on the daily
|
||||||
|
result). The text and the push carry them, so the app can tell whether it is
|
||||||
|
looking at the same window the person was told about.
|
||||||
|
|
||||||
|
- SMS URL: one parameter joining both servers' start cursors with `.`:
|
||||||
|
`https://giftopia.me/deep-link/new-activity?c=1234567.45678-1234567`.
|
||||||
|
That adds about 25 characters; the body stays inside one 160-character
|
||||||
|
segment for counts under 10,000. A first run counts nothing and sends no
|
||||||
|
text.
|
||||||
|
- FCM: the same `c` in the data payload, and a tap routes to
|
||||||
|
`/new-activity?c=…`.
|
||||||
|
- App: `deepLinkQuerySchemas["new-activity"]` accepts optional `c`. When `c`
|
||||||
|
differs from the visit start, the summary also counts from `c` and compares
|
||||||
|
totals: fewer since the visit means "you may have already seen some of what
|
||||||
|
it counted"; more means "this includes N from before that notification".
|
||||||
|
- Optional, for an exact replay: also send the run's end cursors and offer a
|
||||||
|
"Show what the notification counted" view using `afterCursor` = start and
|
||||||
|
`beforeCursor` = end. That doubles the URL addition and can push the text
|
||||||
|
past one segment.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
App (`crowd-funder-for-time-pwa`):
|
||||||
|
|
||||||
|
- [x] HTTP client and paging in `src/libs/alertSearch.ts`.
|
||||||
|
- [x] Bucket registry and cursor state in `src/libs/newActivity.ts`; loads in
|
||||||
|
`src/services/newActivity.ts`.
|
||||||
|
- [x] Settings column `newActivityCursors`, migration
|
||||||
|
`012_add_newActivityCursors_to_settings`.
|
||||||
|
- [x] `NewActivityView.vue` as the summary, with the `c` comparison.
|
||||||
|
- [x] `NewActivityBucketView.vue`, `NewActivityRow.vue`, `PlanChangeDiff.vue`,
|
||||||
|
route `/new-activity/:bucket`.
|
||||||
|
- [x] Re-upload on visit; latest cursors from the enable toggles.
|
||||||
|
- [x] `c` in the deep-link query schema; FCM taps open New Activity with `c`.
|
||||||
|
- [x] First load seeds cursors and shows nothing; no `afterId` sent.
|
||||||
|
- [x] Home tiles for all eight buckets, each opening its detail page, counted
|
||||||
|
from `unseenStart`; the legacy `/report/offers`,
|
||||||
|
`offersToPlansOwnedByMe` and starred-changes counts removed from Home.
|
||||||
|
- [x] Jest tests (`src/test/newActivity.test.ts`); Playwright specs 50 and 55
|
||||||
|
updated.
|
||||||
|
- [x] Playwright specs 50 and 55 pass against local servers in Chromium and
|
||||||
|
Firefox.
|
||||||
|
- [x] CHANGELOG entry (1.4.10).
|
||||||
|
- [x] Test and start-up fixes found while running the specs (Home records
|
||||||
|
where New Activity starts in parallel with the feed, Affirm Delivery waits
|
||||||
|
for the offer's project, a WebAssembly explanation on the start-up error
|
||||||
|
screen, test warm-up, retry and reporter).
|
||||||
|
- [ ] Release the app after endorser-ch is deployed, since detail rows read
|
||||||
|
`claim` from `alertSearch` — next: release (Trent)
|
||||||
|
|
||||||
|
Notification service (`notification-wakeup-service`):
|
||||||
|
|
||||||
|
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
|
||||||
|
CHANGELOG.
|
||||||
|
- [ ] Deploy to test and production — next: deploy, or tick this if done
|
||||||
|
(Trent)
|
||||||
|
|
||||||
|
endorser-ch:
|
||||||
|
|
||||||
|
- [x] `claim` on `claims` rows of `/api/v2/report/alertSearch`, CHANGELOG.
|
||||||
|
- [x] An Offer that names its plan in `itemOffered.isPartOf` (as the app
|
||||||
|
sends) gets `toEntity`, so it reaches the owner's
|
||||||
|
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
|
||||||
|
existing offers without moving `changeSeq`; test, CHANGELOG.
|
||||||
|
- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
|
||||||
|
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
|
||||||
|
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
|
||||||
|
next: commit (Trent)
|
||||||
|
- [ ] Deploy to test, then production, running Flyway on the main DB so V23
|
||||||
|
applies — next: deploy (Trent)
|
||||||
|
- [ ] Leave a person's own gives and offers out of their
|
||||||
|
`personalPlanContributions` (the query includes them when a person
|
||||||
|
contributes to their own project) — next: implement after the deploys above (agent)
|
||||||
|
- endorser-ch: add `AND j.issuer != ?`, bound to the requesting DID, to
|
||||||
|
both halves of the `UNION` in `jwtsForUserPlanContributions`
|
||||||
|
(`endorser.db.service.js`), as `jwtsWithDidAfterId` does.
|
||||||
|
- endorser-ch test in `controller-endorser-6-plans-totals.js`: user 1's own
|
||||||
|
offer to user 1's plan is absent from user 1's
|
||||||
|
`personalPlanContributions`, and user 2's offer is present.
|
||||||
|
- App spec 50 "Record an offer": record the offer as User 1
|
||||||
|
(`importUserFromAccount(page, "01")`) on a project User 0 owns, then
|
||||||
|
switch to User 0 to check the `personalPlanContributions` tile and list.
|
||||||
|
"Affirm delivery" then gets User 1 as giver and the project as recipient.
|
||||||
|
- endorser-ch CHANGELOG entry; the notification service needs no change,
|
||||||
|
since its counts come from the same query.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The native background fetcher (`TimeSafariNativeFetcher`) keeps its own
|
||||||
|
read state.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
Closed findings from reading the notification service before these changes
|
||||||
|
(2026-10-15):
|
||||||
|
|
||||||
|
- The schedulers called `runDailyAlertSearch(userId, now, {})`, and the upload
|
||||||
|
body had no field for `location` or `planHandleIds`, so `trackedPlanUpdates`,
|
||||||
|
`trackedPlanClaims`, `plansNearby` and `profilesNearby` were always empty: the
|
||||||
|
reported total was `claims` + `personalPlanContributions`.
|
||||||
|
- `profilesSimilar` and `plansSimilar` were dropped when partner pages merged.
|
||||||
|
- Each source's cursor advanced on its own, so when one source completed and
|
||||||
|
the other failed, the retry started past the completed source's rows and
|
||||||
|
never counted them.
|
||||||
|
- A first run counted the server's newest page, up to 50 rows per bucket of old
|
||||||
|
activity.
|
||||||
|
|
||||||
|
Closed decisions:
|
||||||
|
|
||||||
|
- 2026-10-16: the first load seeds cursors and shows nothing, replacing the
|
||||||
|
`afterId` handoff from the oldest `lastAcked*JwtId`, so the app sends
|
||||||
|
`alertSearch` only cursors and endorser-ch can retire the ID path. A person
|
||||||
|
upgrading with unread offers sees them under "Show earlier" and on the
|
||||||
|
Recent Offers pages, not as new.
|
||||||
|
- 2026-10-16: detail pages query `alertSearch` themselves and do not reuse
|
||||||
|
Home's rows; an in-memory hand-off would be a second path that every
|
||||||
|
entry other than a fresh, uncapped Home tile skips.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Plan: Web Push on the Web Build
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A user of the web build (browser tab or installed web app) can turn on the
|
||||||
|
Daily Reminder and New Activity notifications and receive them, matching what
|
||||||
|
native builds deliver.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A `DIRECT_NOTIFICATION` push shows its message as sent.
|
||||||
|
2. A `DAILY_CHECK` push shows the count of new activity for the active
|
||||||
|
identity, using the same Endorser query the native fetcher uses.
|
||||||
|
3. The service worker never holds a long-lived credential: it holds only
|
||||||
|
day-scoped JWTs, the same pool native builds hand to their background
|
||||||
|
fetcher.
|
||||||
|
4. The Notifications section of the Account view works on the web build.
|
||||||
|
|
||||||
|
## Can FCM replace Web Push?
|
||||||
|
|
||||||
|
No. Firebase Cloud Messaging on the web (the `firebase/messaging` SDK) is a
|
||||||
|
layer over the browser Push API: `getToken()` requires a registered service
|
||||||
|
worker (`firebase-messaging-sw.js` by default), and that worker must display the
|
||||||
|
notification, the same as a plain Web Push worker. FCM changes only the
|
||||||
|
sending side — the push server would call the FCM HTTP v1 API instead of
|
||||||
|
sending VAPID-signed Web Push requests — and it adds a Google dependency and
|
||||||
|
Firebase config to the web build. It removes none of the work below.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- `public/sw.js` is registered by `src/main.web.ts` on the web platform. It
|
||||||
|
handles only the Web Share Target POST; it has no `push` or
|
||||||
|
`notificationclick` handler.
|
||||||
|
- The Notifications section of the Account view renders only on native
|
||||||
|
(`v-if="isNativePlatform"`, `src/views/AccountViewView.vue:81`), and the
|
||||||
|
"Notification Push Server" setting is hidden with `v-if="false"`
|
||||||
|
(`src/views/AccountViewView.vue:719`). Web users cannot subscribe.
|
||||||
|
- `src/components/PushNotificationPermission.vue` contains the web
|
||||||
|
subscription flow:
|
||||||
|
- GET `{pushUrl}/web-push/vapid` for the VAPID key (`:242`);
|
||||||
|
- `pushManager.subscribe({ userVisibleOnly: true, ... })` (`:538`);
|
||||||
|
- POST of the subscription to the relative URL `/web-push/subscribe`
|
||||||
|
(`:583`), so the web host must proxy that path to the push server;
|
||||||
|
- `askPermission()` sends the worker the placeholder secret
|
||||||
|
`"temporary-secret"` via `SEND_LOCAL_DATA`, which nothing reads.
|
||||||
|
- The push server sends a JSON payload whose `title` is `DAILY_CHECK` (run the
|
||||||
|
new-activity check) or `DIRECT_NOTIFICATION` (show `message` as-is).
|
||||||
|
- Account data lives in SQLite through absurd-sql, which a service worker
|
||||||
|
cannot open. The worker therefore cannot decrypt the identity and sign its
|
||||||
|
own JWTs.
|
||||||
|
- Native builds solve the same problem with `mintBackgroundJwtTokenPool()`
|
||||||
|
(`src/libs/crypto/backgroundJwtPool.ts`): 100 JWTs, each valid for one UTC
|
||||||
|
day, stored at index `epochDay % 100`, handed to the background fetcher by
|
||||||
|
`configureNativeFetcherIfReady()`
|
||||||
|
(`src/services/notifications/nativeFetcherConfig.ts`). Passkey (`did:peer`)
|
||||||
|
identities cannot mint the pool and raise
|
||||||
|
`BackgroundJwtUnsupportedIdentityError`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
### 1. Service worker push handling (`public/sw.js`)
|
||||||
|
|
||||||
|
- `push`: parse the JSON payload.
|
||||||
|
- `DIRECT_NOTIFICATION`: `showNotification("Direct Message", { body: message })`.
|
||||||
|
- `DAILY_CHECK` (and any other title): take the token for today's slot from the
|
||||||
|
pool, GET `{apiServer}/api/v2/report/claims` with it, count claims newer
|
||||||
|
than the stored `lastNotifiedClaimId`, store the newest ID, and show "There
|
||||||
|
is 1 new activity" / "There are N new activities".
|
||||||
|
- `notificationclick`: close the notification, focus an open app window or open
|
||||||
|
`/`.
|
||||||
|
- Worker state (token pool, `apiServer`, `activeDid`, `lastNotifiedClaimId`)
|
||||||
|
lives in the worker's own IndexedDB database, never in the app's SQLite
|
||||||
|
database.
|
||||||
|
|
||||||
|
### 2. Hand the token pool to the worker
|
||||||
|
|
||||||
|
- On app start, identity change, and pool expiry, mint the pool with
|
||||||
|
`mintBackgroundJwtTokenPool()` and `postMessage` it to the worker together
|
||||||
|
with `apiServer` and `activeDid`. Reuse the trigger points in
|
||||||
|
`src/services/notifications/notificationAuthLifecycle.ts`
|
||||||
|
(`onNotificationAuthMayBeReady`).
|
||||||
|
- Delete the `SEND_LOCAL_DATA` / placeholder-secret path in
|
||||||
|
`PushNotificationPermission.vue`.
|
||||||
|
- Passkey identities: do not offer New Activity notifications (the Daily
|
||||||
|
Reminder needs no token).
|
||||||
|
|
||||||
|
### 3. Empty daily checks
|
||||||
|
|
||||||
|
Chrome requires every push received with `userVisibleOnly: true` to show a
|
||||||
|
notification. A `DAILY_CHECK` that finds nothing new must show one anyway, or
|
||||||
|
Chrome eventually shows its own "This site has been updated in the background"
|
||||||
|
notice. Choose one (see Open questions):
|
||||||
|
|
||||||
|
- show a quiet "No new activity" notification and close it after a short delay;
|
||||||
|
- have the push server skip pushes when there is nothing new, which requires
|
||||||
|
the server to query Endorser on the user's behalf.
|
||||||
|
|
||||||
|
### 4. Re-enable the UI on the web build
|
||||||
|
|
||||||
|
- Render the Notifications section on web when `"PushManager" in window` and a
|
||||||
|
service worker is registered, instead of only on native.
|
||||||
|
- Restore the "Notification Push Server" setting for web.
|
||||||
|
- iOS Safari delivers Web Push only to web apps added to the Home Screen
|
||||||
|
(iOS 16.4+). `public/manifest.webmanifest` is linked from `index.html` and
|
||||||
|
makes the app installable; the UI must explain the Home Screen requirement on
|
||||||
|
iOS.
|
||||||
|
|
||||||
|
### 5. Hosting
|
||||||
|
|
||||||
|
- Confirm each web host (test, production, Docker `docker/*.conf`) proxies
|
||||||
|
`/web-push/subscribe` to the push server, or change the client to POST to
|
||||||
|
`{pushUrl}/web-push/subscribe`.
|
||||||
|
- Serve `sw.js` with `Cache-Control: no-cache` so worker updates reach users
|
||||||
|
promptly.
|
||||||
|
|
||||||
|
### 6. Tests
|
||||||
|
|
||||||
|
- Playwright: register the worker, then deliver pushes with the Chrome DevTools
|
||||||
|
Protocol `ServiceWorker.deliverPushMessage` and assert on the shown
|
||||||
|
notification (`registration.getNotifications()`), for both payload types.
|
||||||
|
- Unit-test the claim-counting function, extracted so it runs outside the
|
||||||
|
worker.
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
Two to four days of work, plus push-server and nginx checks (low confidence:
|
||||||
|
the push server's current behavior and routing are not visible from this
|
||||||
|
repository).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Empty daily checks: silent-notification workaround, or server-side filtering?
|
||||||
|
- Does the push server serve `/web-push/vapid` and accept
|
||||||
|
`/web-push/subscribe`, and does it send `DAILY_CHECK` daily?
|
||||||
|
- Should New Activity on web use the same Endorser query as the native fetcher
|
||||||
|
(starred-plan changes) rather than `/api/v2/report/claims`, so both platforms
|
||||||
|
report the same thing?
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- **FCM for web:** still needs a service worker; adds a Google dependency to the
|
||||||
|
web build and a server-side switch to the FCM API. See "Can FCM replace Web
|
||||||
|
Push?".
|
||||||
|
- **Reviving the old worker scripts (`sw_scripts/safari-notifications.js`):**
|
||||||
|
they read the Dexie `TimeSafari` IndexedDB database, which no longer holds
|
||||||
|
accounts, and decrypted the identity inside the worker using a secret passed
|
||||||
|
from the page, shipping NaCl and noble crypto libraries into the worker. The
|
||||||
|
day-scoped token pool needs neither.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 2025-02-18 (`2746b8fe`): the Vite config reorganization switched the web build
|
||||||
|
to a Workbox-generated worker, so the custom push, notification-click, and
|
||||||
|
share-target handlers stopped shipping.
|
||||||
|
- 2025-07-18 (`8bc1c521`): VitePWA and the generated worker were removed; the
|
||||||
|
web manifest stopped being generated and linked.
|
||||||
|
- The leftover `sw_scripts/` files and PWA config were deleted, and
|
||||||
|
`public/sw.js` was added for the Web Share Target only.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Decide the empty-daily-check behavior
|
||||||
|
- [ ] Confirm push-server endpoints and routing per environment
|
||||||
|
- [ ] `push` and `notificationclick` handlers in `public/sw.js`
|
||||||
|
- [ ] Token-pool handoff to the worker; remove the placeholder secret
|
||||||
|
- [ ] Re-enable the web Notifications UI, with the iOS Home Screen note
|
||||||
|
- [ ] Playwright push-delivery tests
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When the local database cannot be opened, a user should (1) get a useful
|
||||||
|
report to the developers with one tap, and (2) keep using whatever
|
||||||
|
parts of the app do not need local storage, with a clear path to recovery.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A failure report reaches the developers without the user composing an email,
|
||||||
|
and it carries enough to identify the cause on the first report.
|
||||||
|
2. The report is sent only with the user's consent and holds no keys, seed
|
||||||
|
phrases, or contact data.
|
||||||
|
3. A user whose database is down can browse public content (feed, projects,
|
||||||
|
claims) instead of being held on the recovery screen.
|
||||||
|
4. Every action that needs the database or an identity is visibly disabled with
|
||||||
|
the real reason, and points to the recovery screen.
|
||||||
|
5. Recovery steps never delete the database without keeping a copy.
|
||||||
|
|
||||||
|
## Startup failure behavior
|
||||||
|
|
||||||
|
- `CapacitorPlatformService._initialize()`
|
||||||
|
(`src/services/platforms/CapacitorPlatformService.ts`) opens the database in
|
||||||
|
three steps: "create connection", "open database", "run migrations". A
|
||||||
|
failure throws `Failed to initialize database (step: <step>): <cause>`.
|
||||||
|
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
||||||
|
to `StartView` through `sessionStorage`.
|
||||||
|
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
||||||
|
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
|
||||||
|
global error handler (`src/main.common.ts`).
|
||||||
|
- The guard calls `retrieveAccountDids()` for every route except the
|
||||||
|
identity-setup routes, so with the database down every route except
|
||||||
|
`/account`, `/test` and `/logs` lands on `/start`.
|
||||||
|
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
|
||||||
|
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
||||||
|
(`resetDatabase()`, which copies the file aside before deleting it).
|
||||||
|
- API reads work without an identity: `getHeaders()`
|
||||||
|
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
|
||||||
|
is no DID.
|
||||||
|
- Every path that creates, saves, or signs with an identity reads the `secret`
|
||||||
|
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
||||||
|
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
||||||
|
the database is down.
|
||||||
|
|
||||||
|
## Part A: One-tap diagnostics report
|
||||||
|
|
||||||
|
Contents of the report:
|
||||||
|
|
||||||
|
- the startup error message (step and cause) and stack
|
||||||
|
- app version, platform, OS version, device model
|
||||||
|
- Android: WebView version and installer package (`getInstallerPackageName`)
|
||||||
|
- whether a database file existed before the first connection attempt of this
|
||||||
|
process, which shows whether Android restored a backup at install time
|
||||||
|
- whether the SQLite plugin loaded (a plugin load failure surfaces as a
|
||||||
|
`CapacitorSQLitePlugin:` prefix on the cause)
|
||||||
|
- the `migrations` table contents, when the database opens far enough to read it
|
||||||
|
|
||||||
|
Delivery: a POST to an endpoint on a server the project runs. The recovery
|
||||||
|
screen shows the exact payload and a Send button; the email option stays for
|
||||||
|
users who decline.
|
||||||
|
|
||||||
|
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
||||||
|
`createConnection`; `databaseDirectoryUri()` already resolves the directory on
|
||||||
|
Android and iOS.
|
||||||
|
|
||||||
|
## Part B: Degraded mode
|
||||||
|
|
||||||
|
- Change the router guard so that a database startup error records the error
|
||||||
|
and lets read-only routes through: home feed, projects, project and claim
|
||||||
|
views, and discovery. Identity-setup routes and anything that writes stay
|
||||||
|
redirected to `/start`.
|
||||||
|
- Show a persistent banner on every page while in degraded mode: "Your
|
||||||
|
device's storage isn't working. You can look around, but you can't act yet.
|
||||||
|
Fix it →", linking to the recovery screen.
|
||||||
|
- Disable buttons that need a DID or the database (give, offer, create project,
|
||||||
|
confirm, invite, contacts) with that same reason, instead of the
|
||||||
|
"someone must register you" text an unregistered user sees.
|
||||||
|
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||||
|
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
|
||||||
|
the caller's `defaults` (often `{}`) on its early-return and error paths, so
|
||||||
|
`apiServer` can be undefined; `HomeView` works around this on its own.
|
||||||
|
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
||||||
|
Copy → Start Over.
|
||||||
|
|
||||||
|
## Part C (optional): An identity that does not need SQLite
|
||||||
|
|
||||||
|
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
||||||
|
In degraded mode the app could generate an identity, require the user to write
|
||||||
|
down the seed phrase, and keep the mnemonic in `@capacitor/preferences`
|
||||||
|
(installed, and independent of SQLite). The user would then have a DID someone
|
||||||
|
can register. When the database opens again, the app imports the identity from
|
||||||
|
Preferences and deletes the Preferences copy.
|
||||||
|
|
||||||
|
Trade-off: the mnemonic sits unencrypted in Preferences, about the same
|
||||||
|
protection as the database's secret stored next to the data it encrypts.
|
||||||
|
Worth building only if startup failures keep being reported with the plugin's
|
||||||
|
encryption setting (`androidIsEncryption: false`) in effect.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
||||||
|
- Whether to quarantine a corrupt database automatically ("file is not a
|
||||||
|
database", "malformed") and start fresh. Automatic quarantine trades a
|
||||||
|
visible failure for a silent switch to a new identity, and some "malformed"
|
||||||
|
databases are recoverable with `REINDEX` or `.recover`; the manual Start Over
|
||||||
|
button covers this case.
|
||||||
|
- Whether Android backup should include `databases/`. Including it restores a
|
||||||
|
user's identity on a new phone; it also gives the Google backup a copy of the
|
||||||
|
keys together with the secret that decrypts them. The backup rules
|
||||||
|
(`android/app/src/main/res/xml/backup_rules.xml`,
|
||||||
|
`data_extraction_rules.xml`) include it.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- An IndexedDB (absurd-sql) database as a fallback inside the Capacitor
|
||||||
|
WebView: untested there, and two stores that both hold keys risk losing an
|
||||||
|
identity when they disagree.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
||||||
|
- [ ] A2. Capture "database file existed before first connection" during startup.
|
||||||
|
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
||||||
|
- [ ] B1. Let read-only routes through the router guard on a database startup error.
|
||||||
|
- [ ] B2. Degraded-mode banner and disabled-action reasons.
|
||||||
|
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
||||||
|
- [ ] B4. Reorder the recovery screen.
|
||||||
|
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Fix: Startup Database Error on Android
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
On some Android installs from the Play Store, the app showed a "Startup
|
||||||
|
Error" on every launch:
|
||||||
|
|
||||||
|
```
|
||||||
|
[CapacitorPlatformService] Failed to initialize database
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinstalling did not help. The failure never appeared on developer devices
|
||||||
|
installed through adb or Android Studio. Full symptom report:
|
||||||
|
`startup-db-error-symptoms.md`.
|
||||||
|
|
||||||
|
## Causes
|
||||||
|
|
||||||
|
### 1. The SQLite plugin ignored its configuration (main cause)
|
||||||
|
|
||||||
|
`capacitor.config.ts` listed the SQLite settings under the key `CapSQLite`.
|
||||||
|
The plugin registers as `CapacitorSQLite` and reads only that key, so every
|
||||||
|
setting under `CapSQLite` was ignored and the plugin defaults applied:
|
||||||
|
|
||||||
|
- **Android:** `isEncryption` defaults to true, so on every launch the plugin
|
||||||
|
created an Android Keystore master key and opened an
|
||||||
|
`EncryptedSharedPreferences` file (`sqlite_encrypted_shared_prefs`) to hold a
|
||||||
|
database passphrase. If that step throws, the plugin fails to load and every
|
||||||
|
`createConnection` call is rejected.
|
||||||
|
- **iOS:** the database location defaults to `Documents`, not the configured
|
||||||
|
`Library/CapacitorDatabase`.
|
||||||
|
- **Electron:** the plugin reads `plugins.CapacitorSQLite` directly, so a
|
||||||
|
bundled config with the wrong key would throw at plugin startup.
|
||||||
|
|
||||||
|
The Keystore step fails on Android in two ways:
|
||||||
|
|
||||||
|
- **Restore without the key.** Android Auto Backup and device-to-device
|
||||||
|
transfer restore the encrypted preferences file, but Keystore keys never
|
||||||
|
leave the device. The restored file can't be decrypted, so the plugin fails on
|
||||||
|
every launch, and a reinstall from Play restores the same file again.
|
||||||
|
- **Device Keystore bugs.** Some manufacturers' Keystore implementations fail
|
||||||
|
without any restore.
|
||||||
|
|
||||||
|
The app never used plugin encryption: it opens the database with
|
||||||
|
`encrypted = false` and mode `"no-encryption"`, and never stores a passphrase.
|
||||||
|
The Keystore setup protected nothing.
|
||||||
|
|
||||||
|
### 2. A migration could be applied but not recorded
|
||||||
|
|
||||||
|
The migration runner ran each migration's SQL in one transaction and recorded
|
||||||
|
its name in the `migrations` table in a second one. If the app died between the
|
||||||
|
two, the migration ran again on the next launch. Migration 001's
|
||||||
|
`INSERT INTO secret (id, ...) VALUES (1, ...)` then failed with
|
||||||
|
`UNIQUE constraint failed: secret.id` on every launch. The window is
|
||||||
|
milliseconds, but once hit the failure is permanent, and Auto Backup carries it
|
||||||
|
to a reinstall.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|---|---|
|
||||||
|
| `capacitor.config.ts`, `electron/capacitor.config.ts`, `android/app/src/main/assets/capacitor.config.json` | Key renamed to `CapacitorSQLite`, so `androidIsEncryption: false` takes effect and the Keystore step never runs. `iosDatabaseLocation` set to `Documents`, the location every iOS install uses. |
|
||||||
|
| `capacitor.config.json`, `electron/capacitor.config.json` | `iosDatabaseLocation` set to `Documents` to match. These files already used the correct key. |
|
||||||
|
| `src/services/platforms/CapacitorPlatformService.ts` | `databaseDirectoryUri()` points at `Documents` on iOS, so "Save a Copy of My Data" and "Start Over" find the real database file. |
|
||||||
|
| `src/services/migrationService.ts` | Each migration's `INSERT INTO migrations` is appended to its own statement batch, which the native plugin runs as one transaction. |
|
||||||
|
| `src/db-sql/migration.ts` | Migration 001 uses `INSERT OR IGNORE` for the `secret` and `settings` rows, so a re-run never replaces the existing secret. |
|
||||||
|
| `android/app/src/main/AndroidManifest.xml`, `res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml` | Cloud backup and device transfer exclude the plugin's encrypted preference files. Everything else is backed up, including the database. |
|
||||||
|
| `src/test/migrations.test.ts` | Tests for a crash right after a migration commits, and for a re-run of 001 keeping the secret. |
|
||||||
|
| `src/test/capacitorConfig.test.ts` | Fails if the plugin config key or the iOS location is wrong. |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Encryption of user keys is unchanged. The identity and seed phrase are encrypted
|
||||||
|
by the app (`simpleEncrypt` with the random secret from migration 001) and stored
|
||||||
|
in `accounts`. The plugin's encryption flag only controlled whole-database
|
||||||
|
SQLCipher encryption, which the app never used; the database file was plain
|
||||||
|
SQLite before and after this change. App sandboxing keeps the file private to
|
||||||
|
the app on both platforms.
|
||||||
|
|
||||||
|
## Compatibility with existing installs
|
||||||
|
|
||||||
|
- **Android:** the database has no password whether plugin encryption is on or
|
||||||
|
off, so it opens the same way. The leftover encrypted preferences file is
|
||||||
|
never read again.
|
||||||
|
- **iOS:** the database stays in `Documents`. Setting the location back to
|
||||||
|
`Library/CapacitorDatabase` would not be safe: the plugin moves databases into
|
||||||
|
a custom location only when that folder does not exist yet, and installs from
|
||||||
|
between May and August 2025 have that folder with an outdated database in it.
|
||||||
|
- **Migrations:** existing installs already have 001 recorded, so its edit
|
||||||
|
never runs for them. The recording change affects only migrations that have
|
||||||
|
not run.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Reproduced before the fix** on an Android emulator: run the app once, copy
|
||||||
|
`shared_prefs` out with `run-as app.timesafari.app`, uninstall (which deletes
|
||||||
|
the Keystore key), reinstall, put the copied `shared_prefs` back, and launch.
|
||||||
|
The startup error appeared.
|
||||||
|
- **Unit tests:** `pkgx npm run test:unit -- src/test/migrations.test.ts
|
||||||
|
src/test/capacitorConfig.test.ts`. The two migration tests fail on the
|
||||||
|
earlier code and pass on the fixed code.
|
||||||
|
- **Android resources:** `./gradlew :app:processDebugResources` builds, and
|
||||||
|
the merged manifest carries both backup-rule attributes.
|
||||||
|
- **After the fix:** run the same emulator steps on a fixed build, and also
|
||||||
|
restore `shared_prefs` captured from a pre-fix build. The app should start
|
||||||
|
normally either way.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
The one-tap diagnostics report and a degraded mode for users without a working
|
||||||
|
database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 2025-05-27: `capacitor.config.json` set `iosDatabaseLocation` to
|
||||||
|
`Library/CapacitorDatabase` under the correct `CapacitorSQLite` key
|
||||||
|
(commit 7b4f9d65).
|
||||||
|
- 2025-08-14: the config moved to `capacitor.config.ts` under the key
|
||||||
|
`CapSQLite`, which the plugin does not read (commit a2840675). Plugin
|
||||||
|
encryption setup ran on Android from then on, and iOS databases moved to the
|
||||||
|
default `Documents` location.
|
||||||
|
- 2026-10-10: commit d6ccf0e4 added the failed step and underlying cause to the
|
||||||
|
startup error, made migration 008 tolerate duplicate contact DIDs and labels
|
||||||
|
pointing at deleted contacts, and added the recovery screen.
|
||||||
|
- 2026-10-10: this fix (config key, atomic migration records, backup rules).
|
||||||
+1
-3
@@ -198,9 +198,7 @@ These take no parameters.
|
|||||||
|
|
||||||
### Gift Routes
|
### Gift Routes
|
||||||
|
|
||||||
- `timesafari://confirm-gift/:id`
|
- `timesafari://confirm-gift/:id` opens the claim page, the same as `timesafari://claim/:id`
|
||||||
- Query params:
|
|
||||||
- `action`: "confirm" | "details"
|
|
||||||
|
|
||||||
### Offer Routes
|
### Offer Routes
|
||||||
|
|
||||||
|
|||||||
@@ -104,9 +104,7 @@ Build Scripts:
|
|||||||
├── scripts/** # All build and automation scripts
|
├── scripts/** # All build and automation scripts
|
||||||
├── electron/** # Electron build files
|
├── electron/** # Electron build files
|
||||||
├── android/** # Android build configuration
|
├── android/** # Android build configuration
|
||||||
├── ios/** # iOS build configuration
|
└── ios/** # iOS build configuration
|
||||||
├── sw_scripts/** # Service worker scripts
|
|
||||||
└── sw_combine.js # Service worker combination
|
|
||||||
|
|
||||||
Deployment:
|
Deployment:
|
||||||
├── Dockerfile # Docker configuration
|
├── Dockerfile # Docker configuration
|
||||||
|
|||||||
+20
-21
@@ -2,49 +2,47 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM push notifications are an opt-in feature at build time; all other functionality works on GMS-less devices (F-Droid, LineageOS without OpenGApps, etc.).
|
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM is **on by default** for local, test, and production builds. GMS-less devices still run the app; FCM degrades when Play Services is absent. F-Droid and other builds that must omit Firebase opt out explicitly.
|
||||||
|
|
||||||
## How the opt-in guard works
|
## How the switch works
|
||||||
|
|
||||||
`android/app/build.gradle` applies the `com.google.gms.google-services` Gradle plugin only when **both** conditions are true:
|
`android/app/build.gradle` applies `com.google.gms.google-services` unless `firebaseEnabled` is `false`. `android/gradle.properties` sets `firebaseEnabled=true`, so Android Studio, `./gradlew`, and `scripts/build-android.sh` all pick it up. `-PfirebaseEnabled` and `-PfirebaseEnabled=true` stay on. The config file does not decide the switch.
|
||||||
|
|
||||||
1. `android/google-services.json` is present on disk
|
When Firebase is on, `android/app/google-services.json` must exist, be non-empty, and contain `mobilesdk_app_id`. Otherwise configuration fails with an error that names that path. When Firebase is off, the file is ignored even if it is on disk.
|
||||||
2. The Gradle property `firebaseEnabled` is explicitly passed
|
|
||||||
|
|
||||||
```groovy
|
`scripts/build-android.sh` forwards an opt-out: `FIREBASE_ENABLED=false` adds `-PfirebaseEnabled=false` to its Gradle commands. Default npm scripts leave Firebase on. A `:nostore` script sets `FIREBASE_ENABLED=false` and reuses that script (`npm run build:android:test:nostore:run`).
|
||||||
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This means the file can live on disk for development purposes without accidentally activating Firebase.
|
iOS uses the same default. `ios/App/scripts/bundle-firebase-config.sh` copies `ios/App/App/GoogleService-Info.plist` into the app and the share extension. `scripts/build-ios.sh` runs it with `--check` before a device build, and both Xcode targets run it as a build phase. The build and archive `xcodebuild` commands both receive `FIREBASE_ENABLED`. `FIREBASE_ENABLED=false` becomes `FIREBASE_ENABLED=NO` and skips the plist. A `:nostore` script sets that variable (`npm run build:ios:prod:nostore`). A missing or empty plist fails the build when Firebase is on.
|
||||||
|
|
||||||
|
`:nostore` disables Firebase only. It does not strip other store-linked dependencies (the ML Kit barcode scanner still pulls in Play Services) and does not guarantee the build will run on every device outside the Play Store.
|
||||||
|
|
||||||
## Build commands
|
## Build commands
|
||||||
|
|
||||||
| Target | Command | Firebase |
|
| Target | Command | Firebase |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| APK / sideload / Zapstore | `./gradlew assembleRelease` | off |
|
| Local / test / production APK or AAB | `./gradlew assembleRelease` or `bundleRelease` | on |
|
||||||
| Aurora / Play Store without FCM | `./gradlew bundleRelease` | off |
|
| Play Store with FCM | `./gradlew bundleRelease` | on |
|
||||||
| Play Store with FCM push notifications | `./gradlew bundleRelease -PfirebaseEnabled` | on |
|
| iOS (Xcode or `scripts/build-ios.sh`) | default | on |
|
||||||
| F-Droid | `./gradlew assembleRelease` | off (required) |
|
| F-Droid / FOSS Android | `./gradlew assembleRelease -PfirebaseEnabled=false` or `npm run build:android:test:nostore` | off |
|
||||||
|
| iOS without Firebase | `FIREBASE_ENABLED=false` or `npm run build:ios:prod:nostore` | off |
|
||||||
|
|
||||||
## Behavior on non-GMS devices
|
## Behavior on non-GMS devices
|
||||||
|
|
||||||
When built with `-PfirebaseEnabled`, Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
|
When Firebase is enabled (the default), Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
|
||||||
|
|
||||||
- Firebase initializes but detects no GMS
|
- Firebase initializes but detects no GMS
|
||||||
- FCM skips token registration silently (no token, no notifications)
|
- FCM skips token registration silently (no token, no notifications)
|
||||||
- The app continues to work normally
|
- The app continues to work normally
|
||||||
|
|
||||||
This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
|
This means a single Play Store AAB (`bundleRelease`, Firebase on by default) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
|
||||||
|
|
||||||
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The Play Store AAB built with `-PfirebaseEnabled` is correct for Aurora.
|
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The default Play Store AAB (Firebase on) is the one to upload.
|
||||||
|
|
||||||
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
|
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease -PfirebaseEnabled=false` build and a dedicated F-Droid listing.
|
||||||
|
|
||||||
## Push registration in builds without Firebase
|
## Push registration in builds without Firebase
|
||||||
|
|
||||||
A build without `-PfirebaseEnabled` contains the `@capacitor/push-notifications` plugin, but no Firebase config. In that build, `PushNotifications.register()` throws `Default FirebaseApp is not initialized` on the Capacitor plugin thread, which kills the app; a JS `try/catch` cannot intercept it.
|
A build with `-PfirebaseEnabled=false` contains the `@capacitor/push-notifications` plugin, but no Firebase config. In that build, `PushNotifications.register()` throws `Default FirebaseApp is not initialized` on the Capacitor plugin thread, which kills the app; a JS `try/catch` cannot intercept it.
|
||||||
|
|
||||||
The JS therefore asks native code first. `NotificationInspector.isFirebaseConfigured()` (`android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java`) reports whether the `google_app_id` string resource exists; the google-services Gradle plugin generates that resource only when Firebase is enabled, and it is the same signal Firebase's auto-initialization uses. `firebaseMessagingClient.ts` skips `register()` on Android when it is absent (or when the check fails), logging `No Firebase config in this Android build; skipping push registration`. Listener setup and the notification permission request run either way, since local daily notifications need that permission.
|
The JS therefore asks native code first. `NotificationInspector.isFirebaseConfigured()` (`android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java`) reports whether the `google_app_id` string resource exists; the google-services Gradle plugin generates that resource only when Firebase is enabled, and it is the same signal Firebase's auto-initialization uses. `firebaseMessagingClient.ts` skips `register()` on Android when it is absent (or when the check fails), logging `No Firebase config in this Android build; skipping push registration`. Listener setup and the notification permission request run either way, since local daily notifications need that permission.
|
||||||
|
|
||||||
@@ -56,7 +54,8 @@ Any code that calls `PushNotifications.register()` or other `FirebaseMessaging`
|
|||||||
|
|
||||||
- Gitignored (`android/.gitignore` line 80) — never commit it
|
- Gitignored (`android/.gitignore` line 80) — never commit it
|
||||||
- Contains Firebase project credentials (project number, app ID, API key)
|
- Contains Firebase project credentials (project number, app ID, API key)
|
||||||
- Safe to leave on disk; has no effect unless `-PfirebaseEnabled` is passed
|
- Required for the default (Firebase on) build. Place it at `android/app/google-services.json`
|
||||||
|
- Ignored when you pass `-PfirebaseEnabled=false`; leaving it on disk does not re-enable Firebase
|
||||||
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
|
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
|
||||||
|
|
||||||
## Known GMS dependency: MLKit barcode scanner
|
## Known GMS dependency: MLKit barcode scanner
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ Complete this section once before your first physical-device push test. If Fireb
|
|||||||
|
|
||||||
2. The repo gitignores this file — each developer keeps a local copy; never commit it.
|
2. The repo gitignores this file — each developer keeps a local copy; never commit it.
|
||||||
|
|
||||||
3. Rebuild so Gradle applies the Google Services plugin (`android/app/build.gradle` applies `com.google.gms.google-services` when the file exists). If the file is missing, the build logs: *google-services.json not found, google-services plugin not applied. Push Notifications won't work*.
|
3. Rebuild. Firebase is on by default, so Gradle applies the Google Services plugin. If `android/app/google-services.json` is missing or empty, the build stops and names that path. Opt out only with `-PfirebaseEnabled=false` or `FIREBASE_ENABLED=false`.
|
||||||
|
|
||||||
4. Sync Capacitor if you changed native config:
|
4. Sync Capacitor if you changed native config:
|
||||||
|
|
||||||
@@ -928,11 +928,11 @@ Compare with panel **Backend Status** and Event Log error text.
|
|||||||
|
|
||||||
### Gradle: Push Notifications won't work
|
### Gradle: Push Notifications won't work
|
||||||
|
|
||||||
**Symptoms:** Build log: `google-services.json not found, google-services plugin not applied`.
|
**Symptoms:** Build fails: `android/app/google-services.json is missing, empty, or has no mobilesdk_app_id`.
|
||||||
|
|
||||||
**Likely causes:** Missing `android/app/google-services.json`.
|
**Likely causes:** Missing `android/app/google-services.json`.
|
||||||
|
|
||||||
**Verification:** File exists on disk; rebuild shows Google Services plugin applied.
|
**Verification:** File exists on disk and the build log says `Firebase enabled: google-services plugin applied`.
|
||||||
|
|
||||||
**Fixes:** Download from Firebase Console (package `app.timesafari.app`); place in `android/app/`; `npx cap sync android` and rebuild.
|
**Fixes:** Download from Firebase Console (package `app.timesafari.app`); place in `android/app/`; `npx cap sync android` and rebuild.
|
||||||
|
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ export default class ComponentName extends Vue {
|
|||||||
- [ ] ContactGiftingView.vue
|
- [ ] ContactGiftingView.vue
|
||||||
- [x] DiscoverView.vue ✅ **MIGRATED & HUMAN TESTED**
|
- [x] DiscoverView.vue ✅ **MIGRATED & HUMAN TESTED**
|
||||||
- [ ] ImportAccountView.vue
|
- [ ] ImportAccountView.vue
|
||||||
- [ ] ConfirmGiftView.vue
|
|
||||||
- [ ] SeedBackupView.vue
|
- [ ] SeedBackupView.vue
|
||||||
- [ ] ContactAmountsView.vue
|
- [ ] ContactAmountsView.vue
|
||||||
- [x] ContactQRScanFullView.vue ✅ **MIGRATED & HUMAN TESTED**
|
- [x] ContactQRScanFullView.vue ✅ **MIGRATED & HUMAN TESTED**
|
||||||
|
|||||||
@@ -289,7 +289,6 @@ const { sql, params } = this.$generateInsertStatement(contact, 'contacts');
|
|||||||
- [ ] ContactGiftingView.vue
|
- [ ] ContactGiftingView.vue
|
||||||
- [ ] DiscoverView.vue
|
- [ ] DiscoverView.vue
|
||||||
- [ ] ImportAccountView.vue
|
- [ ] ImportAccountView.vue
|
||||||
- [ ] ConfirmGiftView.vue
|
|
||||||
- [ ] SeedBackupView.vue
|
- [ ] SeedBackupView.vue
|
||||||
- [ ] [5 more view files]
|
- [ ] [5 more view files]
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ export async function createBuildConfig(mode: string) {
|
|||||||
return defineConfig({
|
return defineConfig({
|
||||||
define: {
|
define: {
|
||||||
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
||||||
// PWA is automatically enabled for web platforms via build configuration
|
|
||||||
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
||||||
__USE_QR_READER__: JSON.stringify(!isCapacitor)
|
__USE_QR_READER__: JSON.stringify(!isCapacitor)
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-5
@@ -5,10 +5,9 @@
|
|||||||
# IMPORTANT: Build web assets first using npm scripts before running docker-compose
|
# IMPORTANT: Build web assets first using npm scripts before running docker-compose
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# Development: npm run build:web:build -- --mode development && docker-compose up dev
|
# Test: npm run docker:up:test (runs npm run build:web:test && docker-compose up test)
|
||||||
# Test: npm run build:web:build -- --mode test && docker-compose up test
|
# Production: npm run docker:up:prod (runs npm run build:web:prod && docker-compose up production)
|
||||||
# Production: npm run build:web:build -- --mode production && docker-compose up production
|
# Custom: npm run build:web:test && BUILD_MODE=test docker-compose up custom
|
||||||
# Custom: BUILD_MODE=test npm run build:web:build -- --mode test && docker-compose up custom
|
|
||||||
#
|
#
|
||||||
# Environment Variables:
|
# Environment Variables:
|
||||||
# BUILD_MODE: development, test, or production (default: production)
|
# BUILD_MODE: development, test, or production (default: production)
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
# PORT: port to expose (default: 80 for production, 8080 for test)
|
# PORT: port to expose (default: 80 for production, 8080 for test)
|
||||||
# ENV_FILE: environment file to use (default: .env.production)
|
# ENV_FILE: environment file to use (default: .env.production)
|
||||||
#
|
#
|
||||||
# Note: For development, use npm run build:web directly (no Docker needed)
|
# Note: For development, use npm run dev directly (no Docker needed)
|
||||||
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -103,7 +103,6 @@ The Dockerfile supports these build arguments:
|
|||||||
| `BUILD_MODE` | `production` | Build mode: development, staging, or production |
|
| `BUILD_MODE` | `production` | Build mode: development, staging, or production |
|
||||||
| `NODE_ENV` | `production` | Node.js environment |
|
| `NODE_ENV` | `production` | Node.js environment |
|
||||||
| `VITE_PLATFORM` | `web` | Vite platform type |
|
| `VITE_PLATFORM` | `web` | Vite platform type |
|
||||||
| PWA | `enabled` | Automatically enabled for web platforms |
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -114,7 +113,6 @@ Docker Compose supports these environment variables:
|
|||||||
| `BUILD_MODE` | `production` | Build mode |
|
| `BUILD_MODE` | `production` | Build mode |
|
||||||
| `NODE_ENV` | `production` | Node environment |
|
| `NODE_ENV` | `production` | Node environment |
|
||||||
| `VITE_PLATFORM` | `web` | Vite platform |
|
| `VITE_PLATFORM` | `web` | Vite platform |
|
||||||
| PWA | `enabled` | Automatically enabled for web platforms |
|
|
||||||
| `DEV_PORT` | `5173` | Development port |
|
| `DEV_PORT` | `5173` | Development port |
|
||||||
| `STAGING_PORT` | `8080` | Staging port |
|
| `STAGING_PORT` | `8080` | Staging port |
|
||||||
| `PROD_PORT` | `80` | Production port |
|
| `PROD_PORT` | `80` | Production port |
|
||||||
@@ -219,13 +217,12 @@ BUILD_MODE=staging NODE_ENV=staging ./docker/run.sh custom
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg BUILD_MODE=staging \
|
--build-arg BUILD_MODE=staging \
|
||||||
--build-arg NODE_ENV=staging \
|
--build-arg NODE_ENV=staging \
|
||||||
|
-t timesafari:staging .
|
||||||
-t timesafari:staging-no-pwa .
|
|
||||||
|
|
||||||
# Run with custom configuration
|
# Run with custom configuration
|
||||||
docker run -d -p 9000:80 \
|
docker run -d -p 9000:80 \
|
||||||
-e VITE_APP_SERVER=https://test.example.com \
|
-e VITE_APP_SERVER=https://test.example.com \
|
||||||
timesafari:staging-no-pwa
|
timesafari:staging
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose with Custom Variables
|
### Docker Compose with Custom Variables
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
# BUILD_MODE: development, staging, or production
|
# BUILD_MODE: development, staging, or production
|
||||||
# NODE_ENV: node environment
|
# NODE_ENV: node environment
|
||||||
# VITE_PLATFORM: vite platform
|
# VITE_PLATFORM: vite platform
|
||||||
# PWA: automatically enabled for web platforms
|
|
||||||
# PORT: port to expose
|
# PORT: port to expose
|
||||||
# ENV_FILE: environment file to use
|
# ENV_FILE: environment file to use
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@ show_usage() {
|
|||||||
echo " BUILD_MODE: development, staging, or production"
|
echo " BUILD_MODE: development, staging, or production"
|
||||||
echo " NODE_ENV: node environment"
|
echo " NODE_ENV: node environment"
|
||||||
echo " VITE_PLATFORM: vite platform"
|
echo " VITE_PLATFORM: vite platform"
|
||||||
echo " PWA: automatically enabled for web platforms"
|
|
||||||
echo " PORT: port to expose"
|
echo " PORT: port to expose"
|
||||||
echo " ENV_FILE: environment file to use"
|
echo " ENV_FILE: environment file to use"
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,6 @@ show_build_args() {
|
|||||||
echo " BUILD_MODE: development"
|
echo " BUILD_MODE: development"
|
||||||
echo " NODE_ENV: development"
|
echo " NODE_ENV: development"
|
||||||
echo " VITE_PLATFORM: web"
|
echo " VITE_PLATFORM: web"
|
||||||
echo " PWA: enabled (web platform)"
|
|
||||||
echo " Target: development"
|
echo " Target: development"
|
||||||
echo " Port: 5173"
|
echo " Port: 5173"
|
||||||
;;
|
;;
|
||||||
@@ -94,7 +91,6 @@ show_build_args() {
|
|||||||
echo " BUILD_MODE: staging"
|
echo " BUILD_MODE: staging"
|
||||||
echo " NODE_ENV: staging"
|
echo " NODE_ENV: staging"
|
||||||
echo " VITE_PLATFORM: web"
|
echo " VITE_PLATFORM: web"
|
||||||
echo " PWA: enabled (web platform)"
|
|
||||||
echo " Target: staging"
|
echo " Target: staging"
|
||||||
echo " Port: 80 (mapped to 8080)"
|
echo " Port: 80 (mapped to 8080)"
|
||||||
;;
|
;;
|
||||||
@@ -102,7 +98,6 @@ show_build_args() {
|
|||||||
echo " BUILD_MODE: production"
|
echo " BUILD_MODE: production"
|
||||||
echo " NODE_ENV: production"
|
echo " NODE_ENV: production"
|
||||||
echo " VITE_PLATFORM: web"
|
echo " VITE_PLATFORM: web"
|
||||||
echo " PWA: enabled (web platform)"
|
|
||||||
echo " Target: production"
|
echo " Target: production"
|
||||||
echo " Port: 80"
|
echo " Port: 80"
|
||||||
;;
|
;;
|
||||||
@@ -110,7 +105,6 @@ show_build_args() {
|
|||||||
echo " BUILD_MODE: \${BUILD_MODE:-production}"
|
echo " BUILD_MODE: \${BUILD_MODE:-production}"
|
||||||
echo " NODE_ENV: \${NODE_ENV:-production}"
|
echo " NODE_ENV: \${NODE_ENV:-production}"
|
||||||
echo " VITE_PLATFORM: \${VITE_PLATFORM:-web}"
|
echo " VITE_PLATFORM: \${VITE_PLATFORM:-web}"
|
||||||
echo " PWA: enabled for web platforms"
|
|
||||||
echo " Target: \${BUILD_TARGET:-production}"
|
echo " Target: \${BUILD_TARGET:-production}"
|
||||||
echo " Port: \${CUSTOM_PORT:-8080}:\${CUSTOM_INTERNAL_PORT:-80}"
|
echo " Port: \${CUSTOM_PORT:-8080}:\${CUSTOM_INTERNAL_PORT:-80}"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
|
||||||
|
|
||||||
<!-- CORS headers removed to allow images from any domain -->
|
<!-- CORS headers removed to allow images from any domain -->
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
|
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
|
||||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
|
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
|
||||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
|
||||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
|
||||||
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */; };
|
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -185,6 +183,7 @@
|
|||||||
504EC3001FED79650016851F /* Sources */,
|
504EC3001FED79650016851F /* Sources */,
|
||||||
504EC3011FED79650016851F /* Frameworks */,
|
504EC3011FED79650016851F /* Frameworks */,
|
||||||
504EC3021FED79650016851F /* Resources */,
|
504EC3021FED79650016851F /* Resources */,
|
||||||
|
F1REBA5E000000000000A001 /* Bundle Firebase Config */,
|
||||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||||
@@ -207,6 +206,7 @@
|
|||||||
C86585D12ED456DE00824752 /* Sources */,
|
C86585D12ED456DE00824752 /* Sources */,
|
||||||
C86585D22ED456DE00824752 /* Frameworks */,
|
C86585D22ED456DE00824752 /* Frameworks */,
|
||||||
C86585D32ED456DE00824752 /* Resources */,
|
C86585D32ED456DE00824752 /* Resources */,
|
||||||
|
F1REBA5E000000000000A002 /* Bundle Firebase Config */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -270,7 +270,6 @@
|
|||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -278,7 +277,6 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -342,6 +340,48 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
|
F1REBA5E000000000000A001 /* Bundle Firebase Config */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
enableUserScriptSandboxing = 0;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Bundle Firebase Config";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
F1REBA5E000000000000A002 /* Bundle Firebase Config */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
enableUserScriptSandboxing = 0;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Bundle Firebase Config";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
|
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -541,10 +581,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 76;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
FIREBASE_ENABLED = YES;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||||
@@ -552,7 +593,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.10;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -571,10 +612,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 76;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
FIREBASE_ENABLED = YES;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||||
@@ -582,7 +624,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.10;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -600,8 +642,10 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 76;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
FIREBASE_ENABLED = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
@@ -614,7 +658,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.10;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -638,8 +682,10 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 76;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
FIREBASE_ENABLED = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
@@ -652,7 +698,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.10;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -144,20 +144,16 @@ final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
|
|||||||
return (s?.isEmpty == false) ? s : nil
|
return (s?.isEmpty == false) ? s : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store the newest JWT ID in the response (the highest ULID), so the next fetch
|
||||||
|
/// asks only for later changes. The server lists them newest first, so the last
|
||||||
|
/// item is the oldest.
|
||||||
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
|
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
|
||||||
guard let data = responseBody.data(using: .utf8),
|
guard let data = responseBody.data(using: .utf8),
|
||||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
let lastItem = dataArray[dataArray.count - 1]
|
if let jwtId = dataArray.compactMap({ extractJwtIdFromItem($0) }).max() {
|
||||||
var jwtId: String?
|
|
||||||
if let j = lastItem["jwtId"] as? String {
|
|
||||||
jwtId = j
|
|
||||||
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
|
|
||||||
jwtId = j
|
|
||||||
}
|
|
||||||
if let jwtId = jwtId, !jwtId.isEmpty {
|
|
||||||
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
|
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ PODS:
|
|||||||
- SQLCipher/common (4.10.0)
|
- SQLCipher/common (4.10.0)
|
||||||
- SQLCipher/standard (4.10.0):
|
- SQLCipher/standard (4.10.0):
|
||||||
- SQLCipher/common
|
- SQLCipher/common
|
||||||
- TimesafariDailyNotificationPlugin (4.0.1):
|
- TimesafariDailyNotificationPlugin (5.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- ZIPFoundation (0.9.20)
|
- ZIPFoundation (0.9.20)
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ SPEC CHECKSUMS:
|
|||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||||
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
|
TimesafariDailyNotificationPlugin: b60e726f8776b5d22e026caef906f1c3b0e963dd
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
|
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
|
||||||
|
|||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Bundle GoogleService-Info.plist when Firebase is enabled (the default).
|
||||||
|
# Opt out with FIREBASE_ENABLED=false, or the Xcode build setting
|
||||||
|
# FIREBASE_ENABLED=NO. The plist does not decide the switch: a missing file
|
||||||
|
# fails an enabled build, and an opt-out build does not bundle the plist
|
||||||
|
# even when the file is on disk.
|
||||||
|
#
|
||||||
|
# Xcode run-script phases invoke this with no arguments. build-ios.sh may pass
|
||||||
|
# --check to validate before a long build without copying into a product.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
check_only=0
|
||||||
|
if [ "${1:-}" = "--check" ]; then
|
||||||
|
check_only=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${SRCROOT:-}" ]; then
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||||
|
SRCROOT=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized=$(printf '%s' "${FIREBASE_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
plist="${SRCROOT}/App/GoogleService-Info.plist"
|
||||||
|
|
||||||
|
firebase_off=0
|
||||||
|
case "$normalized" in
|
||||||
|
false|0|no) firebase_off=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
remove_bundled_plist() {
|
||||||
|
base="$1"
|
||||||
|
leaf="$2"
|
||||||
|
if [ -n "$base" ] && [ -n "$leaf" ]; then
|
||||||
|
rm -f "${base}/${leaf}/GoogleService-Info.plist"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$firebase_off" -eq 1 ]; then
|
||||||
|
if [ "$check_only" -eq 0 ]; then
|
||||||
|
# Drop the plist from this product, including a copy left by an earlier
|
||||||
|
# Firebase-enabled build. The app and the share extension each run this phase.
|
||||||
|
remove_bundled_plist "${TARGET_BUILD_DIR:-}" "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}"
|
||||||
|
remove_bundled_plist "${TARGET_BUILD_DIR:-}" "${WRAPPER_NAME:-}"
|
||||||
|
remove_bundled_plist "${BUILT_PRODUCTS_DIR:-}" "${WRAPPER_NAME:-}"
|
||||||
|
# Embed does not delete files that disappeared from the appex, so a plist
|
||||||
|
# copied into PlugIns on an earlier Firebase-enabled build would otherwise stay.
|
||||||
|
if [ -n "${TARGET_BUILD_DIR:-}" ] && [ -n "${WRAPPER_NAME:-}" ]; then
|
||||||
|
for stale in "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/PlugIns/"*.appex/GoogleService-Info.plist; do
|
||||||
|
if [ -f "$stale" ]; then
|
||||||
|
rm -f "$stale"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "note: Firebase disabled (FIREBASE_ENABLED=${FIREBASE_ENABLED}); GoogleService-Info.plist will not be bundled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s "$plist" ] || ! grep -q "GOOGLE_APP_ID" "$plist"; then
|
||||||
|
echo "error: Firebase is enabled, but ios/App/App/GoogleService-Info.plist is missing, empty, or has no GOOGLE_APP_ID." >&2
|
||||||
|
echo "error: Download it from the Firebase console (Project settings → Your apps → Apple apps)" >&2
|
||||||
|
echo "error: and place it at ios/App/App/GoogleService-Info.plist." >&2
|
||||||
|
echo "error: To build without Firebase, set FIREBASE_ENABLED=false or pass the Xcode build setting FIREBASE_ENABLED=NO." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$check_only" -eq 1 ]; then
|
||||||
|
echo "note: Firebase enabled; ios/App/App/GoogleService-Info.plist is present."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${TARGET_BUILD_DIR:-}" ] || [ -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" ]; then
|
||||||
|
echo "error: Bundle Firebase Config must run as an Xcode build phase (TARGET_BUILD_DIR is unset)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dest_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
|
||||||
|
mkdir -p "$dest_dir"
|
||||||
|
cp "$plist" "${dest_dir}/GoogleService-Info.plist"
|
||||||
|
echo "note: Firebase enabled; bundled GoogleService-Info.plist."
|
||||||
Generated
+2
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.7",
|
"version": "1.4.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.7",
|
"version": "1.4.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "^7.0.3",
|
"@capacitor-community/sqlite": "^7.0.3",
|
||||||
@@ -87,7 +87,6 @@
|
|||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"readable-stream": "^4.5.2",
|
"readable-stream": "^4.5.2",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"register-service-worker": "^1.7.2",
|
|
||||||
"simple-vue-camera": "^1.1.3",
|
"simple-vue-camera": "^1.1.3",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
@@ -26317,10 +26316,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/register-service-worker": {
|
|
||||||
"version": "1.7.2",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/registry-auth-token": {
|
"node_modules/registry-auth-token": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+16
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.7",
|
"version": "1.4.11-beta",
|
||||||
"description": "Giftopia App",
|
"description": "Giftopia App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Gift Economies Team"
|
"name": "Gift Economies Team"
|
||||||
@@ -26,8 +26,11 @@
|
|||||||
"build:android:deploy": "./scripts/build-android.sh --deploy",
|
"build:android:deploy": "./scripts/build-android.sh --deploy",
|
||||||
"build:android:dev": "./scripts/build-android.sh --dev",
|
"build:android:dev": "./scripts/build-android.sh --dev",
|
||||||
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
|
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
|
||||||
|
"build:android:dev:nostore": "FIREBASE_ENABLED=false npm run build:android:dev",
|
||||||
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
|
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
|
||||||
"build:android:prod": "./scripts/build-android.sh --prod",
|
"build:android:prod": "./scripts/build-android.sh --prod",
|
||||||
|
"build:android:prod:nostore": "FIREBASE_ENABLED=false npm run build:android:prod",
|
||||||
|
"build:android:prod:nostore:run": "FIREBASE_ENABLED=false npm run build:android:prod:run",
|
||||||
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
|
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
|
||||||
"build:android:release": "./scripts/build-android.sh --release",
|
"build:android:release": "./scripts/build-android.sh --release",
|
||||||
"build:android:release:run": "./scripts/build-android.sh --release --auto-run",
|
"build:android:release:run": "./scripts/build-android.sh --release --auto-run",
|
||||||
@@ -35,6 +38,8 @@
|
|||||||
"build:android:sync": "./scripts/build-android.sh --sync",
|
"build:android:sync": "./scripts/build-android.sh --sync",
|
||||||
"build:android:test": "./scripts/build-android.sh --test",
|
"build:android:test": "./scripts/build-android.sh --test",
|
||||||
"build:android:test:custom": "./scripts/build-android.sh --test --api-ip",
|
"build:android:test:custom": "./scripts/build-android.sh --test --api-ip",
|
||||||
|
"build:android:test:nostore": "FIREBASE_ENABLED=false npm run build:android:test",
|
||||||
|
"build:android:test:nostore:run": "FIREBASE_ENABLED=false npm run build:android:test:run",
|
||||||
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
|
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
|
||||||
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run",
|
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run",
|
||||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||||
@@ -78,9 +83,12 @@
|
|||||||
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
|
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
|
||||||
"build:ios:dev": "./scripts/build-ios.sh --dev",
|
"build:ios:dev": "./scripts/build-ios.sh --dev",
|
||||||
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
|
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
|
||||||
|
"build:ios:dev:nostore": "FIREBASE_ENABLED=false npm run build:ios:dev",
|
||||||
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
|
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
|
||||||
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
|
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
|
||||||
"build:ios:prod": "./scripts/build-ios.sh --prod",
|
"build:ios:prod": "./scripts/build-ios.sh --prod",
|
||||||
|
"build:ios:prod:nostore": "FIREBASE_ENABLED=false npm run build:ios:prod",
|
||||||
|
"build:ios:prod:nostore:run": "FIREBASE_ENABLED=false npm run build:ios:prod:run",
|
||||||
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
|
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
|
||||||
"build:ios:release": "./scripts/build-ios.sh --release",
|
"build:ios:release": "./scripts/build-ios.sh --release",
|
||||||
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
|
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
|
||||||
@@ -88,11 +96,12 @@
|
|||||||
"build:ios:sync": "./scripts/build-ios.sh --sync",
|
"build:ios:sync": "./scripts/build-ios.sh --sync",
|
||||||
"build:ios:test": "./scripts/build-ios.sh --test",
|
"build:ios:test": "./scripts/build-ios.sh --test",
|
||||||
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
|
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
|
||||||
|
"build:ios:test:nostore": "FIREBASE_ENABLED=false npm run build:ios:test",
|
||||||
|
"build:ios:test:nostore:run": "FIREBASE_ENABLED=false npm run build:ios:test:run",
|
||||||
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
|
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
|
||||||
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
|
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
|
||||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate --assetPath resources",
|
"build:native": "npm run build:capacitor:sync && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate --ios --android --assetPath resources",
|
||||||
"build:web": "./scripts/build-web.sh",
|
"build:web": "./scripts/build-web.sh",
|
||||||
"build:web:dev": "./scripts/build-web.sh --dev",
|
|
||||||
"build:web:docker": "./scripts/build-web.sh --docker",
|
"build:web:docker": "./scripts/build-web.sh --docker",
|
||||||
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
|
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
|
||||||
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
|
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
|
||||||
@@ -110,12 +119,12 @@
|
|||||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||||
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||||
"dev": "npm run build:web:dev",
|
"dev": "./scripts/build-web.sh --dev",
|
||||||
"docker:down": "docker-compose down",
|
"docker:down": "docker-compose down",
|
||||||
"docker:logs": "docker-compose logs -f",
|
"docker:logs": "docker-compose logs -f",
|
||||||
"docker:up": "docker-compose up",
|
"docker:up": "docker-compose up",
|
||||||
"docker:up:prod": "npm run build:web:build -- --mode production && docker-compose up production",
|
"docker:up:prod": "npm run build:web:prod && docker-compose up production",
|
||||||
"docker:up:test": "npm run build:web:build -- --mode test && docker-compose up test",
|
"docker:up:test": "npm run build:web:test && docker-compose up test",
|
||||||
"guard": "bash ./scripts/build-arch-guard.sh",
|
"guard": "bash ./scripts/build-arch-guard.sh",
|
||||||
"guard:setup": "npm run prepare && echo 'Build Architecture Guard is now active!'",
|
"guard:setup": "npm run prepare && echo 'Build Architecture Guard is now active!'",
|
||||||
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
||||||
@@ -124,7 +133,7 @@
|
|||||||
"markdown:check": "markdownlint-cli2",
|
"markdown:check": "markdownlint-cli2",
|
||||||
"markdown:fix": "markdownlint-cli2 --fix",
|
"markdown:fix": "markdownlint-cli2 --fix",
|
||||||
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
|
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
|
||||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node scripts/copy-wasm.js",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
|
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
|
||||||
"test:android": "node scripts/test-android.js",
|
"test:android": "node scripts/test-android.js",
|
||||||
@@ -220,7 +229,6 @@
|
|||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"readable-stream": "^4.5.2",
|
"readable-stream": "^4.5.2",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"register-service-worker": "^1.7.2",
|
|
||||||
"simple-vue-camera": "^1.1.3",
|
"simple-vue-camera": "^1.1.3",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Load the app once before the tests so the dev server is warm; see that file
|
||||||
retries: process.env.CI ? 2 : 0,
|
* for the Firefox WebAssembly failure it reduces. */
|
||||||
|
globalSetup: "./test-playwright/warmUpDevServer.ts",
|
||||||
|
/* Retry once locally: with several instances in parallel, Firefox can fail to
|
||||||
|
* start its WebAssembly compiler (so the app cannot open its database), at
|
||||||
|
* any point in a run. A retried pass is reported as "flaky", not hidden. */
|
||||||
|
retries: process.env.CI ? 2 : 1,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: 3,
|
workers: 3,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: [
|
reporter: [
|
||||||
['list'],
|
['list'],
|
||||||
|
// explains failures caused by the Firefox WebAssembly start-up error
|
||||||
|
['./test-playwright/webAssemblyHintReporter.ts'],
|
||||||
['html'],
|
['html'],
|
||||||
// ['html', { open: 'never' }], // don't automatically open report page after tests complete even with failures
|
// ['html', { open: 'never' }], // don't automatically open report page after tests complete even with failures
|
||||||
['json', { outputFile: 'test-playwright-results/test-results.json' }]
|
['json', { outputFile: 'test-playwright-results/test-results.json' }]
|
||||||
@@ -106,7 +113,7 @@ export default defineConfig({
|
|||||||
*/
|
*/
|
||||||
webServer: {
|
webServer: {
|
||||||
command:
|
command:
|
||||||
"VITE_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web -- --port=8080",
|
"VITE_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=true npm run dev",
|
||||||
url: "http://localhost:8080",
|
url: "http://localhost:8080",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
|
|||||||
+37
-37
@@ -1,46 +1,46 @@
|
|||||||
{
|
{
|
||||||
|
"name": "Giftopia",
|
||||||
|
"short_name": "Giftopia",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#4a90e2",
|
||||||
|
"background_color": "#ffffff",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "../icons/icon-48.webp",
|
"src": "/img/icons/android-chrome-192x192.png",
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "../icons/icon-72.webp",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "../icons/icon-96.webp",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "../icons/icon-128.webp",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "128x128",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "../icons/icon-192.webp",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"purpose": "any maskable"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "../icons/icon-256.webp",
|
"src": "/img/icons/android-chrome-512x512.png",
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "256x256",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "../icons/icon-512.webp",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"purpose": "any maskable"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icons/android-chrome-maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icons/android-chrome-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "photo",
|
||||||
|
"accept": ["image/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/* eslint-env serviceworker */
|
||||||
|
/**
|
||||||
|
* Service worker for the web build.
|
||||||
|
*
|
||||||
|
* Its only job is the Web Share Target: the manifest's share_target POSTs a
|
||||||
|
* photo to /share-target, which no static server can accept. This worker
|
||||||
|
* stashes the photo in Cache Storage and redirects to /shared-photo, where
|
||||||
|
* src/libs/webShareTarget.ts picks it up. Every other request goes straight to
|
||||||
|
* the network (no fetch handler response, no precaching).
|
||||||
|
*
|
||||||
|
* It also replaces the Workbox worker that older builds registered at this
|
||||||
|
* same URL, and deletes that worker's precaches, which otherwise keep serving
|
||||||
|
* a stale index.html that points at JavaScript files no longer deployed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SHARE_TARGET_CACHE = "share-target";
|
||||||
|
// Keep in sync with src/libs/webShareTarget.ts
|
||||||
|
const SHARED_PHOTO_KEY = "/share-target/photo";
|
||||||
|
const FILE_NAME_HEADER = "X-File-Name";
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => name !== SHARE_TARGET_CACHE)
|
||||||
|
.map((name) => caches.delete(name)),
|
||||||
|
);
|
||||||
|
await self.clients.claim();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (
|
||||||
|
event.request.method !== "POST" ||
|
||||||
|
url.origin !== self.location.origin ||
|
||||||
|
url.pathname !== "/share-target"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const photo = formData.get("photo");
|
||||||
|
if (photo instanceof File) {
|
||||||
|
const cache = await caches.open(SHARE_TARGET_CACHE);
|
||||||
|
await cache.put(
|
||||||
|
SHARED_PHOTO_KEY,
|
||||||
|
new Response(photo, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": photo.type || "application/octet-stream",
|
||||||
|
[FILE_NAME_HEADER]: encodeURIComponent(photo.name || ""),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response.redirect("/shared-photo?source=share-target", 303);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Commands Run (paste exact logs/snips)
|
## Commands Run (paste exact logs/snips)
|
||||||
|
|
||||||
- [ ] Web: `npm run build:web` / `:prod`
|
- [ ] Web: `npm run dev` / `npm run build:web`
|
||||||
- [ ] Electron: `npm run build:electron:dev` / package step
|
- [ ] Electron: `npm run build:electron:dev` / package step
|
||||||
- [ ] Mobile: `npm run build:android:test` / iOS equivalent
|
- [ ] Mobile: `npm run build:android:test` / iOS equivalent
|
||||||
- [ ] Clean/auto-run impacted scripts
|
- [ ] Clean/auto-run impacted scripts
|
||||||
|
|||||||
+4
-6
@@ -23,10 +23,10 @@ All scripts automatically handle environment variables for different build types
|
|||||||
|
|
||||||
#### Build Types and Environment Variables
|
#### Build Types and Environment Variables
|
||||||
|
|
||||||
| Platform | Mode | PWA Enabled | Native Features | Build Script |
|
| Platform | Mode | Native Features | Build Script |
|
||||||
|----------|------|-------------|-----------------|--------------|
|
|----------|------|-----------------|--------------|
|
||||||
| `web` | web | true | false | `build-web.sh` |
|
| `web` | web | false | `build-web.sh` |
|
||||||
| `capacitor` | capacitor | false | true | `build-capacitor.sh` |
|
| `capacitor` | capacitor | true | `build-capacitor.sh` |
|
||||||
|
|
||||||
#### Automatic Environment Setup
|
#### Automatic Environment Setup
|
||||||
|
|
||||||
@@ -183,8 +183,6 @@ All scripts automatically configure the correct environment variables for their
|
|||||||
```bash
|
```bash
|
||||||
# Capacitor builds automatically get:
|
# Capacitor builds automatically get:
|
||||||
export VITE_PLATFORM=capacitor
|
export VITE_PLATFORM=capacitor
|
||||||
export VITE_PWA_ENABLED=false
|
|
||||||
export VITE_DISABLE_PWA=true
|
|
||||||
export DEBUG_MIGRATIONS=0
|
export DEBUG_MIGRATIONS=0
|
||||||
export VITE_GIT_HASH=<git-hash>
|
export VITE_GIT_HASH=<git-hash>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
# --assets Generate assets only
|
# --assets Generate assets only
|
||||||
# --deploy Deploy APK to connected device
|
# --deploy Deploy APK to connected device
|
||||||
# --uninstall Uninstall app from connected device
|
# --uninstall Uninstall app from connected device
|
||||||
|
# FIREBASE_ENABLED=false Opt out of Firebase (-PfirebaseEnabled=false)
|
||||||
# -h, --help Show this help message
|
# -h, --help Show this help message
|
||||||
# -v, --verbose Enable verbose logging
|
# -v, --verbose Enable verbose logging
|
||||||
#
|
#
|
||||||
@@ -439,6 +440,9 @@ print_android_usage() {
|
|||||||
echo " --uninstall Uninstall app from connected device"
|
echo " --uninstall Uninstall app from connected device"
|
||||||
echo " --api-ip <ip> Custom IP address for claim API (defaults to 10.0.2.2)"
|
echo " --api-ip <ip> Custom IP address for claim API (defaults to 10.0.2.2)"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Firebase is on by default. Opt out with FIREBASE_ENABLED=false"
|
||||||
|
echo "(passes -PfirebaseEnabled=false to Gradle)."
|
||||||
|
echo ""
|
||||||
echo "Common Options:"
|
echo "Common Options:"
|
||||||
echo " -h, --help Show this help message"
|
echo " -h, --help Show this help message"
|
||||||
echo " -v, --verbose Enable verbose logging"
|
echo " -v, --verbose Enable verbose logging"
|
||||||
@@ -460,6 +464,17 @@ print_android_usage() {
|
|||||||
# Parse command line arguments
|
# Parse command line arguments
|
||||||
parse_android_args "$@"
|
parse_android_args "$@"
|
||||||
|
|
||||||
|
# Firebase is on unless FIREBASE_ENABLED is false, 0, or no. Gradle also
|
||||||
|
# defaults firebaseEnabled=true; this only forwards an explicit opt-out.
|
||||||
|
gradle_firebase_arg=""
|
||||||
|
firebase_enabled_normalized=$(printf '%s' "${FIREBASE_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
case "$firebase_enabled_normalized" in
|
||||||
|
false|0|no)
|
||||||
|
gradle_firebase_arg="-PfirebaseEnabled=false"
|
||||||
|
log_info "Firebase disabled for this Android build (FIREBASE_ENABLED=${FIREBASE_ENABLED})"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Print build header
|
# Print build header
|
||||||
print_header "TimeSafari Android Build Process"
|
print_header "TimeSafari Android Build Process"
|
||||||
|
|
||||||
@@ -528,7 +543,7 @@ if [ "$CLEAN_ONLY" = true ]; then
|
|||||||
log_info "Clean-only mode: cleaning build artifacts"
|
log_info "Clean-only mode: cleaning build artifacts"
|
||||||
safe_execute "Cleaning Android app" "npm run clean:android" || exit 1
|
safe_execute "Cleaning Android app" "npm run clean:android" || exit 1
|
||||||
safe_execute "Cleaning dist directory" "clean_build_artifacts dist" || exit 1
|
safe_execute "Cleaning dist directory" "clean_build_artifacts dist" || exit 1
|
||||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean ${gradle_firebase_arg} && cd .." || exit 4
|
||||||
log_success "Clean completed successfully!"
|
log_success "Clean completed successfully!"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -561,7 +576,7 @@ if [ "$DEPLOY_APP" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Build APK
|
# Build APK
|
||||||
safe_execute "Building APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Building APK" "cd android && ./gradlew assembleDebug ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
|
|
||||||
# Install APK on device
|
# Install APK on device
|
||||||
safe_execute "Installing APK on device" "adb install -r android/app/build/outputs/apk/debug/app-debug.apk" || exit 6
|
safe_execute "Installing APK on device" "adb install -r android/app/build/outputs/apk/debug/app-debug.apk" || exit 6
|
||||||
@@ -628,26 +643,26 @@ fi
|
|||||||
safe_execute "Generating assets" "npx capacitor-assets generate --android --assetPath resources" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --android --assetPath resources" || exit 7
|
||||||
|
|
||||||
# Step 8: Clean Gradle build
|
# Step 8: Clean Gradle build
|
||||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean ${gradle_firebase_arg} && cd .." || exit 4
|
||||||
|
|
||||||
# Step 9: Build based on type
|
# Step 9: Build based on type
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 10: Build APK/AAB if requested
|
# Step 10: Build APK/AAB if requested
|
||||||
if [ "$BUILD_APK" = true ]; then
|
if [ "$BUILD_APK" = true ]; then
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
else
|
else
|
||||||
safe_execute "Building release APK" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
safe_execute "Building release APK" "cd android && ./gradlew assembleRelease ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BUILD_AAB" = true ]; then
|
if [ "$BUILD_AAB" = true ]; then
|
||||||
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease ${gradle_firebase_arg} && cd .." || exit 5
|
||||||
if [ "$BUILD_MODE" = "production" ]; then
|
if [ "$BUILD_MODE" = "production" ]; then
|
||||||
safe_execute "Checking production AAB" "./scripts/check-prod-bundle.sh android/app/build/outputs/bundle/release/app-release.aab" || exit 10
|
safe_execute "Checking production AAB" "./scripts/check-prod-bundle.sh android/app/build/outputs/bundle/release/app-release.aab" || exit 10
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ SENSITIVE=(
|
|||||||
"electron/**"
|
"electron/**"
|
||||||
"android/**"
|
"android/**"
|
||||||
"ios/**"
|
"ios/**"
|
||||||
"sw_scripts/**"
|
|
||||||
"sw_combine.js"
|
|
||||||
"Dockerfile"
|
"Dockerfile"
|
||||||
"docker/**"
|
"docker/**"
|
||||||
"capacitor.config.ts"
|
"capacitor.config.ts"
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ print_ios_usage() {
|
|||||||
echo " --auto-run Auto-run app after build"
|
echo " --auto-run Auto-run app after build"
|
||||||
echo " --api-ip <ip> Custom IP address for claim API (uses Capacitor default)"
|
echo " --api-ip <ip> Custom IP address for claim API (uses Capacitor default)"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Firebase is on by default. Opt out with FIREBASE_ENABLED=false"
|
||||||
|
echo "(forwarded to xcodebuild as FIREBASE_ENABLED=NO)."
|
||||||
|
echo ""
|
||||||
echo "Common Options:"
|
echo "Common Options:"
|
||||||
echo " -h, --help Show this help message"
|
echo " -h, --help Show this help message"
|
||||||
echo " -v, --verbose Enable verbose logging"
|
echo " -v, --verbose Enable verbose logging"
|
||||||
@@ -321,6 +324,17 @@ clean_ios_build() {
|
|||||||
log_success "iOS build cleaned"
|
log_success "iOS build cleaned"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map FIREBASE_ENABLED onto the Xcode build setting the run-script phases read.
|
||||||
|
# Unset stays YES. false, 0, and no (any case) become NO.
|
||||||
|
ios_firebase_build_setting() {
|
||||||
|
local normalized
|
||||||
|
normalized=$(printf '%s' "${FIREBASE_ENABLED:-YES}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
case "$normalized" in
|
||||||
|
false|0|no) printf 'NO' ;;
|
||||||
|
*) printf 'YES' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Function to build iOS app
|
# Function to build iOS app
|
||||||
build_ios_app() {
|
build_ios_app() {
|
||||||
local build_config=""
|
local build_config=""
|
||||||
@@ -358,6 +372,7 @@ build_ios_app() {
|
|||||||
-configuration "$build_config" \
|
-configuration "$build_config" \
|
||||||
-destination "$destination" \
|
-destination "$destination" \
|
||||||
build \
|
build \
|
||||||
|
"FIREBASE_ENABLED=$(ios_firebase_build_setting)" \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
@@ -537,6 +552,13 @@ if [ "$ASSETS_ONLY" = true ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Firebase is on unless FIREBASE_ENABLED=false. Fail before the web build and
|
||||||
|
# xcodebuild when the plist is missing. Xcode runs the same script as a build
|
||||||
|
# phase so GUI builds get the same check and bundle the plist.
|
||||||
|
if ! ios/App/scripts/bundle-firebase-config.sh --check; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Handle deploy-app mode
|
# Handle deploy-app mode
|
||||||
if [ "$DEPLOY_APP" = true ]; then
|
if [ "$DEPLOY_APP" = true ]; then
|
||||||
deploy_ios_app
|
deploy_ios_app
|
||||||
@@ -701,6 +723,7 @@ if [ "$BUILD_IPA" = true ]; then
|
|||||||
-configuration Release \
|
-configuration Release \
|
||||||
-archivePath build/App.xcarchive \
|
-archivePath build/App.xcarchive \
|
||||||
archive \
|
archive \
|
||||||
|
"FIREBASE_ENABLED=$(ios_firebase_build_setting)" \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
|||||||
+23
-16
@@ -5,25 +5,31 @@
|
|||||||
# This script handles the complete web build process including cleanup,
|
# This script handles the complete web build process including cleanup,
|
||||||
# environment setup, Vite build, and optional Docker containerization.
|
# environment setup, Vite build, and optional Docker containerization.
|
||||||
#
|
#
|
||||||
|
# Every invocation except --dev writes dist/. --dev alone starts the Vite dev
|
||||||
|
# server and writes nothing.
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/build-web.sh # Development build
|
# ./scripts/build-web.sh # Production build into dist/
|
||||||
# ./scripts/build-web.sh --dev # Development build (explicit)
|
# ./scripts/build-web.sh --prod # Production build (explicit)
|
||||||
# ./scripts/build-web.sh --test # Test environment build
|
# ./scripts/build-web.sh --test # Test environment build
|
||||||
# ./scripts/build-web.sh --prod # Production environment build
|
# ./scripts/build-web.sh --dev # Start Vite dev server (no dist/)
|
||||||
# ./scripts/build-web.sh --docker # Build with Docker containerization
|
# ./scripts/build-web.sh --docker # Production build + Docker image
|
||||||
# ./scripts/build-web.sh --docker:test # Test environment + Docker
|
# ./scripts/build-web.sh --docker:test # Test environment + Docker
|
||||||
# ./scripts/build-web.sh --docker:prod # Production environment + Docker
|
# ./scripts/build-web.sh --docker:prod # Production environment + Docker
|
||||||
# ./scripts/build-web.sh --serve # Build and serve locally
|
# ./scripts/build-web.sh --serve # Production build, then serve dist/
|
||||||
# ./scripts/build-web.sh --help # Show help
|
# ./scripts/build-web.sh --help # Show help
|
||||||
# ./scripts/build-web.sh --verbose # Enable verbose logging
|
# ./scripts/build-web.sh --verbose # Enable verbose logging
|
||||||
#
|
#
|
||||||
# NPM Script Equivalents:
|
# NPM Script Equivalents:
|
||||||
# npm run build:web # Development build
|
# npm run dev # Vite dev server
|
||||||
|
# npm run build:web # Production build
|
||||||
# npm run build:web:test # Test environment build
|
# npm run build:web:test # Test environment build
|
||||||
# npm run build:web:prod # Production environment build
|
# npm run build:web:prod # Production environment build
|
||||||
# npm run build:web:docker # Docker build
|
# npm run build:web:docker # Production Docker build
|
||||||
# npm run build:web:docker:test # Test Docker build
|
# npm run build:web:docker:test # Test Docker build
|
||||||
# npm run build:web:docker:prod # Production Docker build
|
# npm run build:web:docker:prod # Production Docker build
|
||||||
|
# npm run build:web:serve # Production build, then serve
|
||||||
|
# npm run build:web:serve:test # Test build, then serve
|
||||||
#
|
#
|
||||||
# Exit Codes:
|
# Exit Codes:
|
||||||
# 1 - Web cleanup failed
|
# 1 - Web cleanup failed
|
||||||
@@ -40,7 +46,7 @@ set -e
|
|||||||
source "$(dirname "$0")/common.sh"
|
source "$(dirname "$0")/common.sh"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
BUILD_MODE="development"
|
BUILD_MODE="production"
|
||||||
BUILD_ACTION="build"
|
BUILD_ACTION="build"
|
||||||
DOCKER_BUILD=false
|
DOCKER_BUILD=false
|
||||||
SERVE_BUILD=false
|
SERVE_BUILD=false
|
||||||
@@ -51,9 +57,10 @@ show_usage() {
|
|||||||
Usage: $0 [OPTIONS]
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--dev, --development Build for development environment (default)
|
--prod, --production Build for production environment (default)
|
||||||
--test Build for test environment
|
--test Build for test environment
|
||||||
--prod, --production Build for production environment
|
--dev, --development Start the Vite dev server (with --serve or
|
||||||
|
--docker: build for development instead)
|
||||||
--docker Build and create Docker container
|
--docker Build and create Docker container
|
||||||
--docker:test Build for test environment and create Docker container
|
--docker:test Build for test environment and create Docker container
|
||||||
--docker:prod Build for production environment and create Docker container
|
--docker:prod Build for production environment and create Docker container
|
||||||
@@ -63,18 +70,18 @@ OPTIONS:
|
|||||||
--env Show environment variables
|
--env Show environment variables
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
$0 # Development build
|
$0 # Production build
|
||||||
$0 --test # Test environment build
|
$0 --test # Test environment build
|
||||||
$0 --prod # Production environment build
|
$0 --dev # Development server
|
||||||
$0 --docker # Development + Docker
|
$0 --docker # Production + Docker
|
||||||
$0 --docker:test # Test + Docker
|
$0 --docker:test # Test + Docker
|
||||||
$0 --docker:prod # Production + Docker
|
$0 --docker:prod # Production + Docker
|
||||||
$0 --serve # Build and serve
|
$0 --serve # Production build, then serve
|
||||||
|
|
||||||
BUILD MODES:
|
BUILD MODES:
|
||||||
development: Starts Vite development server with hot reload (default)
|
production: Optimized for production with full minification and optimization (default)
|
||||||
test: Optimized for testing with minimal minification
|
test: Optimized for testing with minimal minification
|
||||||
production: Optimized for production with full minification and optimization
|
development: Starts Vite development server with hot reload
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,4 +161,4 @@ echo "✅ Database clearing instructions completed!"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "💡 Tip: After clearing the database, restart your development server:"
|
echo "💡 Tip: After clearing the database, restart your development server:"
|
||||||
echo " npm run build:electron:dev # For Electron"
|
echo " npm run build:electron:dev # For Electron"
|
||||||
echo " npm run build:web:dev # For Web"
|
echo " npm run dev # For Web"
|
||||||
@@ -283,7 +283,6 @@ const runDeeplinkTests = async (log) => {
|
|||||||
const buildWebAssets = async (log) => {
|
const buildWebAssets = async (log) => {
|
||||||
log('🌐 Building web assets...');
|
log('🌐 Building web assets...');
|
||||||
execSync('rm -rf dist', { stdio: 'inherit' });
|
execSync('rm -rf dist', { stdio: 'inherit' });
|
||||||
execSync('npm run build:web', { stdio: 'inherit' });
|
|
||||||
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
||||||
log('✅ Web assets built successfully');
|
log('✅ Web assets built successfully');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export default config;
|
|||||||
log('🔄 Building web assets before adding iOS platform...');
|
log('🔄 Building web assets before adding iOS platform...');
|
||||||
try {
|
try {
|
||||||
execSync('rm -rf dist', { stdio: 'inherit' });
|
execSync('rm -rf dist', { stdio: 'inherit' });
|
||||||
execSync('npm run build:web', { stdio: 'inherit' });
|
|
||||||
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
||||||
log('✅ Web assets built successfully');
|
log('✅ Web assets built successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -416,7 +415,6 @@ const generateTestData = async (log) => {
|
|||||||
const buildWebAssets = async (log) => {
|
const buildWebAssets = async (log) => {
|
||||||
log('🌐 Building web assets...');
|
log('🌐 Building web assets...');
|
||||||
execSync('rm -rf dist', { stdio: 'inherit' });
|
execSync('rm -rf dist', { stdio: 'inherit' });
|
||||||
execSync('npm run build:web', { stdio: 'inherit' });
|
|
||||||
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
||||||
log('✅ Web assets built successfully');
|
log('✅ Web assets built successfully');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<!-- This is somewhat similar to ClaimView.vue and ConfirmGiftView.vue -->
|
<!-- This is somewhat similar to ClaimView.vue -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
<span v-if="R.isEmpty(visibleToDids)">
|
<span v-if="R.isEmpty(visibleToDids)">
|
||||||
@@ -21,10 +21,17 @@
|
|||||||
|
|
||||||
<div v-if="R.isEmpty(visibleToDids)">
|
<div v-if="R.isEmpty(visibleToDids)">
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
You can ask one of your contacts to take a look and see if their
|
Someone is still closer to them than you are.
|
||||||
contacts can see more details. Someone is connected to people closer
|
<router-link
|
||||||
to them; if you don't know who to ask, try the person who registered
|
v-if="claimJwtId"
|
||||||
you.
|
:to="{ path: '/claim/' + encodeURIComponent(claimJwtId) }"
|
||||||
|
class="text-blue-500"
|
||||||
|
@click="close"
|
||||||
|
>Open the record to see who can introduce you.</router-link
|
||||||
|
>
|
||||||
|
<span v-else>
|
||||||
|
If you don't know who to ask, try the person who registered you.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,8 +126,9 @@ import { createNotifyHelpers } from "@/utils/notify";
|
|||||||
import { TIMEOUTS } from "@/utils/notify";
|
import { TIMEOUTS } from "@/utils/notify";
|
||||||
import { NOTIFY_COPIED_TO_CLIPBOARD } from "@/constants/notifications";
|
import { NOTIFY_COPIED_TO_CLIPBOARD } from "@/constants/notifications";
|
||||||
import { APP_SERVER } from "@/constants/app";
|
import { APP_SERVER } from "@/constants/app";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
@Component({ name: "HiddenDidDialog" })
|
@Component({ name: "HiddenDidDialog", mixins: [PlatformServiceMixin] })
|
||||||
export default class HiddenDidDialog extends Vue {
|
export default class HiddenDidDialog extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
@@ -132,6 +140,7 @@ export default class HiddenDidDialog extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
canShare = false;
|
canShare = false;
|
||||||
|
claimJwtId = "";
|
||||||
deepLinkPathSuffix = "";
|
deepLinkPathSuffix = "";
|
||||||
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
|
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
|
||||||
|
|
||||||
@@ -171,7 +180,9 @@ export default class HiddenDidDialog extends Vue {
|
|||||||
allContacts: Array<Contact>,
|
allContacts: Array<Contact>,
|
||||||
activeDid: string,
|
activeDid: string,
|
||||||
allMyDids: Array<string>,
|
allMyDids: Array<string>,
|
||||||
|
claimJwtId = "",
|
||||||
) {
|
) {
|
||||||
|
this.claimJwtId = claimJwtId;
|
||||||
this.deepLinkPathSuffix = deepLinkPathSuffix;
|
this.deepLinkPathSuffix = deepLinkPathSuffix;
|
||||||
this.roleName = roleName;
|
this.roleName = roleName;
|
||||||
this.visibleToDids = visibleToDids;
|
this.visibleToDids = visibleToDids;
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Nearest neighbors in the registration graph between the user and the people
|
||||||
|
in a claim or profile. Everyone registered is connected through the
|
||||||
|
registration tree, so there is always someone one step closer; nothing is
|
||||||
|
fetched until the user expands this section.
|
||||||
|
-->
|
||||||
|
<div ref="section" data-testId="networkConnections">
|
||||||
|
<h2
|
||||||
|
class="text-lg font-semibold text-blue-500 cursor-pointer"
|
||||||
|
data-testId="networkConnectionsToggle"
|
||||||
|
@click="toggle()"
|
||||||
|
>
|
||||||
|
Who Can Introduce You
|
||||||
|
<font-awesome :icon="isExpanded ? 'chevron-down' : 'chevron-right'" />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="isExpanded" class="mt-2">
|
||||||
|
<p class="text-sm text-slate-700 mb-3">
|
||||||
|
Everyone on this server is connected to you through the people who
|
||||||
|
registered each other, so someone is always one step closer to
|
||||||
|
{{ subjectDescription }}. Ask that person for an introduction.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="!isRegistered" class="text-sm text-slate-700">
|
||||||
|
You are not registered yet, so you are not connected to anyone. The
|
||||||
|
person who registers you will be your first connection to everyone else;
|
||||||
|
find someone on the Help page.
|
||||||
|
</p>
|
||||||
|
<div v-else-if="isLoading" class="flex justify-center items-center py-8">
|
||||||
|
<font-awesome
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin-pulse text-2xl text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="bg-red-50 border border-red-300 rounded-md p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<font-awesome
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
class="text-red-500 mt-0.5"
|
||||||
|
/>
|
||||||
|
<p class="text-red-700">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="Object.keys(neighborsByDid).length === 0"
|
||||||
|
class="text-sm text-slate-500 italic"
|
||||||
|
>
|
||||||
|
Nobody else is involved here.
|
||||||
|
</p>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="(neighbors, did) in neighborsByDid" :key="did" class="mb-4">
|
||||||
|
<h3
|
||||||
|
v-if="Object.keys(neighborsByDid).length > 1"
|
||||||
|
class="text-sm font-medium text-slate-600 mb-1"
|
||||||
|
>
|
||||||
|
Near {{ displayName(did as string) }}:
|
||||||
|
</h3>
|
||||||
|
<!-- the server only returns no neighbors for an unregistered DID -->
|
||||||
|
<p
|
||||||
|
v-if="neighbors.length === 0"
|
||||||
|
class="text-sm text-slate-500 italic"
|
||||||
|
>
|
||||||
|
{{ capitalize(displayName(did as string)) }} is not registered on
|
||||||
|
this server, so nobody here is connected to them. The data may be a
|
||||||
|
mistake, or a test, or a reference to someone on a different system.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="neighbors[0].did === did"
|
||||||
|
class="text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
You are directly connected:
|
||||||
|
{{
|
||||||
|
neighbors[0].relation === "REGISTERED_BY_YOU"
|
||||||
|
? "you registered them."
|
||||||
|
: "they registered you."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="neighbor in neighbors"
|
||||||
|
:key="neighbor.did"
|
||||||
|
class="bg-slate-50 border border-slate-300 rounded-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
:title="`Copy ${linkName.toLowerCase()} and expand`"
|
||||||
|
class="text-blue-600 flex-shrink-0"
|
||||||
|
@click="onNeighborExpandClick(neighbor.did)"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="
|
||||||
|
expandedNeighborDid === neighbor.did
|
||||||
|
? 'chevron-down'
|
||||||
|
: 'chevron-right'
|
||||||
|
"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
{{ displayName(neighbor.did) }}
|
||||||
|
</button>
|
||||||
|
<span :class="relationBadgeClass(neighbor.relation)">
|
||||||
|
{{ relationLabel(neighbor.relation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="expandedNeighborDid === neighbor.did"
|
||||||
|
class="border-t border-slate-300 p-3 bg-white"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{ path: '/did/' + encodeURIComponent(neighbor.did) }"
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium underline"
|
||||||
|
>
|
||||||
|
Go to contact info
|
||||||
|
</router-link>
|
||||||
|
and send them the {{ linkName.toLowerCase() }} from your
|
||||||
|
clipboard. Ask them for an introduction.
|
||||||
|
<div
|
||||||
|
v-if="isNotInContacts(neighbor.did)"
|
||||||
|
class="flex flex-col gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-slate-600">
|
||||||
|
This person is connected to you, but they are not in this
|
||||||
|
device's contacts. Copy this DID link and check on another
|
||||||
|
device or check with different people.
|
||||||
|
</p>
|
||||||
|
<span class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="text-xs truncate text-slate-600 min-w-0">
|
||||||
|
{{ neighbor.did }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
title="Copy DID Link"
|
||||||
|
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
|
||||||
|
@click.prevent="onCopyDidClick(neighbor.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="copy" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import * as serverUtil from "../libs/endorserServer";
|
||||||
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
|
interface Neighbor {
|
||||||
|
did: string;
|
||||||
|
relation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expandable list of the people one step closer, in the registration tree,
|
||||||
|
* to each person in a claim or profile.
|
||||||
|
*
|
||||||
|
* Exactly one of claimId or profileId is expected. The claim endpoint returns
|
||||||
|
* neighbors keyed by each DID in the claim; the profile endpoint returns one
|
||||||
|
* list for the profile owner (keyed here by profileDid).
|
||||||
|
*/
|
||||||
|
@Component({ name: "NetworkConnections", mixins: [PlatformServiceMixin] })
|
||||||
|
export default class NetworkConnections extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
@Prop({ required: true }) activeDid!: string;
|
||||||
|
@Prop({ default: () => [] }) allContacts!: Array<Contact>;
|
||||||
|
@Prop({ default: () => [] }) allMyDids!: Array<string>;
|
||||||
|
/** endorser server for claims, partner server for profiles */
|
||||||
|
@Prop({ required: true }) apiServer!: string;
|
||||||
|
@Prop() claimId?: string;
|
||||||
|
@Prop({ required: true }) isRegistered!: boolean;
|
||||||
|
/** deep link copied for the user to send to a neighbor */
|
||||||
|
@Prop({ required: true }) link!: string;
|
||||||
|
/** eg. "Claim link" */
|
||||||
|
@Prop({ required: true }) linkName!: string;
|
||||||
|
@Prop() profileDid?: string;
|
||||||
|
@Prop() profileId?: string;
|
||||||
|
/** eg. "this person" */
|
||||||
|
@Prop({ required: true }) subjectDescription!: string;
|
||||||
|
|
||||||
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
errorMessage = "";
|
||||||
|
expandedNeighborDid: string | null = null;
|
||||||
|
isExpanded = false;
|
||||||
|
isLoaded = false;
|
||||||
|
isLoading = false;
|
||||||
|
neighborsByDid: Record<string, Array<Neighbor>> = {};
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
if (this.isExpanded) {
|
||||||
|
this.isExpanded = false;
|
||||||
|
} else {
|
||||||
|
await this.expand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the section and loads the neighbors the first time.
|
||||||
|
* @param scroll whether to bring the section into view, for links elsewhere on the page
|
||||||
|
*/
|
||||||
|
async expand(scroll = false) {
|
||||||
|
this.isExpanded = true;
|
||||||
|
if (scroll) {
|
||||||
|
(this.$refs.section as HTMLElement | undefined)?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!this.isLoaded && !this.isLoading && this.isRegistered) {
|
||||||
|
await this.loadNeighbors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNeighbors() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.errorMessage = "";
|
||||||
|
try {
|
||||||
|
const url = this.claimId
|
||||||
|
? this.apiServer +
|
||||||
|
"/api/claim/claimNearestNeighbors/" +
|
||||||
|
encodeURIComponent(this.claimId)
|
||||||
|
: this.apiServer +
|
||||||
|
"/api/partner/userProfileNearestNeighbors/" +
|
||||||
|
encodeURIComponent(this.profileId || "");
|
||||||
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Status ${response.status} from ${url}`);
|
||||||
|
}
|
||||||
|
const data = (await response.json()).data;
|
||||||
|
const raw: Record<string, unknown> = this.claimId
|
||||||
|
? data || {}
|
||||||
|
: { [this.profileDid || ""]: data };
|
||||||
|
const result: Record<string, Array<Neighbor>> = {};
|
||||||
|
for (const [did, neighbors] of Object.entries(raw)) {
|
||||||
|
// the user's own entry adds nothing since "You" is already known
|
||||||
|
if (did === this.activeDid) continue;
|
||||||
|
// the server sends an {error} object instead of a list when one lookup fails
|
||||||
|
if (!Array.isArray(neighbors)) {
|
||||||
|
throw new Error(`Error for ${did}: ${JSON.stringify(neighbors)}`);
|
||||||
|
}
|
||||||
|
result[did] = neighbors.filter((neighbor: Neighbor) => neighbor.did);
|
||||||
|
}
|
||||||
|
this.neighborsByDid = result;
|
||||||
|
this.isLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error loading network connections: ${error}`, true);
|
||||||
|
this.errorMessage =
|
||||||
|
"An error occurred while loading network connections.";
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName(did: string): string {
|
||||||
|
// the server keys a DID hidden from this user as HIDDEN_DID + "_" + index
|
||||||
|
const isHiddenKey = did.startsWith(serverUtil.HIDDEN_DID + "_");
|
||||||
|
return serverUtil.didInfo(
|
||||||
|
isHiddenKey ? serverUtil.HIDDEN_DID : did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalize(text: string): string {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
isNotInContacts(did: string): boolean {
|
||||||
|
return !this.allContacts.some((contact) => contact.did === did);
|
||||||
|
}
|
||||||
|
|
||||||
|
relationLabel(relation: string): string {
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return "Registered by You";
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return "Registered You";
|
||||||
|
case "TARGET":
|
||||||
|
return "Yourself";
|
||||||
|
default:
|
||||||
|
return relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relationBadgeClass(relation: string): string {
|
||||||
|
const baseClasses =
|
||||||
|
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return `${baseClasses} bg-blue-100 text-blue-700`;
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return `${baseClasses} bg-green-100 text-green-700`;
|
||||||
|
case "TARGET":
|
||||||
|
return `${baseClasses} bg-purple-100 text-purple-700`;
|
||||||
|
default:
|
||||||
|
return `${baseClasses} bg-slate-100 text-slate-700`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the link for the neighbor and toggles their instructions
|
||||||
|
*/
|
||||||
|
async onNeighborExpandClick(did: string) {
|
||||||
|
if (this.expandedNeighborDid === did) {
|
||||||
|
this.expandedNeighborDid = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyToClipboard(this.link);
|
||||||
|
this.notify.copied(this.linkName, TIMEOUTS.STANDARD);
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error copying ${this.linkName}: ${error}`, true);
|
||||||
|
this.notify.error(`Failed to copy ${this.linkName.toLowerCase()}.`);
|
||||||
|
}
|
||||||
|
this.expandedNeighborDid = did;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onCopyDidClick(did: string) {
|
||||||
|
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(deepLink);
|
||||||
|
this.notify.copied("DID link", TIMEOUTS.STANDARD);
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error copying DID link: ${error}`, true);
|
||||||
|
this.notify.error("Failed to copy DID link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- claims, and gifts & offers -->
|
||||||
|
<div v-if="kind === 'claim'" class="flex items-start gap-2">
|
||||||
|
<div class="grow min-w-0">
|
||||||
|
<span class="font-medium">{{ claimTypeText }}</span>
|
||||||
|
<span v-if="claimDescription" class="block truncate">{{
|
||||||
|
claimDescription
|
||||||
|
}}</span>
|
||||||
|
<span class="block text-sm text-slate-500">
|
||||||
|
from {{ personName(claimRow.issuer) }}
|
||||||
|
<template v-if="claimRow.issuedAt">
|
||||||
|
· {{ formatDate(claimRow.issuedAt) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="{ path: '/claim/' + encodeURIComponent(claimRow.id) }"
|
||||||
|
class="text-blue-500 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<font-awesome icon="file-lines" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- projects -->
|
||||||
|
<div v-else-if="kind === 'plan'">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="grow min-w-0">
|
||||||
|
<span class="font-medium">{{
|
||||||
|
planRow.name || "Unnamed Project"
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="similarity !== undefined"
|
||||||
|
class="text-sm text-slate-500 ml-2"
|
||||||
|
>{{ similarity }}% match</span
|
||||||
|
>
|
||||||
|
<span v-if="planRow.description" class="block truncate">{{
|
||||||
|
planRow.description
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="{ path: '/project/' + encodeURIComponent(planRow.handleId) }"
|
||||||
|
class="text-blue-500 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<font-awesome icon="file-lines" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<PlanChangeDiff
|
||||||
|
v-if="planChange"
|
||||||
|
:plan-change="planChange"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- profiles -->
|
||||||
|
<div v-else-if="kind === 'profile'" class="flex items-start gap-2">
|
||||||
|
<div class="grow min-w-0">
|
||||||
|
<span class="font-medium">{{ personName(profileRow.issuerDid) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="similarity !== undefined"
|
||||||
|
class="text-sm text-slate-500 ml-2"
|
||||||
|
>{{ similarity }}% match</span
|
||||||
|
>
|
||||||
|
<span class="block truncate">{{ profileRow.description }}</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="profileRow.rowId !== undefined"
|
||||||
|
:to="{
|
||||||
|
path: '/user-profile/' + encodeURIComponent(String(profileRow.rowId)),
|
||||||
|
}"
|
||||||
|
class="text-blue-500 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<font-awesome icon="file-lines" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import PlanChangeDiff from "./PlanChangeDiff.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import type {
|
||||||
|
AlertSearchJwtWithClaimRecord,
|
||||||
|
AlertSearchPlanRecord,
|
||||||
|
AlertSearchProfileRecord,
|
||||||
|
} from "../interfaces/alertSearch";
|
||||||
|
import { PlanSummaryAndPreviousClaim } from "../interfaces/records";
|
||||||
|
import { didInfo } from "../libs/endorserServer";
|
||||||
|
import type { NewActivityBucket } from "../libs/newActivity";
|
||||||
|
|
||||||
|
type RowKind = "claim" | "plan" | "profile";
|
||||||
|
|
||||||
|
const BUCKET_KINDS: Record<NewActivityBucket, RowKind> = {
|
||||||
|
claims: "claim",
|
||||||
|
personalPlanContributions: "claim",
|
||||||
|
trackedPlanClaims: "claim",
|
||||||
|
trackedPlanUpdates: "plan",
|
||||||
|
plansNearby: "plan",
|
||||||
|
plansSimilar: "plan",
|
||||||
|
profilesNearby: "profile",
|
||||||
|
profilesSimilar: "profile",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Readable names for the claim types alertSearch returns most. */
|
||||||
|
const CLAIM_TYPE_TEXT: Record<string, string> = {
|
||||||
|
AgreeAction: "Agreement",
|
||||||
|
GiveAction: "Gift",
|
||||||
|
JoinAction: "Join",
|
||||||
|
Offer: "Offer",
|
||||||
|
PlanAction: "Project",
|
||||||
|
RegisterAction: "Registration",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row of a New Activity bucket, shaped by the kind of record it is. */
|
||||||
|
@Component({ components: { PlanChangeDiff } })
|
||||||
|
export default class NewActivityRow extends Vue {
|
||||||
|
@Prop({ required: true }) bucket!: NewActivityBucket;
|
||||||
|
@Prop({ required: true }) row!: unknown;
|
||||||
|
@Prop({ required: true }) activeDid!: string;
|
||||||
|
@Prop({ default: () => [] }) allMyDids!: string[];
|
||||||
|
@Prop({ default: () => [] }) allContacts!: Contact[];
|
||||||
|
@Prop() planChange?: PlanSummaryAndPreviousClaim;
|
||||||
|
|
||||||
|
get kind(): RowKind {
|
||||||
|
return BUCKET_KINDS[this.bucket];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Similar matches wrap the record with its similarity, 0 to 1. */
|
||||||
|
get record(): Record<string, unknown> {
|
||||||
|
const row = this.row as Record<string, unknown>;
|
||||||
|
return ((row.profile ?? row.plan ?? row) as Record<string, unknown>) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get similarity(): number | undefined {
|
||||||
|
const value = (this.row as { similarity?: unknown }).similarity;
|
||||||
|
return typeof value === "number" ? Math.round(value * 100) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get claimRow(): AlertSearchJwtWithClaimRecord {
|
||||||
|
return this.record as unknown as AlertSearchJwtWithClaimRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planRow(): AlertSearchPlanRecord {
|
||||||
|
return this.record as unknown as AlertSearchPlanRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
get profileRow(): AlertSearchProfileRecord {
|
||||||
|
return this.record as unknown as AlertSearchProfileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
get claimTypeText(): string {
|
||||||
|
const type = this.claimRow.claimType || "";
|
||||||
|
return CLAIM_TYPE_TEXT[type] || type || "Claim";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The description inside the claim text, when the row carries it. */
|
||||||
|
get claimDescription(): string {
|
||||||
|
const text = this.claimRow.claim;
|
||||||
|
if (!text) return "";
|
||||||
|
try {
|
||||||
|
const claim = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
const subject = (claim.credentialSubject ?? claim) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const object = subject.object as Record<string, unknown> | undefined;
|
||||||
|
const offered = subject.itemOffered as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const description =
|
||||||
|
subject.description ?? object?.description ?? offered?.description;
|
||||||
|
return typeof description === "string" ? description : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
personName(did: string | undefined): string {
|
||||||
|
return didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="getPlanDifferences(planChange.plan.handleId)"
|
||||||
|
class="text-sm mt-2"
|
||||||
|
>
|
||||||
|
<div class="font-medium mb-2">Changes</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
class="w-full text-xs border-collapse border border-gray-300 rounded-lg shadow-sm bg-white"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50">
|
||||||
|
<th
|
||||||
|
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
|
||||||
|
></th>
|
||||||
|
<th
|
||||||
|
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Current
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(difference, field) in getPlanDifferences(
|
||||||
|
planChange.plan.handleId,
|
||||||
|
)"
|
||||||
|
:key="field"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border border-gray-300 px-3 py-2 font-medium text-gray-800 break-words"
|
||||||
|
>
|
||||||
|
{{ getDisplayFieldName(field) }}
|
||||||
|
</td>
|
||||||
|
<template v-if="field === 'description'">
|
||||||
|
<td
|
||||||
|
colspan="2"
|
||||||
|
class="border border-gray-300 px-3 py-2 break-words align-top"
|
||||||
|
>
|
||||||
|
<div class="w-3/5 mx-auto">
|
||||||
|
<div
|
||||||
|
v-for="(change, index) in getDescriptionDiff(
|
||||||
|
difference.old,
|
||||||
|
difference.new,
|
||||||
|
)"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<vue-markdown
|
||||||
|
:source="change.value"
|
||||||
|
:style="getDiffStyle(change)"
|
||||||
|
class="markdown-content inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<td
|
||||||
|
class="border border-gray-300 px-3 py-2 text-gray-600 break-words align-top"
|
||||||
|
>
|
||||||
|
<span>{{ formatFieldValue(difference.old) }}</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="border border-gray-300 px-3 py-2 text-green-700 font-medium break-words align-top"
|
||||||
|
>
|
||||||
|
<span>{{ formatFieldValue(difference.new) }}</span>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
The changes are not important, like it was saved by accident or you've
|
||||||
|
seen it all before.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* What changed in a starred project since the claim before the change: a
|
||||||
|
* table of previous and current values, with a line diff of the description.
|
||||||
|
*/
|
||||||
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
|
import VueMarkdown from "vue-markdown-render";
|
||||||
|
import { diffLines, Change } from "diff";
|
||||||
|
import * as R from "ramda";
|
||||||
|
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { GenericCredWrapper } from "@/interfaces";
|
||||||
|
import { PlanActionClaim } from "../interfaces/claims";
|
||||||
|
import {
|
||||||
|
PlanSummaryAndPreviousClaim,
|
||||||
|
PlanSummaryRecord,
|
||||||
|
} from "../interfaces/records";
|
||||||
|
import { didInfoOrNobody } from "../libs/endorserServer";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
@Component({ components: { VueMarkdown } })
|
||||||
|
export default class PlanChangeDiff extends Vue {
|
||||||
|
@Prop({ required: true }) planChange!: PlanSummaryAndPreviousClaim;
|
||||||
|
@Prop({ required: true }) activeDid!: string;
|
||||||
|
@Prop({ default: () => [] }) allMyDids!: string[];
|
||||||
|
@Prop({ default: () => [] }) allContacts!: Contact[];
|
||||||
|
|
||||||
|
planDifferences: Record<
|
||||||
|
string,
|
||||||
|
Record<string, { old: unknown; new: unknown }>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.analyzePlanDifferences([this.planChange]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes differences between current plans and their previous claims
|
||||||
|
*
|
||||||
|
* Walks through a list of PlanSummaryAndPreviousClaim items and stores the
|
||||||
|
* differences between the previous claim and the current plan. This method
|
||||||
|
* extracts the claim from the wrappedClaimBefore object and compares relevant
|
||||||
|
* fields with the current plan.
|
||||||
|
*
|
||||||
|
* @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze
|
||||||
|
*/
|
||||||
|
analyzePlanDifferences(planChanges: Array<PlanSummaryAndPreviousClaim>) {
|
||||||
|
this.planDifferences = {};
|
||||||
|
|
||||||
|
for (const planChange of planChanges) {
|
||||||
|
const currentPlan: PlanSummaryRecord = planChange.plan;
|
||||||
|
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
|
||||||
|
planChange.wrappedClaimBefore;
|
||||||
|
|
||||||
|
// Extract the actual claim from the wrapped claim
|
||||||
|
let previousClaim: PlanActionClaim | undefined;
|
||||||
|
|
||||||
|
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
|
||||||
|
if (
|
||||||
|
embeddedClaim &&
|
||||||
|
typeof embeddedClaim === "object" &&
|
||||||
|
"credentialSubject" in embeddedClaim
|
||||||
|
) {
|
||||||
|
// It's a Verifiable Credential
|
||||||
|
previousClaim =
|
||||||
|
(embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim;
|
||||||
|
} else {
|
||||||
|
// It's a direct claim
|
||||||
|
previousClaim = embeddedClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousClaim) {
|
||||||
|
// Can happen when a project is starred after the stored last-seen-change-jwt ID
|
||||||
|
// so we'll just leave the message saying there are no important differences.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const differences: Record<string, { old: unknown; new: unknown }> = {};
|
||||||
|
|
||||||
|
// Compare name
|
||||||
|
const normalizedOldName = this.normalizeValueForComparison(
|
||||||
|
previousClaim.name,
|
||||||
|
);
|
||||||
|
const normalizedNewName = this.normalizeValueForComparison(
|
||||||
|
currentPlan.name,
|
||||||
|
);
|
||||||
|
if (!R.equals(normalizedOldName, normalizedNewName)) {
|
||||||
|
differences.name = {
|
||||||
|
old: previousClaim.name,
|
||||||
|
new: currentPlan.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare description
|
||||||
|
const normalizedOldDescription = this.normalizeValueForComparison(
|
||||||
|
previousClaim.description,
|
||||||
|
);
|
||||||
|
const normalizedNewDescription = this.normalizeValueForComparison(
|
||||||
|
currentPlan.description,
|
||||||
|
);
|
||||||
|
if (!R.equals(normalizedOldDescription, normalizedNewDescription)) {
|
||||||
|
differences.description = {
|
||||||
|
old: previousClaim.description,
|
||||||
|
new: currentPlan.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare location (combine latitude and longitude into one row)
|
||||||
|
const oldLat = this.normalizeValueForComparison(
|
||||||
|
previousClaim.location?.geo?.latitude,
|
||||||
|
);
|
||||||
|
const oldLon = this.normalizeValueForComparison(
|
||||||
|
previousClaim.location?.geo?.longitude,
|
||||||
|
);
|
||||||
|
const newLat = this.normalizeValueForComparison(currentPlan.locLat);
|
||||||
|
const newLon = this.normalizeValueForComparison(currentPlan.locLon);
|
||||||
|
|
||||||
|
if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) {
|
||||||
|
differences.location = {
|
||||||
|
old: this.formatLocationValue(oldLat, oldLon, true),
|
||||||
|
new: this.formatLocationValue(newLat, newLon, false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare agent (issuer)
|
||||||
|
const oldAgent = didInfoOrNobody(
|
||||||
|
previousClaim.agent?.identifier,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
const newAgent = didInfoOrNobody(
|
||||||
|
currentPlan.agentDid,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
const normalizedOldAgent = this.normalizeValueForComparison(oldAgent);
|
||||||
|
const normalizedNewAgent = this.normalizeValueForComparison(newAgent);
|
||||||
|
if (!R.equals(normalizedOldAgent, normalizedNewAgent)) {
|
||||||
|
differences.agent = {
|
||||||
|
old: oldAgent,
|
||||||
|
new: newAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare start time
|
||||||
|
const oldStartTime = previousClaim.startTime;
|
||||||
|
const newStartTime = currentPlan.startTime;
|
||||||
|
const normalizedOldStartTime =
|
||||||
|
this.normalizeDateForComparison(oldStartTime);
|
||||||
|
const normalizedNewStartTime =
|
||||||
|
this.normalizeDateForComparison(newStartTime);
|
||||||
|
if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) {
|
||||||
|
differences.startTime = {
|
||||||
|
old: oldStartTime,
|
||||||
|
new: newStartTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare end time
|
||||||
|
const oldEndTime = previousClaim.endTime;
|
||||||
|
const newEndTime = currentPlan.endTime;
|
||||||
|
const normalizedOldEndTime = this.normalizeDateForComparison(oldEndTime);
|
||||||
|
const normalizedNewEndTime = this.normalizeDateForComparison(newEndTime);
|
||||||
|
if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) {
|
||||||
|
differences.endTime = {
|
||||||
|
old: oldEndTime,
|
||||||
|
new: newEndTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare image
|
||||||
|
const oldImage = previousClaim.image;
|
||||||
|
const newImage = currentPlan.image;
|
||||||
|
const normalizedOldImage = this.normalizeValueForComparison(oldImage);
|
||||||
|
const normalizedNewImage = this.normalizeValueForComparison(newImage);
|
||||||
|
if (!R.equals(normalizedOldImage, normalizedNewImage)) {
|
||||||
|
differences.image = {
|
||||||
|
old: oldImage,
|
||||||
|
new: newImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare url
|
||||||
|
const oldUrl = previousClaim.url;
|
||||||
|
const newUrl = currentPlan.url;
|
||||||
|
const normalizedOldUrl = this.normalizeValueForComparison(oldUrl);
|
||||||
|
const normalizedNewUrl = this.normalizeValueForComparison(newUrl);
|
||||||
|
if (!R.equals(normalizedOldUrl, normalizedNewUrl)) {
|
||||||
|
differences.url = {
|
||||||
|
old: oldUrl,
|
||||||
|
new: newUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store differences if any were found
|
||||||
|
if (!R.isEmpty(differences)) {
|
||||||
|
this.planDifferences[currentPlan.handleId] = differences;
|
||||||
|
logger.debug(
|
||||||
|
"[PlanChangeDiff] Plan differences found for",
|
||||||
|
currentPlan.handleId,
|
||||||
|
differences,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[PlanChangeDiff] Analyzed",
|
||||||
|
planChanges.length,
|
||||||
|
"plan changes, found differences in",
|
||||||
|
Object.keys(this.planDifferences).length,
|
||||||
|
"plans",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes values for comparison - treats null, undefined, and empty string as equivalent
|
||||||
|
*
|
||||||
|
* @param value The value to normalize
|
||||||
|
* @returns The normalized value (null for null/undefined/empty, otherwise the original value)
|
||||||
|
*/
|
||||||
|
normalizeValueForComparison<T>(value: T | null | undefined): T | null {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes date values for comparison by converting strings to Date objects
|
||||||
|
* Returns null for null/undefined/empty values, Date objects for valid date strings
|
||||||
|
*/
|
||||||
|
normalizeDateForComparison(value: unknown): Date | null {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const date = new Date(value);
|
||||||
|
// Check if the date is valid
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return isNaN(value.getTime()) ? null : value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the differences for a specific plan by handle ID
|
||||||
|
*
|
||||||
|
* @param handleId The handle ID of the plan to get differences for
|
||||||
|
* @returns The differences object or null if no differences found
|
||||||
|
*/
|
||||||
|
getPlanDifferences(
|
||||||
|
handleId: string,
|
||||||
|
): Record<string, { old: unknown; new: unknown }> | null {
|
||||||
|
return this.planDifferences[handleId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a field value for display in the UI
|
||||||
|
*
|
||||||
|
* @param value The value to format
|
||||||
|
* @returns A human-readable string representation
|
||||||
|
*/
|
||||||
|
formatFieldValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "Not set";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const stringValue = value || "Empty";
|
||||||
|
|
||||||
|
// Check if it's a date/time string
|
||||||
|
if (this.isDateTimeString(stringValue)) {
|
||||||
|
return this.formatDateTime(stringValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a URL
|
||||||
|
if (this.isUrl(stringValue)) {
|
||||||
|
return stringValue; // Keep URLs as-is for now
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringValue;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "Yes" : "No";
|
||||||
|
}
|
||||||
|
// For complex objects, stringify
|
||||||
|
const stringified = JSON.stringify(value);
|
||||||
|
return stringified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string appears to be a date/time string
|
||||||
|
*/
|
||||||
|
isDateTimeString(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
// Check for ISO 8601 format or other common date formats
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/;
|
||||||
|
return dateRegex.test(value) || !isNaN(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a URL
|
||||||
|
*/
|
||||||
|
isUrl(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
new URL(value);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date/time string for display
|
||||||
|
*/
|
||||||
|
formatDateTime(value: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return value; // Return original if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a human-readable field name for display
|
||||||
|
*
|
||||||
|
* @param fieldName The internal field name
|
||||||
|
* @returns A formatted field name for display
|
||||||
|
*/
|
||||||
|
getDisplayFieldName(fieldName: string): string {
|
||||||
|
const fieldNameMap: Record<string, string> = {
|
||||||
|
name: "Name",
|
||||||
|
description: "Description",
|
||||||
|
location: "Location",
|
||||||
|
agent: "Agent",
|
||||||
|
startTime: "Start Time",
|
||||||
|
endTime: "End Time",
|
||||||
|
image: "Image",
|
||||||
|
url: "URL",
|
||||||
|
};
|
||||||
|
return fieldNameMap[fieldName] || fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats location values for display
|
||||||
|
*
|
||||||
|
* @param latitude The latitude value
|
||||||
|
* @param longitude The longitude value
|
||||||
|
* @param isOldValue Whether this is the old value (true) or new value (false)
|
||||||
|
* @returns A formatted location string
|
||||||
|
*/
|
||||||
|
formatLocationValue(
|
||||||
|
latitude: number | undefined | null,
|
||||||
|
longitude: number | undefined | null,
|
||||||
|
isOldValue: boolean = false,
|
||||||
|
): string {
|
||||||
|
if (latitude == null && longitude == null) {
|
||||||
|
return "Not set";
|
||||||
|
}
|
||||||
|
// If there's any location data, show generic labels instead of coordinates
|
||||||
|
if (isOldValue) {
|
||||||
|
return "A Location";
|
||||||
|
} else {
|
||||||
|
return "New Location";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the line-by-line diff between two description strings
|
||||||
|
*
|
||||||
|
* @param oldValue The old description value
|
||||||
|
* @param newValue The new description value
|
||||||
|
* @returns An array of Change objects from the diff library
|
||||||
|
*/
|
||||||
|
getDescriptionDiff(oldValue: unknown, newValue: unknown): Change[] {
|
||||||
|
const oldStr = this.formatFieldValue(oldValue);
|
||||||
|
const newStr = this.formatFieldValue(newValue);
|
||||||
|
return diffLines(oldStr, newStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the background color style for a diff change
|
||||||
|
*
|
||||||
|
* @param change A Change object from the diff library
|
||||||
|
* @returns A CSS style object with background color
|
||||||
|
*/
|
||||||
|
getDiffStyle(change: Change): Record<string, string> {
|
||||||
|
if (change.added) {
|
||||||
|
return { backgroundColor: "#d4edda" }; // Light green
|
||||||
|
}
|
||||||
|
if (change.removed) {
|
||||||
|
return { backgroundColor: "#f8d7da" }; // Light red
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -34,7 +34,10 @@ export enum AppString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const APP_SERVER =
|
export const APP_SERVER =
|
||||||
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
|
import.meta.env.VITE_APP_SERVER || "https://giftopia.me";
|
||||||
|
|
||||||
|
export const ORG_SERVER =
|
||||||
|
import.meta.env.VITE_ORG_SERVER || "GiftEconomies.org";
|
||||||
|
|
||||||
export const DEFAULT_ENDORSER_API_SERVER =
|
export const DEFAULT_ENDORSER_API_SERVER =
|
||||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||||
@@ -65,7 +68,7 @@ export const IMAGE_TYPE_PROFILE = "profile";
|
|||||||
export function isNotProdServer(apiServer: string): boolean {
|
export function isNotProdServer(apiServer: string): boolean {
|
||||||
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
||||||
}
|
}
|
||||||
export const SUPPORT_EMAIL = "info@TimeSafari.app";
|
export const SUPPORT_EMAIL = "info@giftopia.me";
|
||||||
|
|
||||||
export const PASSKEYS_ENABLED =
|
export const PASSKEYS_ENABLED =
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import { THAT_UNNAMED_PERSON } from "./entities";
|
|||||||
// Constants marked with "[Component usage not yet documented]" need their
|
// Constants marked with "[Component usage not yet documented]" need their
|
||||||
// usage locations verified and documented.
|
// usage locations verified and documented.
|
||||||
|
|
||||||
// Used in: [Component usage not yet documented]
|
// Used in: ActivityListItem.vue (notifyHiddenPerson - hidden issuer, giver, or recipient)
|
||||||
export const NOTIFY_PERSON_HIDDEN = {
|
export const NOTIFY_PERSON_HIDDEN = {
|
||||||
title: "Person Outside Your Network",
|
title: "Person Outside Your Network",
|
||||||
message: "This person is not visible to you.",
|
message:
|
||||||
|
"This person is not visible to you. Open the record to see who can introduce you.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: [Component usage not yet documented]
|
// Used in: [Component usage not yet documented]
|
||||||
@@ -357,55 +358,18 @@ export const NOTIFY_OFFER_PRIVACY_INFO = {
|
|||||||
message: "Your data is shared with the world when you sign and send.",
|
message: "Your data is shared with the world when you sign and send.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ConfirmGiftView.vue specific constants
|
// Used in: GiftedDialog.vue, GiftedDetailsView.vue (negative amount error)
|
||||||
// Used in: ConfirmGiftView.vue (mounted method - error loading gift details)
|
|
||||||
export const NOTIFY_GIFT_ERROR_LOADING = {
|
|
||||||
title: "Error",
|
|
||||||
message: "There was an error loading the gift details.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirm method - no identifier error)
|
|
||||||
export const NOTIFY_GIFT_ERROR_NO_IDENTIFIER = {
|
|
||||||
title: "Error",
|
|
||||||
message: "You must select an identifier before you can record a gift.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirm method - negative amount error)
|
|
||||||
export const NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT = {
|
export const NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT = {
|
||||||
title: "",
|
title: "",
|
||||||
message: "You may not send a negative number.",
|
message: "You may not send a negative number.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirm method - no description error)
|
// Used in: GiftedDialog.vue, GiftedDetailsView.vue (no description error)
|
||||||
export const NOTIFY_GIFT_ERROR_NO_DESCRIPTION = {
|
export const NOTIFY_GIFT_ERROR_NO_DESCRIPTION = {
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "You must enter a description or some number of {unit}.",
|
message: "You must enter a description or some number of {unit}.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirm method - processing status)
|
|
||||||
export const NOTIFY_GIFT_PROCESSING = {
|
|
||||||
title: "",
|
|
||||||
message: "Recording the gift...",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (recordGift method - creation error)
|
|
||||||
export const NOTIFY_GIFT_ERROR_CREATION = {
|
|
||||||
title: "Error",
|
|
||||||
message: "There was an error creating the gift.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (recordGift method - success)
|
|
||||||
export const NOTIFY_GIFT_SUCCESS_RECORDED = {
|
|
||||||
title: "Success",
|
|
||||||
message: "That gift was recorded.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (recordGift method - recordation error)
|
|
||||||
export const NOTIFY_GIFT_ERROR_RECORDATION = {
|
|
||||||
title: "Error",
|
|
||||||
message: "There was an error recording the gift.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: [Component usage not yet documented]
|
// Used in: [Component usage not yet documented]
|
||||||
export const NOTIFY_REGISTER_PROCESSING = {
|
export const NOTIFY_REGISTER_PROCESSING = {
|
||||||
title: "Processing",
|
title: "Processing",
|
||||||
@@ -1098,26 +1062,7 @@ export function createCombinedSuccessMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmGiftView.vue additional constants
|
// Used in: HiddenDidDialog.vue, QuickActionBvcEndView.vue (copied toast)
|
||||||
// Used in: ConfirmGiftView.vue (confirmClaim method - success)
|
|
||||||
export const NOTIFY_GIFT_CONFIRMATION_SUCCESS = {
|
|
||||||
title: "Success",
|
|
||||||
message: "Confirmation submitted.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirmClaim method - error)
|
|
||||||
export const NOTIFY_GIFT_CONFIRMATION_ERROR = {
|
|
||||||
title: "Error",
|
|
||||||
message: "There was a problem submitting the confirmation.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (confirmConfirmClaim method - confirm modal)
|
|
||||||
export const NOTIFY_GIFT_CONFIRM_MODAL = {
|
|
||||||
title: "Confirm",
|
|
||||||
message: "Do you personally confirm that this is true?",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: ConfirmGiftView.vue (copyToClipboard method - copied toast)
|
|
||||||
export const NOTIFY_COPIED_TO_CLIPBOARD = {
|
export const NOTIFY_COPIED_TO_CLIPBOARD = {
|
||||||
title: "Copied",
|
title: "Copied",
|
||||||
message: (description?: string) =>
|
message: (description?: string) =>
|
||||||
|
|||||||
+26
-7
@@ -108,7 +108,7 @@ const MIGRATIONS = [
|
|||||||
secretBase64 TEXT NOT NULL
|
secretBase64 TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -144,7 +144,7 @@ const MIGRATIONS = [
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||||
|
|
||||||
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -245,8 +245,11 @@ const MIGRATIONS = [
|
|||||||
registered BOOLEAN,
|
registered BOOLEAN,
|
||||||
hideTheirContent BOOLEAN DEFAULT 0
|
hideTheirContent BOOLEAN DEFAULT 0
|
||||||
);
|
);
|
||||||
|
-- Older versions could save the same DID more than once (nothing was unique on contacts.did),
|
||||||
|
-- which made the unique index below fail on every startup. Keep only the newest row per DID.
|
||||||
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
||||||
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
|
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts
|
||||||
|
WHERE id IN (SELECT MAX(id) FROM contacts GROUP BY did);
|
||||||
|
|
||||||
DROP TABLE contacts;
|
DROP TABLE contacts;
|
||||||
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
||||||
@@ -263,7 +266,9 @@ const MIGRATIONS = [
|
|||||||
);
|
);
|
||||||
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
||||||
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
||||||
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
|
-- The PRAGMA above is a no-op inside the migration's transaction, so foreign keys are enforced here:
|
||||||
|
-- skip any label whose contact no longer exists.
|
||||||
|
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008 WHERE did IN (SELECT did FROM contacts);
|
||||||
DROP TABLE _contact_labels_backup_008;
|
DROP TABLE _contact_labels_backup_008;
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -294,6 +299,13 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN imageServer TEXT;
|
ALTER TABLE settings ADD COLUMN imageServer TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "012_add_newActivityCursors_to_settings",
|
||||||
|
sql: `
|
||||||
|
-- JSON of the alertSearch cursors the New Activity screen counts from
|
||||||
|
ALTER TABLE settings ADD COLUMN newActivityCursors TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,6 +332,8 @@ function extractSingleValue<T>(result: T): string | number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let migrationsRegistered = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param sqlExec - A function that executes a SQL statement and returns the result
|
* @param sqlExec - A function that executes a SQL statement and returns the result
|
||||||
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
||||||
@@ -331,9 +345,14 @@ export async function runMigrations<T>(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.debug("[Migration] Starting database migrations");
|
logger.debug("[Migration] Starting database migrations");
|
||||||
|
|
||||||
for (const migration of MIGRATIONS) {
|
// Register once: a retry after a failed initialization calls this again,
|
||||||
logger.debug("[Migration] Registering migration:", migration.name);
|
// and the registry rejects duplicate names.
|
||||||
registerMigration(migration);
|
if (!migrationsRegistered) {
|
||||||
|
for (const migration of MIGRATIONS) {
|
||||||
|
logger.debug("[Migration] Registering migration:", migration.name);
|
||||||
|
registerMigration(migration);
|
||||||
|
}
|
||||||
|
migrationsRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("[Migration] Running migration service");
|
logger.debug("[Migration] Running migration service");
|
||||||
|
|||||||
@@ -298,7 +298,6 @@ export async function logToDb(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// similar method is in the sw_scripts/additional-scripts.js file
|
|
||||||
export async function logConsoleAndDb(
|
export async function logConsoleAndDb(
|
||||||
message: string,
|
message: string,
|
||||||
isError = false,
|
isError = false,
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export async function logToDb(message: string): Promise<void> {
|
|||||||
await db.logs.update(todayKey, { message: fullMessage });
|
await db.logs.update(todayKey, { message: fullMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// similar method is in the sw_scripts/additional-scripts.js file
|
|
||||||
export async function logConsoleAndDb(
|
export async function logConsoleAndDb(
|
||||||
message: string,
|
message: string,
|
||||||
isError = false,
|
isError = false,
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export type Settings = {
|
|||||||
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||||
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
|
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
|
||||||
|
|
||||||
|
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
|
||||||
|
newActivityCursors?: string;
|
||||||
|
|
||||||
// The claim list has a most recent one used in notifications that's separate from the last viewed
|
// The claim list has a most recent one used in notifications that's separate from the last viewed
|
||||||
lastNotifiedClaimId?: string;
|
lastNotifiedClaimId?: string;
|
||||||
lastViewedClaimId?: string;
|
lastViewedClaimId?: string;
|
||||||
|
|||||||
@@ -19,15 +19,28 @@
|
|||||||
/**
|
/**
|
||||||
* Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId /
|
* Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId /
|
||||||
* beforeId. Not an authentication JWT and not a delegated notification JWT.
|
* beforeId. Not an authentication JWT and not a delegated notification JWT.
|
||||||
|
* Superseded by AlertSearchCursor for "what changed since" queries.
|
||||||
*/
|
*/
|
||||||
export type AlertSearchCursorUlid = string;
|
export type AlertSearchCursorUlid = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor: a position between changes on one server. Responses return
|
||||||
|
* `nextAfterCursor` and `nextBeforeCursor`; requests send them back as
|
||||||
|
* `afterCursor` (changes after it, oldest first) or `beforeCursor` (changes
|
||||||
|
* before it, newest first). Store it as text and never parse or compare it;
|
||||||
|
* its format belongs to the server (see endorser-ch
|
||||||
|
* CHANGE/PLAN-accurate-cursors.md).
|
||||||
|
*/
|
||||||
|
export type AlertSearchCursor = string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT row from endorser `jwtsWithDidAfterId` (no claim body).
|
* JWT row from endorser `jwtsWithDidAfterId` (no claim body).
|
||||||
* Cursor field: `id`.
|
* Cursor field: `id`.
|
||||||
*/
|
*/
|
||||||
export interface AlertSearchClaimRecord {
|
export interface AlertSearchClaimRecord {
|
||||||
id: AlertSearchCursorUlid;
|
id: AlertSearchCursorUlid;
|
||||||
|
/** Server change sequence of this row; ascending within a cursor-path list. */
|
||||||
|
changeSeq?: number;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
@@ -54,6 +67,8 @@ export interface AlertSearchJwtWithClaimRecord extends AlertSearchClaimRecord {
|
|||||||
export interface AlertSearchPlanRecord {
|
export interface AlertSearchPlanRecord {
|
||||||
handleId: string;
|
handleId: string;
|
||||||
jwtId: AlertSearchCursorUlid;
|
jwtId: AlertSearchCursorUlid;
|
||||||
|
/** Server change sequence of this row; ascending within a cursor-path list. */
|
||||||
|
changeSeq?: number;
|
||||||
issuerDid?: string;
|
issuerDid?: string;
|
||||||
agentDid?: string;
|
agentDid?: string;
|
||||||
fulfillsLinkConfirmed?: boolean | number;
|
fulfillsLinkConfirmed?: boolean | number;
|
||||||
@@ -77,6 +92,8 @@ export interface AlertSearchPlanRecord {
|
|||||||
*/
|
*/
|
||||||
export interface AlertSearchProfileRecord {
|
export interface AlertSearchProfileRecord {
|
||||||
rowId?: number;
|
rowId?: number;
|
||||||
|
/** Server change sequence of this row; ascending within a cursor-path list. */
|
||||||
|
changeSeq?: number;
|
||||||
issuerDid: string;
|
issuerDid: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -94,18 +111,43 @@ export interface EndorserAlertSearchData {
|
|||||||
plansNearby: AlertSearchPlanRecord[];
|
plansNearby: AlertSearchPlanRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile or plan close in meaning to the requester's profile, from partner
|
||||||
|
* `profilesSimilar` / `plansSimilar` (best first; not paged).
|
||||||
|
*/
|
||||||
|
export interface AlertSearchSimilarProfile {
|
||||||
|
similarity: number;
|
||||||
|
profile: AlertSearchProfileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertSearchSimilarPlan {
|
||||||
|
similarity: number;
|
||||||
|
plan: AlertSearchPlanRecord;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PartnerAlertSearchData {
|
export interface PartnerAlertSearchData {
|
||||||
profilesNearby: AlertSearchProfileRecord[];
|
profilesNearby: AlertSearchProfileRecord[];
|
||||||
|
profilesSimilar?: AlertSearchSimilarProfile[];
|
||||||
|
plansSimilar?: AlertSearchSimilarPlan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endorser GET/POST /api/v2/report/alertSearch body.
|
* Endorser GET/POST /api/v2/report/alertSearch body.
|
||||||
* Per-bucket SQL hitLimit is not currently copied onto this envelope.
|
* The cursors and `hasMore` are absent from servers that predate cursors.
|
||||||
* Timeouts may set `userMessage` instead.
|
* Timeouts may set `userMessage`; the cursor then does not move.
|
||||||
*/
|
*/
|
||||||
export interface EndorserAlertSearchResponse {
|
export interface EndorserAlertSearchResponse {
|
||||||
data: EndorserAlertSearchData;
|
data: EndorserAlertSearchData;
|
||||||
userMessage?: string;
|
userMessage?: string;
|
||||||
|
/** Send as `afterCursor` to get what changed after this response. */
|
||||||
|
nextAfterCursor?: AlertSearchCursor;
|
||||||
|
/** Send as `beforeCursor` to scroll back past this response. */
|
||||||
|
nextBeforeCursor?: AlertSearchCursor;
|
||||||
|
/**
|
||||||
|
* A list was cut at its limit: call again in the same direction, with
|
||||||
|
* `afterCursor = nextAfterCursor` or `beforeCursor = nextBeforeCursor`.
|
||||||
|
*/
|
||||||
|
hasMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +156,15 @@ export interface EndorserAlertSearchResponse {
|
|||||||
export interface PartnerAlertSearchResponse {
|
export interface PartnerAlertSearchResponse {
|
||||||
data: PartnerAlertSearchData;
|
data: PartnerAlertSearchData;
|
||||||
userMessage?: string;
|
userMessage?: string;
|
||||||
|
/** Send as `afterCursor` to get what changed after this response. */
|
||||||
|
nextAfterCursor?: AlertSearchCursor;
|
||||||
|
/** Send as `beforeCursor` to scroll back past this response. */
|
||||||
|
nextBeforeCursor?: AlertSearchCursor;
|
||||||
|
/**
|
||||||
|
* A list was cut at its limit: call again in the same direction, with
|
||||||
|
* `afterCursor = nextAfterCursor` or `beforeCursor = nextBeforeCursor`.
|
||||||
|
*/
|
||||||
|
hasMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export const deepLinkQuerySchemas = {
|
|||||||
searchPeople: z.string().optional(),
|
searchPeople: z.string().optional(),
|
||||||
hideOnboarding: z.string().optional(),
|
hideOnboarding: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
// The start cursors a daily notification counted from, joined by "."
|
||||||
|
"new-activity": z.object({
|
||||||
|
c: z.string().optional(),
|
||||||
|
}),
|
||||||
"onboard-meeting-members": z.object({
|
"onboard-meeting-members": z.object({
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -194,6 +194,26 @@ export interface AlertAuthorizationRequestBody {
|
|||||||
jwts: DelegatedAlertJwt[];
|
jwts: DelegatedAlertJwt[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional alertSearch state carried by either alert-authorization upload.
|
||||||
|
* The cursors replace the channel's stored cursors, so the next daily count
|
||||||
|
* starts from them; `planHandleIds` and `location` are what the daily search
|
||||||
|
* sends, and null clears them. An absent field keeps what the service has.
|
||||||
|
*/
|
||||||
|
export interface AlertSearchUploadState {
|
||||||
|
/** Letters, digits, `_`, `:` or `-`; at most 64. */
|
||||||
|
endorserAfterCursor?: string;
|
||||||
|
partnerAfterCursor?: string;
|
||||||
|
/** At most 200. */
|
||||||
|
planHandleIds?: string[] | null;
|
||||||
|
location?: {
|
||||||
|
minLocLat: number;
|
||||||
|
maxLocLat: number;
|
||||||
|
minLocLon: number;
|
||||||
|
maxLocLon: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Success body of storing a batch, on either alert-authorization route. */
|
/** Success body of storing a batch, on either alert-authorization route. */
|
||||||
export interface AlertAuthorizationResponse {
|
export interface AlertAuthorizationResponse {
|
||||||
success: true;
|
success: true;
|
||||||
|
|||||||
+191
-9
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Typed alertSearch API contract only. No HTTP client yet.
|
* alertSearch API contract and HTTP client.
|
||||||
*
|
*
|
||||||
* Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from
|
* Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from
|
||||||
* `@/constants/app`. Do not duplicate those constants here.
|
* `@/constants/app`. Do not duplicate those constants here.
|
||||||
@@ -14,23 +14,32 @@
|
|||||||
* notify-api's push or SMS `alert-authorization` route.
|
* notify-api's push or SMS `alert-authorization` route.
|
||||||
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
||||||
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
||||||
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
|
* - alertSearch cursors: opaque server strings, positions between changes.
|
||||||
* ULID). `afterId` means ids strictly greater than that ULID; `beforeId`
|
* To get what changed: send the stored value as `afterCursor`, store each
|
||||||
* means strictly less. First daily run omits afterId. `beforeId` is for
|
* response's `nextAfterCursor`, and while `hasMore` call again with
|
||||||
* pagination within a run. These are not auth JWTs and not signed tokens.
|
* `afterCursor = nextAfterCursor`. Every page is complete up to it.
|
||||||
|
* To scroll back, newest first: send `beforeCursor` (any cursor, eg. a
|
||||||
|
* `nextAfterCursor`), then `beforeCursor = nextBeforeCursor` while `hasMore`.
|
||||||
|
* A first run sends neither.
|
||||||
|
* - alertSearch ULIDs (`afterId` / `beforeId`): the older bounds, ignored when
|
||||||
|
* a cursor is sent. Not auth JWTs and not signed tokens.
|
||||||
*
|
*
|
||||||
* Truncation: each endorser bucket query uses a server hit-limit (typically
|
* See endorser-ch CHANGE/PLAN-accurate-cursors.md.
|
||||||
* 50). That flag is not currently returned on the alertSearch JSON envelope.
|
|
||||||
* Timeouts may add `userMessage`. A later caller must still paginate with
|
|
||||||
* beforeId when a bucket may be incomplete.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Axios } from "axios";
|
||||||
|
|
||||||
|
import type { BoundingBox } from "@/interfaces/accountView";
|
||||||
import type {
|
import type {
|
||||||
|
AlertSearchCursor,
|
||||||
AlertSearchCursorUlid,
|
AlertSearchCursorUlid,
|
||||||
CombinedAlertSearchData,
|
CombinedAlertSearchData,
|
||||||
|
EndorserAlertSearchData,
|
||||||
EndorserAlertSearchResponse,
|
EndorserAlertSearchResponse,
|
||||||
|
PartnerAlertSearchData,
|
||||||
PartnerAlertSearchResponse,
|
PartnerAlertSearchResponse,
|
||||||
} from "@/interfaces/alertSearch";
|
} from "@/interfaces/alertSearch";
|
||||||
|
import { getHeaders } from "@/libs/endorserServer";
|
||||||
|
|
||||||
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
|
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
|
||||||
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
|
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
|
||||||
@@ -47,6 +56,10 @@ export interface AlertSearchLocationBBox {
|
|||||||
* GET is the planned daily method; the server also accepts POST.
|
* GET is the planned daily method; the server also accepts POST.
|
||||||
*/
|
*/
|
||||||
export interface AlertSearchRequestParams {
|
export interface AlertSearchRequestParams {
|
||||||
|
/** Changes after this cursor, oldest first; the ID and date bounds are then ignored. */
|
||||||
|
afterCursor?: AlertSearchCursor;
|
||||||
|
/** Changes before this cursor (and after `afterCursor`, if given), newest first. */
|
||||||
|
beforeCursor?: AlertSearchCursor;
|
||||||
afterId?: AlertSearchCursorUlid;
|
afterId?: AlertSearchCursorUlid;
|
||||||
beforeId?: AlertSearchCursorUlid;
|
beforeId?: AlertSearchCursorUlid;
|
||||||
afterDate?: string;
|
afterDate?: string;
|
||||||
@@ -59,11 +72,180 @@ export interface AlertSearchRequestParams {
|
|||||||
planHandleIds?: string[];
|
planHandleIds?: string[];
|
||||||
planIds?: string[];
|
planIds?: string[];
|
||||||
handleIds?: string[];
|
handleIds?: string[];
|
||||||
|
/** Partner only: bounding box for profilesSimilar / plansSimilar (everywhere without it). */
|
||||||
|
similarLocation?: AlertSearchLocationBBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
AlertSearchCursor,
|
||||||
AlertSearchCursorUlid,
|
AlertSearchCursorUlid,
|
||||||
CombinedAlertSearchData,
|
CombinedAlertSearchData,
|
||||||
EndorserAlertSearchResponse,
|
EndorserAlertSearchResponse,
|
||||||
PartnerAlertSearchResponse,
|
PartnerAlertSearchResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AlertSearchServer = "endorser" | "partner";
|
||||||
|
|
||||||
|
/** The data each server returns, with the partner similar lists always present. */
|
||||||
|
export interface AlertSearchServerData {
|
||||||
|
endorser: EndorserAlertSearchData;
|
||||||
|
partner: Required<PartnerAlertSearchData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One alertSearch page, or the merge of several pages in one direction. */
|
||||||
|
export interface AlertSearchResult<S extends AlertSearchServer> {
|
||||||
|
data: AlertSearchServerData[S];
|
||||||
|
nextAfterCursor?: AlertSearchCursor;
|
||||||
|
nextBeforeCursor?: AlertSearchCursor;
|
||||||
|
/** A list was cut at its limit (after the last page fetched, for a merge). */
|
||||||
|
hasMore: boolean;
|
||||||
|
userMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyAlertSearchData<S extends AlertSearchServer>(
|
||||||
|
server: S,
|
||||||
|
): AlertSearchServerData[S] {
|
||||||
|
return (server === "endorser"
|
||||||
|
? {
|
||||||
|
claims: [],
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
profilesNearby: [],
|
||||||
|
profilesSimilar: [],
|
||||||
|
plansSimilar: [],
|
||||||
|
}) as unknown as AlertSearchServerData[S];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search box the person saved, in alertSearch's shape. alertSearch and the
|
||||||
|
* notification service both take min/max latitude and longitude.
|
||||||
|
*/
|
||||||
|
export function alertSearchLocationFromBox(
|
||||||
|
bbox: BoundingBox | undefined,
|
||||||
|
): AlertSearchLocationBBox | undefined {
|
||||||
|
if (!bbox) return undefined;
|
||||||
|
return {
|
||||||
|
minLocLat: bbox.minLat,
|
||||||
|
maxLocLat: bbox.maxLat,
|
||||||
|
minLocLon: bbox.westLong,
|
||||||
|
maxLocLon: bbox.eastLong,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one alertSearch page. POST, so the starred handle IDs and the area
|
||||||
|
* travel in the body.
|
||||||
|
*/
|
||||||
|
export async function fetchAlertSearchPage<S extends AlertSearchServer>(
|
||||||
|
axios: Axios,
|
||||||
|
server: S,
|
||||||
|
baseUrl: string,
|
||||||
|
activeDid: string,
|
||||||
|
params: AlertSearchRequestParams,
|
||||||
|
): Promise<AlertSearchResult<S>> {
|
||||||
|
const path =
|
||||||
|
server === "endorser"
|
||||||
|
? ENDORSER_ALERT_SEARCH_PATH
|
||||||
|
: PARTNER_ALERT_SEARCH_PATH;
|
||||||
|
const headers = await getHeaders(activeDid);
|
||||||
|
const response = await axios.post(baseUrl + path, params, { headers });
|
||||||
|
const body = response.data as
|
||||||
|
| EndorserAlertSearchResponse
|
||||||
|
| PartnerAlertSearchResponse;
|
||||||
|
const data = { ...emptyAlertSearchData(server), ...(body.data || {}) };
|
||||||
|
return {
|
||||||
|
data: data as AlertSearchServerData[S],
|
||||||
|
nextAfterCursor: body.nextAfterCursor,
|
||||||
|
nextBeforeCursor: body.nextBeforeCursor,
|
||||||
|
hasMore: body.hasMore === true,
|
||||||
|
userMessage: body.userMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key that identifies one row of any alertSearch list, for merging pages. */
|
||||||
|
function alertSearchRowKey(row: unknown): string | undefined {
|
||||||
|
if (row === null || typeof row !== "object") return undefined;
|
||||||
|
const record = row as Record<string, unknown>;
|
||||||
|
const inner = (record.profile ?? record.plan) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const source = inner && typeof inner === "object" ? inner : record;
|
||||||
|
for (const key of ["id", "jwtId", "rowId", "handleId", "issuerDid"]) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return key + ":" + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concatenate each list of two pages, dropping repeated rows. */
|
||||||
|
export function mergeAlertSearchData<T extends object>(acc: T, page: T): T {
|
||||||
|
const merged: Record<string, unknown[]> = {};
|
||||||
|
for (const key of Object.keys(acc) as Array<keyof T & string>) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: unknown[] = [];
|
||||||
|
const accList = (acc[key] as unknown[]) || [];
|
||||||
|
const pageList =
|
||||||
|
((page as Record<string, unknown>)[key] as unknown[]) || [];
|
||||||
|
for (const row of [...accList, ...pageList]) {
|
||||||
|
const rowKey = alertSearchRowKey(row);
|
||||||
|
if (rowKey !== undefined) {
|
||||||
|
if (seen.has(rowKey)) continue;
|
||||||
|
seen.add(rowKey);
|
||||||
|
}
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
merged[key] = out;
|
||||||
|
}
|
||||||
|
return merged as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page forward from `afterCursor` while `hasMore`, up to `maxPages`, and merge.
|
||||||
|
* Without `afterCursor` (and no other bound) one page is the whole result: the
|
||||||
|
* server returns its most recent rows.
|
||||||
|
*
|
||||||
|
* @returns the merge; `hasMore` is true when it stopped at `maxPages` with more
|
||||||
|
* to read, and `nextAfterCursor` is where the merge ends
|
||||||
|
*/
|
||||||
|
export async function collectAlertSearchForward<S extends AlertSearchServer>(
|
||||||
|
fetchPage: (
|
||||||
|
params: AlertSearchRequestParams,
|
||||||
|
) => Promise<AlertSearchResult<S>>,
|
||||||
|
server: S,
|
||||||
|
params: AlertSearchRequestParams,
|
||||||
|
maxPages: number,
|
||||||
|
): Promise<AlertSearchResult<S>> {
|
||||||
|
let result: AlertSearchResult<S> = {
|
||||||
|
data: emptyAlertSearchData(server),
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
let pageParams = params;
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (let page = 0; page < maxPages; page++) {
|
||||||
|
const pageResult = await fetchPage(pageParams);
|
||||||
|
if (pageResult.userMessage) messages.push(pageResult.userMessage);
|
||||||
|
result = {
|
||||||
|
data: mergeAlertSearchData(result.data, pageResult.data),
|
||||||
|
nextAfterCursor: pageResult.nextAfterCursor,
|
||||||
|
nextBeforeCursor: result.nextBeforeCursor ?? pageResult.nextBeforeCursor,
|
||||||
|
hasMore: pageResult.hasMore,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!pageResult.hasMore ||
|
||||||
|
!params.afterCursor ||
|
||||||
|
!pageResult.nextAfterCursor ||
|
||||||
|
pageResult.nextAfterCursor === pageParams.afterCursor
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageParams = { ...params, afterCursor: pageResult.nextAfterCursor };
|
||||||
|
}
|
||||||
|
if (messages.length > 0) result.userMessage = messages.join(" ");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCr
|
|||||||
|
|
||||||
// This is used to check for hidden info.
|
// This is used to check for hidden info.
|
||||||
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
||||||
const HIDDEN_DID = "did:none:HIDDEN";
|
export const HIDDEN_DID = "did:none:HIDDEN";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if a string is a valid DID
|
* Validates if a string is a valid DID
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
/**
|
||||||
|
* New Activity: the alertSearch buckets, and the cursors that say what the
|
||||||
|
* person has seen.
|
||||||
|
*
|
||||||
|
* Read state has two layers, stored together as JSON in the settings column
|
||||||
|
* `newActivityCursors`:
|
||||||
|
* - The visit window, one cursor per server. The summary counts every bucket
|
||||||
|
* from its start, the same point the notification service counts from once
|
||||||
|
* the visit is uploaded to it.
|
||||||
|
* - A window per bucket, on that bucket's server. A detail page counts from the
|
||||||
|
* end of its last load, so opening it marks the bucket read.
|
||||||
|
*
|
||||||
|
* A visit started less than {@link SAME_VISIT_MS} ago is the same visit: the
|
||||||
|
* next summary load counts from the same start, so reloading the summary does
|
||||||
|
* not zero the counts. A bucket opened since that start counts from the end of
|
||||||
|
* its own list instead ({@link summaryStart}).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AlertSearchCursor } from "@/interfaces/alertSearch";
|
||||||
|
import type {
|
||||||
|
AlertSearchRequestParams,
|
||||||
|
AlertSearchServer,
|
||||||
|
AlertSearchServerData,
|
||||||
|
} from "./alertSearch";
|
||||||
|
|
||||||
|
export const NEW_ACTIVITY_BUCKETS = [
|
||||||
|
"claims",
|
||||||
|
"personalPlanContributions",
|
||||||
|
"trackedPlanUpdates",
|
||||||
|
"trackedPlanClaims",
|
||||||
|
"plansNearby",
|
||||||
|
"profilesNearby",
|
||||||
|
"profilesSimilar",
|
||||||
|
"plansSimilar",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NewActivityBucket = (typeof NEW_ACTIVITY_BUCKETS)[number];
|
||||||
|
|
||||||
|
/** What a bucket needs before it can hold anything. */
|
||||||
|
export type NewActivityPrerequisite = "none" | "starred" | "location";
|
||||||
|
|
||||||
|
export interface NewActivityBucketInfo {
|
||||||
|
key: NewActivityBucket;
|
||||||
|
server: AlertSearchServer;
|
||||||
|
/** Heading on the summary and the detail page; `n` is the count. */
|
||||||
|
label: (n: number) => string;
|
||||||
|
/** One sentence for the detail page. */
|
||||||
|
description: string;
|
||||||
|
needs: NewActivityPrerequisite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n: number, one: string, many: string): string {
|
||||||
|
return n === 1 ? one : many;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEW_ACTIVITY_BUCKET_INFO: Record<
|
||||||
|
NewActivityBucket,
|
||||||
|
NewActivityBucketInfo
|
||||||
|
> = {
|
||||||
|
claims: {
|
||||||
|
key: "claims",
|
||||||
|
server: "endorser",
|
||||||
|
label: (n) => plural(n, "Claim About You", "Claims About You"),
|
||||||
|
description:
|
||||||
|
"Offers, gifts, and other claims by others that name you as giver or recipient.",
|
||||||
|
needs: "none",
|
||||||
|
},
|
||||||
|
personalPlanContributions: {
|
||||||
|
key: "personalPlanContributions",
|
||||||
|
server: "endorser",
|
||||||
|
label: (n) =>
|
||||||
|
plural(
|
||||||
|
n,
|
||||||
|
"Gift or Offer on Your Projects",
|
||||||
|
"Gifts & Offers on Your Projects",
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
"Gifts and offers to or from projects you created or are agent for.",
|
||||||
|
needs: "none",
|
||||||
|
},
|
||||||
|
trackedPlanUpdates: {
|
||||||
|
key: "trackedPlanUpdates",
|
||||||
|
server: "endorser",
|
||||||
|
label: (n) =>
|
||||||
|
plural(
|
||||||
|
n,
|
||||||
|
"Starred Project With Changes",
|
||||||
|
"Starred Projects With Changes",
|
||||||
|
),
|
||||||
|
description: "Edits to projects you starred.",
|
||||||
|
needs: "starred",
|
||||||
|
},
|
||||||
|
trackedPlanClaims: {
|
||||||
|
key: "trackedPlanClaims",
|
||||||
|
server: "endorser",
|
||||||
|
label: (n) =>
|
||||||
|
plural(
|
||||||
|
n,
|
||||||
|
"Gift or Offer on Starred Projects",
|
||||||
|
"Gifts & Offers on Starred Projects",
|
||||||
|
),
|
||||||
|
description: "Gifts and offers to or from projects you starred.",
|
||||||
|
needs: "starred",
|
||||||
|
},
|
||||||
|
plansNearby: {
|
||||||
|
key: "plansNearby",
|
||||||
|
server: "endorser",
|
||||||
|
label: (n) => plural(n, "Project Nearby", "Projects Nearby"),
|
||||||
|
description: "Projects created or edited in your search area.",
|
||||||
|
needs: "location",
|
||||||
|
},
|
||||||
|
profilesNearby: {
|
||||||
|
key: "profilesNearby",
|
||||||
|
server: "partner",
|
||||||
|
label: (n) => plural(n, "Profile Nearby", "Profiles Nearby"),
|
||||||
|
description: "Profiles created or edited in your search area.",
|
||||||
|
needs: "location",
|
||||||
|
},
|
||||||
|
profilesSimilar: {
|
||||||
|
key: "profilesSimilar",
|
||||||
|
server: "partner",
|
||||||
|
label: (n) => plural(n, "Profile Like Yours", "Profiles Like Yours"),
|
||||||
|
description:
|
||||||
|
"Profiles whose description is close in meaning to yours, best match first.",
|
||||||
|
needs: "none",
|
||||||
|
},
|
||||||
|
plansSimilar: {
|
||||||
|
key: "plansSimilar",
|
||||||
|
server: "partner",
|
||||||
|
label: (n) =>
|
||||||
|
plural(
|
||||||
|
n,
|
||||||
|
"Project Matching Your Profile",
|
||||||
|
"Projects Matching Your Profile",
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
"Projects whose description is close in meaning to your profile, best match first.",
|
||||||
|
needs: "none",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isNewActivityBucket(
|
||||||
|
value: unknown,
|
||||||
|
): value is NewActivityBucket {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(NEW_ACTIVITY_BUCKETS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows of one bucket in a server's alertSearch data. */
|
||||||
|
export function bucketRows<S extends AlertSearchServer>(
|
||||||
|
data: AlertSearchServerData[S],
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
): unknown[] {
|
||||||
|
return ((data as unknown as Record<string, unknown[]>)[bucket] ||
|
||||||
|
[]) as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CursorPair {
|
||||||
|
endorser: AlertSearchCursor;
|
||||||
|
partner: AlertSearchCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The span one load counted: from `from` (absent before any cursor was known)
|
||||||
|
* up to `to`, the cursor the server returned. `at` is when the visit began;
|
||||||
|
* `seenAt` is when `to` was recorded, and `fromSeenAt` when `from` was.
|
||||||
|
*/
|
||||||
|
export interface ActivityWindow<T> {
|
||||||
|
from?: T;
|
||||||
|
to: T;
|
||||||
|
at: string;
|
||||||
|
seenAt?: string;
|
||||||
|
fromSeenAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationChannel = "fcm" | "sms";
|
||||||
|
|
||||||
|
export interface NewActivityCursorState {
|
||||||
|
visit?: ActivityWindow<CursorPair>;
|
||||||
|
buckets?: Partial<
|
||||||
|
Record<NewActivityBucket, ActivityWindow<AlertSearchCursor>>
|
||||||
|
>;
|
||||||
|
/** The cursors last sent to the notification service, per channel. */
|
||||||
|
uploaded?: Partial<Record<NotificationChannel, CursorPair>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a visit lasts: loads inside it count from the same start. */
|
||||||
|
export const SAME_VISIT_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/** The stored JSON, or an empty state when it is missing or unreadable. */
|
||||||
|
export function parseNewActivityCursorState(
|
||||||
|
text: string | undefined | null,
|
||||||
|
): NewActivityCursorState {
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNewActivityCursorState(
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameVisit<T>(
|
||||||
|
window: ActivityWindow<T> | undefined,
|
||||||
|
now: Date,
|
||||||
|
): boolean {
|
||||||
|
if (!window) return false;
|
||||||
|
const started = Date.parse(window.at);
|
||||||
|
return !Number.isNaN(started) && now.getTime() - started < SAME_VISIT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a load counts from: the same start inside a visit, else the last end. */
|
||||||
|
export function windowStart<T>(
|
||||||
|
window: ActivityWindow<T> | undefined,
|
||||||
|
now: Date,
|
||||||
|
): T | undefined {
|
||||||
|
return isSameVisit(window, now) ? window?.from : window?.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window to store after a load that counted from `windowStart` up to `to`. */
|
||||||
|
export function nextWindow<T>(
|
||||||
|
window: ActivityWindow<T> | undefined,
|
||||||
|
to: T,
|
||||||
|
now: Date,
|
||||||
|
): ActivityWindow<T> {
|
||||||
|
if (window && isSameVisit(window, now)) {
|
||||||
|
return {
|
||||||
|
...(window.from !== undefined ? { from: window.from } : {}),
|
||||||
|
...(window.fromSeenAt ? { fromSeenAt: window.fromSeenAt } : {}),
|
||||||
|
to,
|
||||||
|
at: window.at,
|
||||||
|
seenAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(window?.to !== undefined
|
||||||
|
? { from: window.to, fromSeenAt: window.seenAt ?? window.at }
|
||||||
|
: {}),
|
||||||
|
to,
|
||||||
|
at: now.toISOString(),
|
||||||
|
seenAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seenTime<T>(window: ActivityWindow<T> | undefined): number {
|
||||||
|
const time = Date.parse(window?.seenAt ?? window?.at ?? "");
|
||||||
|
return Number.isNaN(time) ? 0 : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the cursor that {@link windowStart} returns was recorded. */
|
||||||
|
function startTime<T>(
|
||||||
|
window: ActivityWindow<T> | undefined,
|
||||||
|
now: Date,
|
||||||
|
): number {
|
||||||
|
if (!isSameVisit(window, now)) return seenTime(window);
|
||||||
|
const time = Date.parse(window?.fromSeenAt ?? window?.at ?? "");
|
||||||
|
return Number.isNaN(time) ? 0 : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the summary counts a bucket from: the visit's start (or `fallback`,
|
||||||
|
* the notification link's cursors, when the visit has none), unless this
|
||||||
|
* bucket's list was opened after that start was recorded, in which case the
|
||||||
|
* end of that list. Opening a list therefore drops its count on the summary
|
||||||
|
* while the other buckets keep theirs for the rest of the visit.
|
||||||
|
*/
|
||||||
|
export function summaryStart(
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
now: Date,
|
||||||
|
fallback?: CursorPair,
|
||||||
|
): AlertSearchCursor | undefined {
|
||||||
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
|
const visitStart =
|
||||||
|
windowStart(state.visit, now)?.[server] ?? fallback?.[server];
|
||||||
|
const bucketWindow = state.buckets?.[bucket];
|
||||||
|
if (!bucketWindow) return visitStart;
|
||||||
|
if (visitStart === undefined) return bucketWindow.to;
|
||||||
|
return seenTime(bucketWindow) > startTime(state.visit, now)
|
||||||
|
? bucketWindow.to
|
||||||
|
: visitStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the Home screen counts a bucket from: the end of whichever load the
|
||||||
|
* person saw last, the summary's or this bucket's own list. Unlike
|
||||||
|
* {@link windowStart} it never reaches back to the start of a visit, so a list
|
||||||
|
* just opened shows nothing new on Home.
|
||||||
|
*/
|
||||||
|
export function unseenStart(
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
): AlertSearchCursor | undefined {
|
||||||
|
const visitTo = state.visit?.to[NEW_ACTIVITY_BUCKET_INFO[bucket].server];
|
||||||
|
const bucketWindow = state.buckets?.[bucket];
|
||||||
|
if (!bucketWindow) return visitTo;
|
||||||
|
if (visitTo === undefined) return bucketWindow.to;
|
||||||
|
return seenTime(bucketWindow) >= seenTime(state.visit)
|
||||||
|
? bucketWindow.to
|
||||||
|
: visitTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `c` query value in notification links: the endorser and partner cursors
|
||||||
|
* joined by ".", which appears in neither server's cursor format.
|
||||||
|
*/
|
||||||
|
export function joinCursorPair(pair: CursorPair): string {
|
||||||
|
return pair.endorser + "." + pair.partner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCursorParam(
|
||||||
|
value: string | undefined | null,
|
||||||
|
): CursorPair | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const dot = value.indexOf(".");
|
||||||
|
if (dot <= 0 || dot === value.length - 1) return undefined;
|
||||||
|
return { endorser: value.slice(0, dot), partner: value.slice(dot + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameCursorPair(
|
||||||
|
a: CursorPair | undefined,
|
||||||
|
b: CursorPair | undefined,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a !== undefined &&
|
||||||
|
b !== undefined &&
|
||||||
|
a.endorser === b.endorser &&
|
||||||
|
a.partner === b.partner
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request params for a load that starts at `cursor`. Without one, the request
|
||||||
|
* carries no cursor or ID, and the server answers with its most recent rows
|
||||||
|
* and its latest cursors.
|
||||||
|
*/
|
||||||
|
export function startParams(
|
||||||
|
cursor: AlertSearchCursor | undefined,
|
||||||
|
): AlertSearchRequestParams {
|
||||||
|
return cursor ? { afterCursor: cursor } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state with a visit ending at `latest`, as if the person had looked at
|
||||||
|
* everything up to it: nothing before it counts as new. The visit's start is
|
||||||
|
* long past, so the next load counts from `latest`.
|
||||||
|
*/
|
||||||
|
export function seededState(
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
latest: CursorPair,
|
||||||
|
now: Date,
|
||||||
|
): NewActivityCursorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
visit: {
|
||||||
|
to: latest,
|
||||||
|
at: new Date(0).toISOString(),
|
||||||
|
seenAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ULID immediately before `id` in sort order, so that `afterId` set to it
|
||||||
|
* includes `id` itself. Undefined for a value that is not a ULID or is all
|
||||||
|
* zeros.
|
||||||
|
*/
|
||||||
|
export function ulidJustBefore(id: string): string | undefined {
|
||||||
|
const upper = id.toUpperCase();
|
||||||
|
if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(upper)) return undefined;
|
||||||
|
const chars = upper.split("");
|
||||||
|
for (let i = chars.length - 1; i >= 0; i--) {
|
||||||
|
const index = CROCKFORD.indexOf(chars[i]);
|
||||||
|
if (index > 0) {
|
||||||
|
chars[i] = CROCKFORD[index - 1];
|
||||||
|
return chars.join("");
|
||||||
|
}
|
||||||
|
chars[i] = CROCKFORD[CROCKFORD.length - 1];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
+113
-4
@@ -266,6 +266,87 @@ export interface ConfirmerData {
|
|||||||
numConfsNotVisible: number;
|
numConfsNotVisible: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConfirmationRecord {
|
||||||
|
issuer: string; // may be HIDDEN
|
||||||
|
issuedAt?: string; // missing from servers without confirmationsForClaim
|
||||||
|
jwtId?: string; // the confirmation's own claim; missing like issuedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmationsData {
|
||||||
|
confirmations: ConfirmationRecord[]; // excluding the claim issuer, newest first
|
||||||
|
confsVisibleToIdList: string[]; // list of DIDs that can see any hidden confirmer
|
||||||
|
hitLimit: boolean; // there may be more before the last of these confirmations
|
||||||
|
requesterConfirmed: boolean; // even if that confirmation is not in this page
|
||||||
|
totalCount: number; // across all pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param claimJwtId the JWT ID of the claim, not a handle
|
||||||
|
* @param beforeId the jwtId of the last confirmation already retrieved, for the next page
|
||||||
|
* @return the confirmations of the claim, newest first, excluding the issuer
|
||||||
|
*/
|
||||||
|
export async function retrieveConfirmations(
|
||||||
|
apiServer: string,
|
||||||
|
claimJwtId: string,
|
||||||
|
claimIssuerId: string,
|
||||||
|
userDid: string,
|
||||||
|
beforeId?: string,
|
||||||
|
): Promise<ConfirmationsData | undefined> {
|
||||||
|
const url =
|
||||||
|
apiServer +
|
||||||
|
"/api/v2/report/confirmationsForClaim?claimId=" +
|
||||||
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimJwtId)) +
|
||||||
|
(beforeId ? "&beforeId=" + encodeURIComponent(beforeId) : "");
|
||||||
|
const headers = await serverUtil.getHeaders(userDid);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { headers });
|
||||||
|
const records: Array<
|
||||||
|
ConfirmationRecord & { issuerVisibleToDids?: string[] }
|
||||||
|
> = response.data.data || [];
|
||||||
|
return {
|
||||||
|
confirmations: records.map((record) => ({
|
||||||
|
issuer: record.issuer,
|
||||||
|
issuedAt: record.issuedAt,
|
||||||
|
jwtId: record.jwtId,
|
||||||
|
})),
|
||||||
|
confsVisibleToIdList: R.uniq(
|
||||||
|
records.flatMap((record) => record.issuerVisibleToDids || []),
|
||||||
|
),
|
||||||
|
hitLimit: !!response.data.hitLimit,
|
||||||
|
requesterConfirmed: !!response.data.requesterConfirmed,
|
||||||
|
totalCount: response.data.totalCount ?? records.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!axios.isAxiosError(error) || error.response?.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// This server predates confirmationsForClaim, so get all the confirmers without dates.
|
||||||
|
const confirmerInfo = await retrieveConfirmerIdList(
|
||||||
|
apiServer,
|
||||||
|
claimJwtId,
|
||||||
|
claimIssuerId,
|
||||||
|
userDid,
|
||||||
|
);
|
||||||
|
if (!confirmerInfo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const confirmations = [
|
||||||
|
...confirmerInfo.confirmerIdList.map((issuer) => ({ issuer })),
|
||||||
|
...R.repeat(
|
||||||
|
{ issuer: serverUtil.HIDDEN_DID },
|
||||||
|
Math.max(confirmerInfo.numConfsNotVisible, 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
confirmations,
|
||||||
|
confsVisibleToIdList: confirmerInfo.confsVisibleToIdList,
|
||||||
|
hitLimit: false,
|
||||||
|
requesterConfirmed: confirmerInfo.confirmerIdList.includes(userDid),
|
||||||
|
totalCount: confirmations.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// // This is meant to be a second argument to JSON.stringify to avoid circular references.
|
// // This is meant to be a second argument to JSON.stringify to avoid circular references.
|
||||||
// // Usage: JSON.stringify(error, getCircularReplacer())
|
// // Usage: JSON.stringify(error, getCircularReplacer())
|
||||||
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
|
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
|
||||||
@@ -336,10 +417,36 @@ export async function retrieveConfirmerIdList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirming these makes no sense: a confirmation of a confirmation, or of a reaction.
|
||||||
|
const UNCONFIRMABLE_CLAIM_TYPES = ["AgreeAction", "Emoji"];
|
||||||
|
|
||||||
|
export const isConfirmableClaimType = (claimType?: string) => {
|
||||||
|
return !UNCONFIRMABLE_CLAIM_TYPES.includes(claimType || "");
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns true if the user can confirm the claim
|
* @returns true if the user can confirm the claim
|
||||||
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
*/
|
*/
|
||||||
|
export function isClaimTheUserCanConfirm(
|
||||||
|
isRegistered: boolean,
|
||||||
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
||||||
|
activeDid: string,
|
||||||
|
confirmerIdList: string[] = [],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isRegistered &&
|
||||||
|
isConfirmableClaimType(veriClaim.claimType) &&
|
||||||
|
!confirmerIdList.includes(activeDid) &&
|
||||||
|
veriClaim.issuer !== activeDid &&
|
||||||
|
!containsHiddenDid(veriClaim.claim)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the claim is a give and the user can confirm it
|
||||||
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
|
*/
|
||||||
export function isGiveRecordTheUserCanConfirm(
|
export function isGiveRecordTheUserCanConfirm(
|
||||||
isRegistered: boolean,
|
isRegistered: boolean,
|
||||||
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
||||||
@@ -347,11 +454,13 @@ export function isGiveRecordTheUserCanConfirm(
|
|||||||
confirmerIdList: string[] = [],
|
confirmerIdList: string[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
isRegistered &&
|
|
||||||
isGiveAction(veriClaim) &&
|
isGiveAction(veriClaim) &&
|
||||||
!confirmerIdList.includes(activeDid) &&
|
isClaimTheUserCanConfirm(
|
||||||
veriClaim.issuer !== activeDid &&
|
isRegistered,
|
||||||
!containsHiddenDid(veriClaim.claim)
|
veriClaim,
|
||||||
|
activeDid,
|
||||||
|
confirmerIdList,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Web Share Target handoff (web build only).
|
||||||
|
*
|
||||||
|
* public/sw.js receives the POST from the manifest's share_target, stores the
|
||||||
|
* photo in Cache Storage under these keys, and redirects to /shared-photo.
|
||||||
|
* Keep the constants in sync with public/sw.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const SHARE_TARGET_CACHE = "share-target";
|
||||||
|
const SHARED_PHOTO_KEY = "/share-target/photo";
|
||||||
|
const FILE_NAME_HEADER = "X-File-Name";
|
||||||
|
|
||||||
|
export interface WebSharedPhoto {
|
||||||
|
blob: Blob;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers public/sw.js, which handles the share target POST.
|
||||||
|
*/
|
||||||
|
export function registerWebShareTargetWorker(): void {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
||||||
|
// Not fatal: only sharing into the app depends on the worker.
|
||||||
|
logger.warn("[WebShareTarget] Service worker registration failed", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the photo stashed by the service worker and removes it, or null
|
||||||
|
* when there is none (or Cache Storage is unavailable).
|
||||||
|
*/
|
||||||
|
export async function takeWebSharedPhoto(): Promise<WebSharedPhoto | null> {
|
||||||
|
if (typeof caches === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cache = await caches.open(SHARE_TARGET_CACHE);
|
||||||
|
const response = await cache.match(SHARED_PHOTO_KEY);
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const fileName = decodeURIComponent(
|
||||||
|
response.headers.get(FILE_NAME_HEADER) || "",
|
||||||
|
);
|
||||||
|
await cache.delete(SHARED_PHOTO_KEY);
|
||||||
|
return { blob, fileName };
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import "./assets/styles/tailwind.css";
|
|||||||
import { FontAwesomeIcon } from "./libs/fontawesome";
|
import { FontAwesomeIcon } from "./libs/fontawesome";
|
||||||
import Camera from "simple-vue-camera";
|
import Camera from "simple-vue-camera";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
import {
|
||||||
|
isDatabaseStartupError,
|
||||||
|
recordStartupError,
|
||||||
|
} from "./utils/startupError";
|
||||||
|
|
||||||
// Global Error Handler
|
// Global Error Handler
|
||||||
function setupGlobalErrorHandler(app: VueApp) {
|
function setupGlobalErrorHandler(app: VueApp) {
|
||||||
@@ -22,6 +26,12 @@ function setupGlobalErrorHandler(app: VueApp) {
|
|||||||
info,
|
info,
|
||||||
component: instance?.$options.name || "unknown",
|
component: instance?.$options.name || "unknown",
|
||||||
});
|
});
|
||||||
|
if (isDatabaseStartupError(err)) {
|
||||||
|
// Nothing works without the database: show the recovery options instead.
|
||||||
|
recordStartupError(err);
|
||||||
|
router.replace("/start");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(
|
alert(
|
||||||
(err instanceof Error ? err.message : "Something bad happened") +
|
(err instanceof Error ? err.message : "Something bad happened") +
|
||||||
" - Try reloading or restarting the app.",
|
" - Try reloading or restarting the app.",
|
||||||
|
|||||||
+51
-14
@@ -1,20 +1,57 @@
|
|||||||
import { initializeApp } from "./main.common";
|
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
import { registerWebShareTargetWorker } from "./libs/webShareTarget";
|
||||||
|
|
||||||
const platform = process.env.VITE_PLATFORM;
|
const platform = process.env.VITE_PLATFORM;
|
||||||
|
|
||||||
// PWA service worker is automatically registered by VitePWA plugin
|
/**
|
||||||
|
* Browsers withhold crypto.subtle outside secure contexts (HTTPS, localhost,
|
||||||
const app = initializeApp();
|
* 127.0.0.1, [::1]), and identity creation cannot run without it. Addresses
|
||||||
|
* like http://[::]:8000 or http://192.168.x.x would otherwise fail later with
|
||||||
// Note: Worker initialization is now handled by WebPlatformService
|
* a misleading "could not open its data" startup error. main.common is loaded
|
||||||
// This ensures single-point database access and prevents double migrations
|
* only after this check because importing the router starts navigation.
|
||||||
if (platform === "web" || platform === "development") {
|
*/
|
||||||
logger.debug(
|
function showInsecureContextError(): void {
|
||||||
"[Web] Database initialization will be handled by WebPlatformService",
|
const container = document.getElementById("app");
|
||||||
);
|
if (!container) {
|
||||||
} else {
|
return;
|
||||||
logger.debug("[Web] SQL not initialized for platform", { platform });
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<section style="max-width: 40rem; margin: 3rem auto; padding: 0 1rem; font-family: sans-serif; line-height: 1.5;">
|
||||||
|
<h1 style="font-size: 1.25rem; font-weight: 600;">Time Safari Cannot Start Here</h1>
|
||||||
|
<p>This page must be opened over HTTPS or from <code>localhost</code>.</p>
|
||||||
|
<p>The browser does not treat <code></code> as secure, so it disables the cryptography the app uses for identities.</p>
|
||||||
|
<p>On a local server, use <code>http://localhost:PORT/</code> instead of <code>http://[::]:PORT/</code> or <code>http://0.0.0.0:PORT/</code>.</p>
|
||||||
|
</section>`;
|
||||||
|
const originCode = container.querySelectorAll("code")[1];
|
||||||
|
if (originCode) {
|
||||||
|
originCode.textContent = window.location.origin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mount("#app");
|
if (!window.isSecureContext) {
|
||||||
|
logger.error("[Web] Not a secure context; app will not start", {
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
|
showInsecureContextError();
|
||||||
|
} else {
|
||||||
|
import("./main.common").then(({ initializeApp }) => {
|
||||||
|
const app = initializeApp();
|
||||||
|
|
||||||
|
// Note: Worker initialization is now handled by WebPlatformService
|
||||||
|
// This ensures single-point database access and prevents double migrations
|
||||||
|
if (platform === "web" || platform === "development") {
|
||||||
|
logger.debug(
|
||||||
|
"[Web] Database initialization will be handled by WebPlatformService",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug("[Web] SQL not initialized for platform", { platform });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
|
|
||||||
|
// Handles the manifest's share_target POST (see public/sw.js)
|
||||||
|
if (platform === "web") {
|
||||||
|
registerWebShareTargetWorker();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type PendingNotificationInfo = {
|
|||||||
|
|
||||||
export interface NotificationInspectorPlugin {
|
export interface NotificationInspectorPlugin {
|
||||||
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
|
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
|
||||||
/** Android only: whether the build has a Firebase config (-PfirebaseEnabled). */
|
/** Android only: whether this build included Firebase (off only with -PfirebaseEnabled=false). */
|
||||||
isFirebaseConfigured(): Promise<{ configured: boolean }>;
|
isFirebaseConfigured(): Promise<{ configured: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-23
@@ -6,6 +6,10 @@ import {
|
|||||||
RouteRecordRaw,
|
RouteRecordRaw,
|
||||||
} from "vue-router";
|
} from "vue-router";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import {
|
||||||
|
isDatabaseStartupError,
|
||||||
|
recordStartupError,
|
||||||
|
} from "../utils/startupError";
|
||||||
import {
|
import {
|
||||||
retrieveAccountDids,
|
retrieveAccountDids,
|
||||||
generateSaveAndActivateIdentity,
|
generateSaveAndActivateIdentity,
|
||||||
@@ -33,9 +37,10 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
component: () => import("../views/ClaimCertificateView.vue"),
|
component: () => import("../views/ClaimCertificateView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// shared links to the former gift-confirmation page; the claim page has the same details
|
||||||
path: "/confirm-gift/:id?",
|
path: "/confirm-gift/:id?",
|
||||||
name: "confirm-gift",
|
name: "confirm-gift",
|
||||||
component: () => import("../views/ConfirmGiftView.vue"),
|
redirect: (to) => ({ name: "claim", params: { id: to.params.id } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-amounts",
|
path: "/contact-amounts",
|
||||||
@@ -91,6 +96,15 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/dev/notifications",
|
||||||
|
name: "dev-notifications",
|
||||||
|
component: () => import("../views/dev/NotificationDebugView.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Notification Debug",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/did/:did?",
|
path: "/did/:did?",
|
||||||
name: "did",
|
name: "did",
|
||||||
@@ -177,6 +191,11 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "new-activity",
|
name: "new-activity",
|
||||||
component: () => import("../views/NewActivityView.vue"),
|
component: () => import("../views/NewActivityView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/new-activity/:bucket",
|
||||||
|
name: "new-activity-bucket",
|
||||||
|
component: () => import("../views/NewActivityBucketView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/new-edit-account",
|
path: "/new-edit-account",
|
||||||
name: "new-edit-account",
|
name: "new-edit-account",
|
||||||
@@ -268,8 +287,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
component: () => import("../views/SharedPhotoView.vue"),
|
component: () => import("../views/SharedPhotoView.vue"),
|
||||||
},
|
},
|
||||||
|
|
||||||
// /share-target is also an endpoint in the service worker
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/start",
|
path: "/start",
|
||||||
name: "start",
|
name: "start",
|
||||||
@@ -290,15 +307,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "user-profile",
|
name: "user-profile",
|
||||||
component: () => import("../views/UserProfileView.vue"),
|
component: () => import("../views/UserProfileView.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/dev/notifications",
|
|
||||||
name: "dev-notifications",
|
|
||||||
component: () => import("../views/dev/NotificationDebugView.vue"),
|
|
||||||
meta: {
|
|
||||||
title: "Notification Debug",
|
|
||||||
requiresAuth: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Catch-all route for 404 errors - must be last
|
// Catch-all route for 404 errors - must be last
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
@@ -337,6 +345,12 @@ const errorHandler = (
|
|||||||
) => {
|
) => {
|
||||||
// Handle the error here
|
// Handle the error here
|
||||||
logger.error("Caught in top level error handler:", error, to, from);
|
logger.error("Caught in top level error handler:", error, to, from);
|
||||||
|
if (isDatabaseStartupError(error)) {
|
||||||
|
// Nothing works without the database: show the recovery options instead.
|
||||||
|
recordStartupError(error);
|
||||||
|
router.replace("/start");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert("Something is very wrong. Try reloading or restarting the app.");
|
alert("Something is very wrong. Try reloading or restarting the app.");
|
||||||
|
|
||||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||||
@@ -421,17 +435,8 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store error details so StartView can display them
|
// Store error details so StartView can display them with recovery options
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
recordStartupError(error);
|
||||||
const errorStack = error instanceof Error ? error.stack || "" : "";
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"startupError",
|
|
||||||
JSON.stringify({ message: errorMessage, stack: errorStack }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// sessionStorage may be unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
||||||
// failed migration leaves every DB call throwing), so the user can open
|
// failed migration leaves every DB call throwing), so the user can open
|
||||||
|
|||||||
@@ -209,13 +209,20 @@ export interface PlatformService {
|
|||||||
*/
|
*/
|
||||||
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
||||||
|
|
||||||
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
// --- Startup-failure recovery (optional, only implemented on native mobile) ---
|
||||||
/**
|
/**
|
||||||
* Registers the service worker for PWA support (web only)
|
* Opens the share sheet with a copy of the raw database file, so a user
|
||||||
|
* whose database cannot be opened can still save it or send it to support.
|
||||||
|
* It contains their keys, so the UI must say so.
|
||||||
*/
|
*/
|
||||||
registerServiceWorker?(): void;
|
shareDatabaseFile?(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if PWA is enabled (web only)
|
* Copies the database file aside (into the app's files directory) and then
|
||||||
|
* deletes it, so the next start builds a fresh database. The caller is
|
||||||
|
* expected to reload the app afterward.
|
||||||
|
* @returns the backup file's URI, or undefined when there was no file to back up
|
||||||
|
* @throws if the backup copy fails (the original is never deleted without one)
|
||||||
*/
|
*/
|
||||||
|
resetDatabase?(): Promise<string | undefined>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ class MigrationRegistry {
|
|||||||
// Create a singleton instance of the migration registry
|
// Create a singleton instance of the migration registry
|
||||||
const migrationRegistry = new MigrationRegistry();
|
const migrationRegistry = new MigrationRegistry();
|
||||||
|
|
||||||
|
/** The migration's SQL followed by the statement that records it as applied. */
|
||||||
|
function withMigrationRecord(migration: Migration): string {
|
||||||
|
// A trailing "--" comment ends at its newline, so check what's left without comments.
|
||||||
|
const terminated = migration.sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.trim()
|
||||||
|
.endsWith(";");
|
||||||
|
const name = migration.name.replace(/'/g, "''");
|
||||||
|
return `${migration.sql}\n${terminated ? "" : ";\n"}INSERT INTO migrations (name) VALUES ('${name}');\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a migration with the migration service
|
* Register a migration with the migration service
|
||||||
*
|
*
|
||||||
@@ -282,19 +293,15 @@ export async function runMigrations<T>(
|
|||||||
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
||||||
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
||||||
|
|
||||||
// Execute the migration SQL directly - it should be atomic
|
// The record goes in the same batch as the migration, which the native
|
||||||
// The SQL itself should handle any necessary transactions
|
// plugin runs as one transaction: if the app dies between the two, a
|
||||||
const execResult = await sqlExec(migration.sql);
|
// migration that can't run twice (like 001) would fail every launch.
|
||||||
|
const execResult = await sqlExec(withMigrationRecord(migration));
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record that the migration was applied
|
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
|
||||||
migration.name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
||||||
appliedCount++;
|
appliedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* Loads for the New Activity summary and detail pages, and the upload that
|
||||||
|
* keeps the notification service counting from the same point.
|
||||||
|
*
|
||||||
|
* The cursors and windows are described in `@/libs/newActivity`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Axios } from "axios";
|
||||||
|
|
||||||
|
import type { AlertSearchCursor } from "@/interfaces/alertSearch";
|
||||||
|
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||||
|
import {
|
||||||
|
type AlertSearchLocationBBox,
|
||||||
|
type AlertSearchRequestParams,
|
||||||
|
type AlertSearchResult,
|
||||||
|
type AlertSearchServer,
|
||||||
|
collectAlertSearchForward,
|
||||||
|
fetchAlertSearchPage,
|
||||||
|
} from "@/libs/alertSearch";
|
||||||
|
import {
|
||||||
|
bucketRows,
|
||||||
|
type CursorPair,
|
||||||
|
NEW_ACTIVITY_BUCKETS,
|
||||||
|
NEW_ACTIVITY_BUCKET_INFO,
|
||||||
|
type NewActivityBucket,
|
||||||
|
type NewActivityCursorState,
|
||||||
|
seededState,
|
||||||
|
startParams,
|
||||||
|
summaryStart,
|
||||||
|
unseenStart,
|
||||||
|
windowStart,
|
||||||
|
} from "@/libs/newActivity";
|
||||||
|
|
||||||
|
/** Pages read per server for the summary counts; past that a count shows "+". */
|
||||||
|
export const SUMMARY_MAX_PAGES = 4;
|
||||||
|
/** Pages read for a detail page before offering "Show more". */
|
||||||
|
export const DETAIL_MAX_PAGES = 10;
|
||||||
|
/** Pages to step back through looking for one of this bucket's rows. */
|
||||||
|
export const EARLIER_MAX_PAGES = 5;
|
||||||
|
|
||||||
|
export interface NewActivityContext {
|
||||||
|
axios: Axios;
|
||||||
|
endorserServer: string;
|
||||||
|
partnerServer: string;
|
||||||
|
activeDid: string;
|
||||||
|
/** Starred projects; the tracked buckets are empty without them. */
|
||||||
|
planHandleIds: string[];
|
||||||
|
/** The saved search area; the nearby buckets are empty without it. */
|
||||||
|
location?: AlertSearchLocationBBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a bucket can hold anything with this context's inputs. */
|
||||||
|
export function bucketIsAvailable(
|
||||||
|
ctx: Pick<NewActivityContext, "planHandleIds" | "location">,
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
): boolean {
|
||||||
|
const needs = NEW_ACTIVITY_BUCKET_INFO[bucket].needs;
|
||||||
|
if (needs === "starred") return ctx.planHandleIds.length > 0;
|
||||||
|
if (needs === "location") return ctx.location !== undefined;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverBaseUrl(ctx: NewActivityContext, server: AlertSearchServer) {
|
||||||
|
return server === "endorser" ? ctx.endorserServer : ctx.partnerServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inputs every request to a server carries, besides its bounds. */
|
||||||
|
function serverInputs(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
server: AlertSearchServer,
|
||||||
|
): AlertSearchRequestParams {
|
||||||
|
const inputs: AlertSearchRequestParams = {};
|
||||||
|
if (ctx.location) inputs.location = ctx.location;
|
||||||
|
if (server === "endorser" && ctx.planHandleIds.length > 0) {
|
||||||
|
inputs.planHandleIds = ctx.planHandleIds;
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageFetcher<S extends AlertSearchServer>(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
server: S,
|
||||||
|
) {
|
||||||
|
return (params: AlertSearchRequestParams) =>
|
||||||
|
fetchAlertSearchPage(
|
||||||
|
ctx.axios,
|
||||||
|
server,
|
||||||
|
serverBaseUrl(ctx, server),
|
||||||
|
ctx.activeDid,
|
||||||
|
{ ...serverInputs(ctx, server), ...params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerCounts {
|
||||||
|
/** Undefined for a bucket whose inputs are missing. */
|
||||||
|
counts: Partial<Record<NewActivityBucket, number>>;
|
||||||
|
/** A count is a lower bound: the read stopped with more to go. */
|
||||||
|
hitLimit: boolean;
|
||||||
|
/** Per bucket, when this server's buckets were read from different starts. */
|
||||||
|
bucketHitLimit?: Partial<Record<NewActivityBucket, boolean>>;
|
||||||
|
/** Where this read ended: the start of the next visit. */
|
||||||
|
to?: AlertSearchCursor;
|
||||||
|
userMessage?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewActivityCounts {
|
||||||
|
endorser: ServerCounts;
|
||||||
|
partner: ServerCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const response = (error as { response?: { data?: { error?: unknown } } })
|
||||||
|
.response;
|
||||||
|
const serverError = response?.data?.error;
|
||||||
|
if (typeof serverError === "string") return serverError;
|
||||||
|
if (serverError && typeof serverError === "object") {
|
||||||
|
const message = (serverError as { message?: unknown }).message;
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
const message = (error as { message?: unknown }).message;
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return "The request failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countServer(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
server: AlertSearchServer,
|
||||||
|
from: AlertSearchCursor | undefined,
|
||||||
|
): Promise<ServerCounts> {
|
||||||
|
try {
|
||||||
|
const result = await collectAlertSearchForward(
|
||||||
|
pageFetcher(ctx, server),
|
||||||
|
server,
|
||||||
|
startParams(from),
|
||||||
|
SUMMARY_MAX_PAGES,
|
||||||
|
);
|
||||||
|
const counts: Partial<Record<NewActivityBucket, number>> = {};
|
||||||
|
for (const bucket of NEW_ACTIVITY_BUCKETS) {
|
||||||
|
if (
|
||||||
|
NEW_ACTIVITY_BUCKET_INFO[bucket].server === server &&
|
||||||
|
bucketIsAvailable(ctx, bucket)
|
||||||
|
) {
|
||||||
|
// with no start, the rows are the server's latest, not new ones
|
||||||
|
counts[bucket] =
|
||||||
|
from === undefined ? 0 : bucketRows(result.data, bucket).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
hitLimit: from !== undefined && result.hasMore,
|
||||||
|
to: result.nextAfterCursor,
|
||||||
|
userMessage: result.userMessage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { counts: {}, hitLimit: false, error: errorText(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts for every bucket since `from` (each server independently). */
|
||||||
|
export async function countNewActivity(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
from: Partial<CursorPair> | undefined,
|
||||||
|
): Promise<NewActivityCounts> {
|
||||||
|
const [endorser, partner] = await Promise.all([
|
||||||
|
countServer(ctx, "endorser", from?.endorser),
|
||||||
|
countServer(ctx, "partner", from?.partner),
|
||||||
|
]);
|
||||||
|
return { endorser, partner };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketCount {
|
||||||
|
count: number;
|
||||||
|
/** The count is a lower bound: the read stopped with more to go. */
|
||||||
|
hitLimit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnseenCounts {
|
||||||
|
/** Undefined for a bucket whose inputs are missing or whose server failed. */
|
||||||
|
buckets: Partial<Record<NewActivityBucket, BucketCount>>;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartGroup {
|
||||||
|
server: AlertSearchServer;
|
||||||
|
from?: AlertSearchCursor;
|
||||||
|
buckets: NewActivityBucket[];
|
||||||
|
result: ServerCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts for each available bucket from its own start. Buckets on the same
|
||||||
|
* server with the same start share one read.
|
||||||
|
*/
|
||||||
|
async function countFromStarts(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
startOf: (bucket: NewActivityBucket) => AlertSearchCursor | undefined,
|
||||||
|
): Promise<StartGroup[]> {
|
||||||
|
const groups = new Map<string, Omit<StartGroup, "result">>();
|
||||||
|
for (const bucket of NEW_ACTIVITY_BUCKETS) {
|
||||||
|
if (!bucketIsAvailable(ctx, bucket)) continue;
|
||||||
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
|
const from = startOf(bucket);
|
||||||
|
const key = server + " " + (from ?? "");
|
||||||
|
const group = groups.get(key) ?? { server, from, buckets: [] };
|
||||||
|
group.buckets.push(bucket);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
[...groups.values()].map(async (group) => ({
|
||||||
|
...group,
|
||||||
|
result: await countServer(ctx, group.server, group.from),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts for the summary: each bucket from {@link summaryStart}. Each server's
|
||||||
|
* `to`, the end of the visit, comes from the read that started at the visit's
|
||||||
|
* start, so a capped read leaves the rest for the next visit; when every
|
||||||
|
* bucket on the server has its own start, from a read that was not capped.
|
||||||
|
* A server with any failed read reports the error and no `to`.
|
||||||
|
*/
|
||||||
|
export async function countSummaryActivity(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
now: Date,
|
||||||
|
fallback?: CursorPair,
|
||||||
|
): Promise<NewActivityCounts> {
|
||||||
|
const groups = await countFromStarts(ctx, (bucket) =>
|
||||||
|
summaryStart(state, bucket, now, fallback),
|
||||||
|
);
|
||||||
|
const visitStart = windowStart(state.visit, now) ?? fallback;
|
||||||
|
const serverCounts = (server: AlertSearchServer): ServerCounts => {
|
||||||
|
const own = groups.filter((group) => group.server === server);
|
||||||
|
const counts: ServerCounts = {
|
||||||
|
counts: {},
|
||||||
|
hitLimit: false,
|
||||||
|
bucketHitLimit: {},
|
||||||
|
};
|
||||||
|
for (const group of own) {
|
||||||
|
const result = group.result;
|
||||||
|
if (result.error) {
|
||||||
|
counts.error ??= result.error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.userMessage ??= result.userMessage;
|
||||||
|
counts.hitLimit ||= result.hitLimit;
|
||||||
|
for (const bucket of group.buckets) {
|
||||||
|
counts.counts[bucket] = result.counts[bucket];
|
||||||
|
counts.bucketHitLimit![bucket] = result.hitLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!counts.error) {
|
||||||
|
const ending =
|
||||||
|
own.find((group) => group.from === visitStart?.[server]) ??
|
||||||
|
own.find((group) => !group.result.hitLimit) ??
|
||||||
|
own[0];
|
||||||
|
counts.to = ending?.result.to;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
endorser: serverCounts("endorser"),
|
||||||
|
partner: serverCounts("partner"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts for the Home screen: each bucket from {@link unseenStart}. A person
|
||||||
|
* who has opened no list since the summary costs one read per server.
|
||||||
|
*/
|
||||||
|
export async function countUnseenActivity(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
): Promise<UnseenCounts> {
|
||||||
|
const counted = await countFromStarts(ctx, (bucket) =>
|
||||||
|
unseenStart(state, bucket),
|
||||||
|
);
|
||||||
|
const unseen: UnseenCounts = { buckets: {}, errors: [] };
|
||||||
|
for (const { buckets, result } of counted) {
|
||||||
|
if (result.error) {
|
||||||
|
if (!unseen.errors.includes(result.error)) {
|
||||||
|
unseen.errors.push(result.error);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
unseen.buckets[bucket] = {
|
||||||
|
count: result.counts[bucket] ?? 0,
|
||||||
|
hitLimit: result.hitLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unseen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sum of the known counts, as the notification service totals them. */
|
||||||
|
export function totalNewActivity(counts: NewActivityCounts): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const server of ["endorser", "partner"] as const) {
|
||||||
|
for (const value of Object.values(counts[server].counts)) {
|
||||||
|
total += value ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair to store as the end of this visit, when both servers answered. */
|
||||||
|
export function countsEndPair(
|
||||||
|
counts: NewActivityCounts,
|
||||||
|
): CursorPair | undefined {
|
||||||
|
return counts.endorser.to && counts.partner.to
|
||||||
|
? { endorser: counts.endorser.to, partner: counts.partner.to }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketPage {
|
||||||
|
rows: unknown[];
|
||||||
|
/** Forward: the cursor to store, where this read ended. */
|
||||||
|
nextAfterCursor?: AlertSearchCursor;
|
||||||
|
/** Backward: where to continue for earlier rows. */
|
||||||
|
nextBeforeCursor?: AlertSearchCursor;
|
||||||
|
hasMore: boolean;
|
||||||
|
userMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every row of one bucket from `from` forward, up to DETAIL_MAX_PAGES. */
|
||||||
|
export async function loadBucket(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
from: AlertSearchCursor | undefined,
|
||||||
|
): Promise<BucketPage> {
|
||||||
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
|
const result: AlertSearchResult<AlertSearchServer> =
|
||||||
|
await collectAlertSearchForward(
|
||||||
|
pageFetcher(ctx, server),
|
||||||
|
server,
|
||||||
|
startParams(from),
|
||||||
|
DETAIL_MAX_PAGES,
|
||||||
|
);
|
||||||
|
// with no start, the rows are the server's latest, not new ones
|
||||||
|
return {
|
||||||
|
rows: from === undefined ? [] : bucketRows(result.data, bucket),
|
||||||
|
nextAfterCursor: result.nextAfterCursor,
|
||||||
|
nextBeforeCursor: result.nextBeforeCursor,
|
||||||
|
hasMore: from !== undefined && result.hasMore,
|
||||||
|
userMessage: result.userMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows of one bucket before `before`, newest first. Pages are cut across all
|
||||||
|
* of a server's lists, so a page can hold none of this bucket while more
|
||||||
|
* remain; this steps back up to EARLIER_MAX_PAGES looking for some.
|
||||||
|
*/
|
||||||
|
export async function loadBucketEarlier(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
bucket: NewActivityBucket,
|
||||||
|
before: AlertSearchCursor,
|
||||||
|
): Promise<BucketPage> {
|
||||||
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
|
const fetchPage = pageFetcher(ctx, server);
|
||||||
|
let cursor = before;
|
||||||
|
for (let page = 0; page < EARLIER_MAX_PAGES; page++) {
|
||||||
|
const result = await fetchPage({ beforeCursor: cursor });
|
||||||
|
const rows = bucketRows(result.data, bucket);
|
||||||
|
const moved =
|
||||||
|
!!result.nextBeforeCursor && result.nextBeforeCursor !== cursor;
|
||||||
|
if (rows.length > 0 || !result.hasMore || !moved) {
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
nextBeforeCursor: result.nextBeforeCursor,
|
||||||
|
hasMore: result.hasMore && moved,
|
||||||
|
userMessage: result.userMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
cursor = result.nextBeforeCursor as AlertSearchCursor;
|
||||||
|
}
|
||||||
|
return { rows: [], nextBeforeCursor: cursor, hasMore: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest cursors, for a notification upload when notifications are turned
|
||||||
|
* on: the first daily count then starts from now instead of from old rows.
|
||||||
|
* Each server answers on its own; one that fails is left out.
|
||||||
|
*/
|
||||||
|
export async function latestCursorPair(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
): Promise<Partial<CursorPair>> {
|
||||||
|
const [endorser, partner] = await Promise.allSettled([
|
||||||
|
pageFetcher(ctx, "endorser")({}),
|
||||||
|
pageFetcher(ctx, "partner")({}),
|
||||||
|
]);
|
||||||
|
const latest: Partial<CursorPair> = {};
|
||||||
|
if (endorser.status === "fulfilled" && endorser.value.nextAfterCursor) {
|
||||||
|
latest.endorser = endorser.value.nextAfterCursor;
|
||||||
|
}
|
||||||
|
if (partner.status === "fulfilled" && partner.value.nextAfterCursor) {
|
||||||
|
latest.partner = partner.value.nextAfterCursor;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A state with a visit to count from. With none yet (the person has never
|
||||||
|
* loaded New Activity or Home, nor turned on notifications), this asks each
|
||||||
|
* server for its latest cursor and returns a visit ending there, so nothing
|
||||||
|
* before it shows as new. The state is returned unchanged when it has a visit
|
||||||
|
* or a server did not answer; `seeded` says whether to save it.
|
||||||
|
*/
|
||||||
|
export async function seedIfNeeded(
|
||||||
|
ctx: NewActivityContext,
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
now: Date,
|
||||||
|
): Promise<{ state: NewActivityCursorState; seeded: boolean }> {
|
||||||
|
if (state.visit) return { state, seeded: false };
|
||||||
|
const latest = await latestCursorPair(ctx);
|
||||||
|
if (!latest.endorser || !latest.partner) return { state, seeded: false };
|
||||||
|
return {
|
||||||
|
state: seededState(
|
||||||
|
state,
|
||||||
|
{ endorser: latest.endorser, partner: latest.partner },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
seeded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The upload body fields for a channel's alert authorization. A server
|
||||||
|
* without a cursor is left out, and the service keeps (or, with none stored,
|
||||||
|
* seeds) its own.
|
||||||
|
*/
|
||||||
|
export function alertSearchUploadState(
|
||||||
|
ctx: Pick<NewActivityContext, "planHandleIds" | "location">,
|
||||||
|
cursors: Partial<CursorPair> | undefined,
|
||||||
|
): AlertSearchUploadState {
|
||||||
|
return {
|
||||||
|
...(cursors?.endorser ? { endorserAfterCursor: cursors.endorser } : {}),
|
||||||
|
...(cursors?.partner ? { partnerAfterCursor: cursors.partner } : {}),
|
||||||
|
planHandleIds: ctx.planHandleIds.length > 0 ? ctx.planHandleIds : null,
|
||||||
|
location: ctx.location ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Build a {@link NewActivityContext} from the account settings, the same way
|
||||||
|
* on the summary, the detail pages and the notification toggles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Axios } from "axios";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_ENDORSER_API_SERVER,
|
||||||
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
|
} from "@/constants/app";
|
||||||
|
import * as databaseUtil from "@/db/databaseUtil";
|
||||||
|
import type { Settings } from "@/db/tables/settings";
|
||||||
|
import type { BoundingBox } from "@/interfaces/accountView";
|
||||||
|
import { alertSearchLocationFromBox } from "@/libs/alertSearch";
|
||||||
|
import type { NewActivityContext } from "./newActivity";
|
||||||
|
|
||||||
|
export function buildNewActivityContext(
|
||||||
|
axios: Axios,
|
||||||
|
activeDid: string,
|
||||||
|
settings: Settings,
|
||||||
|
): NewActivityContext {
|
||||||
|
const searchBoxes = databaseUtil.parseJsonField<
|
||||||
|
Array<{ name: string; bbox: BoundingBox }>
|
||||||
|
>(settings.searchBoxes, []);
|
||||||
|
return {
|
||||||
|
axios,
|
||||||
|
endorserServer: settings.apiServer || DEFAULT_ENDORSER_API_SERVER,
|
||||||
|
partnerServer: settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER,
|
||||||
|
activeDid,
|
||||||
|
planHandleIds: databaseUtil.parseJsonField<string[]>(
|
||||||
|
settings.starredPlanHandleIds,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
location: alertSearchLocationFromBox(searchBoxes[0]?.bbox),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@
|
|||||||
* `@/interfaces/notifyApi`.
|
* `@/interfaces/notifyApi`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AlertAuthorizationRequestBody } from "@/interfaces/notifyApi";
|
import type {
|
||||||
|
AlertAuthorizationRequestBody,
|
||||||
|
AlertSearchUploadState,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
||||||
import {
|
import {
|
||||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||||
@@ -87,7 +90,8 @@ const defaultDependencies: AlertAuthorizationDependencies = {
|
|||||||
function buildRequestBody(
|
function buildRequestBody(
|
||||||
batch: AlertAuthorizationBatch,
|
batch: AlertAuthorizationBatch,
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
): AlertAuthorizationRequestBody {
|
searchState: AlertSearchUploadState = {},
|
||||||
|
): AlertAuthorizationRequestBody & AlertSearchUploadState {
|
||||||
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
|
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`,
|
`Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`,
|
||||||
@@ -102,6 +106,7 @@ function buildRequestBody(
|
|||||||
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
|
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
|
||||||
timezone: timeZone,
|
timezone: timeZone,
|
||||||
jwts: batch.jwts,
|
jwts: batch.jwts,
|
||||||
|
...searchState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +208,13 @@ async function authorizationHttpFailure(response: Response): Promise<{
|
|||||||
/**
|
/**
|
||||||
* Mint and upload a delegated AlertSearch authorization batch.
|
* Mint and upload a delegated AlertSearch authorization batch.
|
||||||
* This function never retries and refuses notification auth bypass mode.
|
* This function never retries and refuses notification auth bypass mode.
|
||||||
|
*
|
||||||
|
* @param searchState cursors to count from, and the starred handle IDs and
|
||||||
|
* area the daily search uses; see {@link AlertSearchUploadState}
|
||||||
*/
|
*/
|
||||||
export async function uploadAlertSearchAuthorization(
|
export async function uploadAlertSearchAuthorization(
|
||||||
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
||||||
|
searchState?: AlertSearchUploadState,
|
||||||
): Promise<AlertAuthorizationUploadResult> {
|
): Promise<AlertAuthorizationUploadResult> {
|
||||||
try {
|
try {
|
||||||
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
|
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
|
||||||
@@ -215,7 +224,7 @@ export async function uploadAlertSearchAuthorization(
|
|||||||
|
|
||||||
const batch = await dependencies.mintBatch(access.did);
|
const batch = await dependencies.mintBatch(access.did);
|
||||||
const timeZone = dependencies.getTimeZone();
|
const timeZone = dependencies.getTimeZone();
|
||||||
const body = buildRequestBody(batch, timeZone);
|
const body = buildRequestBody(batch, timeZone, searchState);
|
||||||
|
|
||||||
const response = await dependencies.fetch(
|
const response = await dependencies.fetch(
|
||||||
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
||||||
|
|||||||
@@ -69,9 +69,10 @@ async function registerRetrievedToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether PushNotifications.register() is safe to call. Android builds without
|
* Whether PushNotifications.register() is safe to call. Android builds with
|
||||||
* -PfirebaseEnabled (FOSS/F-Droid/APK) have no Firebase config, and register()
|
* Firebase disabled (-PfirebaseEnabled=false, FOSS/F-Droid) have no Firebase
|
||||||
* there throws natively, crashing the app before any JS catch can run.
|
* config, and register() there throws natively, crashing the app before any
|
||||||
|
* JS catch can run.
|
||||||
*/
|
*/
|
||||||
async function canRegisterNativePush(): Promise<boolean> {
|
async function canRegisterNativePush(): Promise<boolean> {
|
||||||
if (Capacitor.getPlatform() !== "android") {
|
if (Capacitor.getPlatform() !== "android") {
|
||||||
@@ -101,7 +102,7 @@ export async function reregisterFcmTokenNow(): Promise<string> {
|
|||||||
|
|
||||||
if (!(await canRegisterNativePush())) {
|
if (!(await canRegisterNativePush())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Push is unavailable: this Android build has no Firebase config (built without -PfirebaseEnabled)",
|
"Push is unavailable: this Android build has no Firebase config (built with -PfirebaseEnabled=false)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +259,23 @@ async function attachFirebaseMessagingIfSupported(
|
|||||||
* Native: register Capacitor push listeners, request permissions, register for push,
|
* Native: register Capacitor push listeners, request permissions, register for push,
|
||||||
* then initialize Firebase Messaging when env config and platform support allow.
|
* then initialize Firebase Messaging when env config and platform support allow.
|
||||||
*/
|
*/
|
||||||
|
/** The FCM data `type` of the daily New Activity notification. */
|
||||||
|
const ALERT_SEARCH_FCM_TYPE = "alert_search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open New Activity when the daily New Activity notification is tapped,
|
||||||
|
* passing its start cursors (`c`) so the screen can compare them with its own.
|
||||||
|
*/
|
||||||
|
async function openNewActivityForAlertSearch(
|
||||||
|
data: Record<string, unknown> | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (data?.type !== ALERT_SEARCH_FCM_TYPE) return;
|
||||||
|
// imported here: the router imports views that import this module's callers
|
||||||
|
const { default: router } = await import("@/router");
|
||||||
|
const c = typeof data.c === "string" ? data.c : undefined;
|
||||||
|
await router.push({ name: "new-activity", query: c ? { c } : {} });
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
||||||
if (!Capacitor.isNativePlatform()) {
|
if (!Capacitor.isNativePlatform()) {
|
||||||
return;
|
return;
|
||||||
@@ -306,6 +324,11 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
|||||||
(action) => {
|
(action) => {
|
||||||
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
|
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
|
||||||
logPushNotificationActionPerformed(action);
|
logPushNotificationActionPerformed(action);
|
||||||
|
void openNewActivityForAlertSearch(action.notification?.data).catch(
|
||||||
|
(err) => {
|
||||||
|
logger.warn(`${LOG} Could not open New Activity`, err);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* unchanged instead of showing the subscription as off.
|
* unchanged instead of showing the subscription as off.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||||
import {
|
import {
|
||||||
type AlertAuthorizationDependencies,
|
type AlertAuthorizationDependencies,
|
||||||
type AlertAuthorizationRevokeResult,
|
type AlertAuthorizationRevokeResult,
|
||||||
@@ -23,8 +24,12 @@ import {
|
|||||||
export async function authorizeInAppNewActivity(
|
export async function authorizeInAppNewActivity(
|
||||||
reportError: (message: string) => void,
|
reportError: (message: string) => void,
|
||||||
dependencies?: AlertAuthorizationDependencies,
|
dependencies?: AlertAuthorizationDependencies,
|
||||||
|
searchState?: AlertSearchUploadState,
|
||||||
): Promise<AlertAuthorizationUploadResult> {
|
): Promise<AlertAuthorizationUploadResult> {
|
||||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
const result = await uploadAlertSearchAuthorization(
|
||||||
|
dependencies,
|
||||||
|
searchState,
|
||||||
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
reportError(result.errorMessage);
|
reportError(result.errorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Re-authorize the daily New Activity notifications after the person has looked
|
||||||
|
* at New Activity, so the next push or text counts only what came after.
|
||||||
|
*
|
||||||
|
* Each enabled channel gets a fresh delegated JWT batch (replacing the unused
|
||||||
|
* one) carrying the visit's end cursors, the starred handle IDs and the search
|
||||||
|
* area. The service skips days whose JWT was already spent, so this never
|
||||||
|
* causes a second notification on the same day.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||||
|
import type { CursorPair, NotificationChannel } from "@/libs/newActivity";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import { localTimeTextToUtc } from "./alertAuthorizationBatch";
|
||||||
|
import { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
||||||
|
import { authorizeSmsAlertSearch } from "./smsNotificationApi";
|
||||||
|
|
||||||
|
/** The settings that say which channels are on. */
|
||||||
|
export interface NotificationChannelSettings {
|
||||||
|
notifyingNewActivityTime?: string;
|
||||||
|
notifyingNewActivitySmsTime?: string;
|
||||||
|
smsNotificationsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enabledNotificationChannels(
|
||||||
|
settings: NotificationChannelSettings,
|
||||||
|
): NotificationChannel[] {
|
||||||
|
const channels: NotificationChannel[] = [];
|
||||||
|
if (settings.notifyingNewActivityTime) channels.push("fcm");
|
||||||
|
if (
|
||||||
|
settings.smsNotificationsEnabled &&
|
||||||
|
settings.notifyingNewActivitySmsTime
|
||||||
|
) {
|
||||||
|
channels.push("sms");
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationRefreshDependencies {
|
||||||
|
uploadFcm: (state: AlertSearchUploadState) => Promise<boolean>;
|
||||||
|
uploadSms: (
|
||||||
|
did: string,
|
||||||
|
notifyHourUtc: number,
|
||||||
|
notifyMinuteUtc: number,
|
||||||
|
state: AlertSearchUploadState,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDependencies: NotificationRefreshDependencies = {
|
||||||
|
uploadFcm: async (state) => {
|
||||||
|
const result = await uploadAlertSearchAuthorization(undefined, state);
|
||||||
|
if (!result.ok) {
|
||||||
|
logger.warn(
|
||||||
|
"[NewActivityRefresh] Push re-authorization failed:",
|
||||||
|
result.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.ok;
|
||||||
|
},
|
||||||
|
uploadSms: async (did, hour, minute, state) => {
|
||||||
|
await authorizeSmsAlertSearch(did, hour, minute, state);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload to each channel in `channels`. Failures are logged, never thrown: the
|
||||||
|
* screen has already shown its counts, and the old batch keeps working.
|
||||||
|
*
|
||||||
|
* @returns the channels that accepted the upload, with the cursors they now hold
|
||||||
|
*/
|
||||||
|
export async function refreshNotificationAuthorizations(
|
||||||
|
did: string,
|
||||||
|
settings: NotificationChannelSettings,
|
||||||
|
channels: NotificationChannel[],
|
||||||
|
cursors: CursorPair,
|
||||||
|
state: AlertSearchUploadState,
|
||||||
|
dependencies: NotificationRefreshDependencies = defaultDependencies,
|
||||||
|
): Promise<Partial<Record<NotificationChannel, CursorPair>>> {
|
||||||
|
const uploaded: Partial<Record<NotificationChannel, CursorPair>> = {};
|
||||||
|
for (const channel of channels) {
|
||||||
|
try {
|
||||||
|
let ok = false;
|
||||||
|
if (channel === "fcm") {
|
||||||
|
ok = await dependencies.uploadFcm(state);
|
||||||
|
} else {
|
||||||
|
const utc = localTimeTextToUtc(
|
||||||
|
settings.notifyingNewActivitySmsTime || "",
|
||||||
|
);
|
||||||
|
if (!utc) {
|
||||||
|
logger.warn(
|
||||||
|
"[NewActivityRefresh] Unreadable SMS time; texts not re-authorized:",
|
||||||
|
settings.notifyingNewActivitySmsTime,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ok = await dependencies.uploadSms(
|
||||||
|
did,
|
||||||
|
utc.notifyHourUtc,
|
||||||
|
utc.notifyMinuteUtc,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ok) uploaded[channel] = cursors;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[NewActivityRefresh] ${channel} re-authorization failed:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AlertAuthorizationRequestBody,
|
AlertAuthorizationRequestBody,
|
||||||
AlertAuthorizationResponse,
|
AlertAuthorizationResponse,
|
||||||
|
AlertSearchUploadState,
|
||||||
AlertAuthorizationRevokeResponse,
|
AlertAuthorizationRevokeResponse,
|
||||||
NotifySmsFailure,
|
NotifySmsFailure,
|
||||||
SmsActionJwtPayload,
|
SmsActionJwtPayload,
|
||||||
@@ -172,7 +173,7 @@ type SmsRequestOptions = SmsActionTarget & {
|
|||||||
| SmsPhoneRegisterRequest
|
| SmsPhoneRegisterRequest
|
||||||
| SmsPhoneVerifyRequest
|
| SmsPhoneVerifyRequest
|
||||||
| SmsPhoneDeleteRequest
|
| SmsPhoneDeleteRequest
|
||||||
| AlertAuthorizationRequestBody;
|
| (AlertAuthorizationRequestBody & AlertSearchUploadState);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function smsRequest<T>(
|
async function smsRequest<T>(
|
||||||
@@ -327,17 +328,19 @@ export async function deleteSmsPhone(
|
|||||||
*
|
*
|
||||||
* @param notifyHourUtc UTC hour, 0-23
|
* @param notifyHourUtc UTC hour, 0-23
|
||||||
* @param notifyMinuteUtc UTC minute, 0-59
|
* @param notifyMinuteUtc UTC minute, 0-59
|
||||||
|
* @param searchState cursors to count from, and the starred handle IDs and
|
||||||
|
* area the daily search uses; see {@link AlertSearchUploadState}
|
||||||
*/
|
*/
|
||||||
export async function authorizeSmsAlertSearch(
|
export async function authorizeSmsAlertSearch(
|
||||||
did: string,
|
did: string,
|
||||||
notifyHourUtc: number,
|
notifyHourUtc: number,
|
||||||
notifyMinuteUtc: number,
|
notifyMinuteUtc: number,
|
||||||
|
searchState?: AlertSearchUploadState,
|
||||||
): Promise<AlertAuthorizationResponse> {
|
): Promise<AlertAuthorizationResponse> {
|
||||||
const body = await buildAlertAuthorizationBody(
|
const body = {
|
||||||
did,
|
...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)),
|
||||||
notifyHourUtc,
|
...searchState,
|
||||||
notifyMinuteUtc,
|
};
|
||||||
);
|
|
||||||
return smsRequest<AlertAuthorizationResponse>(did, {
|
return smsRequest<AlertAuthorizationResponse>(did, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/notify-sms/alert-authorization",
|
path: "/notify-sms/alert-authorization",
|
||||||
|
|||||||
@@ -21,9 +21,17 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger, safeStringify } from "../../utils/logger";
|
||||||
import { BaseDatabaseService } from "./BaseDatabaseService";
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
|
|
||||||
|
/** Readable one-line description of anything thrown, including plugin rejections. */
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
return typeof error === "string" ? error : safeStringify(error);
|
||||||
|
}
|
||||||
|
|
||||||
interface QueuedOperation {
|
interface QueuedOperation {
|
||||||
type: "run" | "query" | "rawQuery";
|
type: "run" | "query" | "rawQuery";
|
||||||
sql: string;
|
sql: string;
|
||||||
@@ -54,6 +62,8 @@ export class CapacitorPlatformService
|
|||||||
private initializationPromise: Promise<void> | null = null;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
private operationQueue: Array<QueuedOperation> = [];
|
private operationQueue: Array<QueuedOperation> = [];
|
||||||
private isProcessingQueue: boolean = false;
|
private isProcessingQueue: boolean = false;
|
||||||
|
/** Set by resetDatabase so a stray query cannot recreate the file mid-reset. */
|
||||||
|
private resetInProgress = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -71,6 +81,12 @@ export class CapacitorPlatformService
|
|||||||
return this.initializationPromise;
|
return this.initializationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.resetInProgress) {
|
||||||
|
throw new Error(
|
||||||
|
"[CapacitorPlatformService] The database is being reset; restart the app.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start initialization
|
// Start initialization
|
||||||
this.initializationPromise = this._initialize();
|
this.initializationPromise = this._initialize();
|
||||||
@@ -90,6 +106,8 @@ export class CapacitorPlatformService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which step failed is the first thing support needs to know.
|
||||||
|
let step = "create connection";
|
||||||
try {
|
try {
|
||||||
// Try to create/Open database connection
|
// Try to create/Open database connection
|
||||||
try {
|
try {
|
||||||
@@ -164,6 +182,7 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open the connection if it's not already open
|
// Open the connection if it's not already open
|
||||||
|
step = "open database";
|
||||||
try {
|
try {
|
||||||
await this.db.open();
|
await this.db.open();
|
||||||
} catch (openError: unknown) {
|
} catch (openError: unknown) {
|
||||||
@@ -182,6 +201,7 @@ export class CapacitorPlatformService
|
|||||||
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
|
step = "run migrations";
|
||||||
await this.runCapacitorMigrations();
|
await this.runCapacitorMigrations();
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -196,8 +216,9 @@ export class CapacitorPlatformService
|
|||||||
"[CapacitorPlatformService] Error initializing SQLite database:",
|
"[CapacitorPlatformService] Error initializing SQLite database:",
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
// Keep the underlying cause in the message: it is all a user can send us.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"[CapacitorPlatformService] Failed to initialize database",
|
`[CapacitorPlatformService] Failed to initialize database (step: ${step}): ${describeError(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,6 +441,119 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** File name the SQLite plugin stores this.dbName under (it appends "SQLite.db"). */
|
||||||
|
private get databaseFileName(): string {
|
||||||
|
return `${this.dbName}SQLite.db`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URI of the directory where the SQLite plugin keeps its database files. */
|
||||||
|
private async databaseDirectoryUri(): Promise<string> {
|
||||||
|
const platform = Capacitor.getPlatform();
|
||||||
|
if (platform === "android") {
|
||||||
|
// The plugin uses Context.getDatabasePath(), the "databases" sibling of
|
||||||
|
// getFilesDir() (which is Directory.Data).
|
||||||
|
const { uri } = await Filesystem.getUri({
|
||||||
|
path: "",
|
||||||
|
directory: Directory.Data,
|
||||||
|
});
|
||||||
|
if (!/\/files\/?$/.test(uri)) {
|
||||||
|
throw new Error(`Unexpected app data directory: ${uri}`);
|
||||||
|
}
|
||||||
|
return uri.replace(/\/files\/?$/, "/databases");
|
||||||
|
}
|
||||||
|
if (platform === "ios") {
|
||||||
|
// Matches iosDatabaseLocation "Documents" in capacitor.config
|
||||||
|
const { uri } = await Filesystem.getUri({
|
||||||
|
path: "",
|
||||||
|
directory: Directory.Documents,
|
||||||
|
});
|
||||||
|
return uri.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
throw new Error(`Database file access is not supported on ${platform}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async databaseFileExists(fileUri: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Filesystem.stat({ path: fileUri });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareDatabaseFile(): Promise<void> {
|
||||||
|
const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`;
|
||||||
|
if (!(await this.databaseFileExists(source))) {
|
||||||
|
throw new Error("There is no database file on this device.");
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const { uri } = await Filesystem.copy({
|
||||||
|
from: source,
|
||||||
|
to: `timesafari-database-${stamp}.sqlite`,
|
||||||
|
toDirectory: Directory.Cache,
|
||||||
|
});
|
||||||
|
await Share.share({
|
||||||
|
title: "Time Safari database",
|
||||||
|
url: uri,
|
||||||
|
dialogTitle: "Save or send your database file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetDatabase(): Promise<string | undefined> {
|
||||||
|
this.resetInProgress = true;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await this.sqlite.closeConnection(this.dbName, false);
|
||||||
|
} catch (closeError) {
|
||||||
|
// Expected when the connection was never created
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Close before reset failed (may be expected):",
|
||||||
|
closeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.db = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.initializationPromise = null;
|
||||||
|
|
||||||
|
const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`;
|
||||||
|
let backupUri: string | undefined;
|
||||||
|
if (await this.databaseFileExists(source)) {
|
||||||
|
// Never delete without a copy: this file may hold the only copy of the user's keys.
|
||||||
|
const backupDir = "database-backups";
|
||||||
|
try {
|
||||||
|
await Filesystem.mkdir({
|
||||||
|
path: backupDir,
|
||||||
|
directory: Directory.Data,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const { uri } = await Filesystem.copy({
|
||||||
|
from: source,
|
||||||
|
to: `${backupDir}/${this.databaseFileName}-${stamp}`,
|
||||||
|
toDirectory: Directory.Data,
|
||||||
|
});
|
||||||
|
backupUri = uri;
|
||||||
|
await Filesystem.deleteFile({ path: source });
|
||||||
|
}
|
||||||
|
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
||||||
|
if (await this.databaseFileExists(source + suffix)) {
|
||||||
|
await Filesystem.deleteFile({ path: source + suffix });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`[CapacitorPlatformService] Database reset; backup at ${backupUri ?? "(no file existed)"}`,
|
||||||
|
);
|
||||||
|
// resetInProgress stays set: the caller reloads the app.
|
||||||
|
return backupUri;
|
||||||
|
} catch (error) {
|
||||||
|
this.resetInProgress = false;
|
||||||
|
throw new Error(`Could not reset the database: ${describeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if an object is a Proxy object that cannot be serialized
|
* Detect if an object is a Proxy object that cannot be serialized
|
||||||
* Proxy objects cause "An object could not be cloned" errors in Capacitor
|
* Proxy objects cause "An object could not be cloned" errors in Capacitor
|
||||||
@@ -1412,9 +1546,6 @@ export class CapacitorPlatformService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PWA/Web-only methods (no-op for Capacitor) ---
|
|
||||||
public registerServiceWorker(): void {}
|
|
||||||
|
|
||||||
// Database utility methods - inherited from BaseDatabaseService
|
// Database utility methods - inherited from BaseDatabaseService
|
||||||
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
||||||
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
||||||
|
|||||||
@@ -163,7 +163,4 @@ export class ElectronPlatformService extends CapacitorPlatformService {
|
|||||||
isWeb(): boolean {
|
isWeb(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PWA/Web-only methods (no-op for Electron) ---
|
|
||||||
public registerServiceWorker(): void {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger, safeStringify } from "../../utils/logger";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
import { BaseDatabaseService } from "./BaseDatabaseService";
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
// Dynamic import of initBackend to prevent worker context errors
|
// Dynamic import of initBackend to prevent worker context errors
|
||||||
@@ -123,7 +123,9 @@ export class WebPlatformService
|
|||||||
logger.error("[WebPlatformService] Failed to initialize worker:", error);
|
logger.error("[WebPlatformService] Failed to initialize worker:", error);
|
||||||
this.workerReady = false;
|
this.workerReady = false;
|
||||||
this.workerInitPromise = null;
|
this.workerInitPromise = null;
|
||||||
throw new Error("Failed to initialize database worker");
|
throw new Error(
|
||||||
|
`Failed to initialize database worker: ${error instanceof Error ? String(error) : safeStringify(error)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* The SQLite plugin reads its settings only under its registered name,
|
||||||
|
* "CapacitorSQLite". Under any other key the settings are silently ignored and
|
||||||
|
* the plugin defaults apply, which on Android means Keystore-backed encryption
|
||||||
|
* setup that can fail on every launch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const repo = join(__dirname, "..", "..");
|
||||||
|
|
||||||
|
function read(relativePath: string): string {
|
||||||
|
return readFileSync(join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SQLite plugin config", () => {
|
||||||
|
it.each(["capacitor.config.ts", "electron/capacitor.config.ts"])(
|
||||||
|
"%s uses the plugin's registered name",
|
||||||
|
(file) => {
|
||||||
|
const source = read(file);
|
||||||
|
expect(source).toMatch(/^\s*CapacitorSQLite:\s*\{/m);
|
||||||
|
expect(source).toMatch(/androidIsEncryption:\s*false/);
|
||||||
|
expect(source).toMatch(/iosDatabaseLocation:\s*'Documents'/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("the synced Android config uses the plugin's registered name", () => {
|
||||||
|
const config = JSON.parse(
|
||||||
|
read("android/app/src/main/assets/capacitor.config.json"),
|
||||||
|
);
|
||||||
|
expect(config.plugins.CapacitorSQLite.androidIsEncryption).toBe(false);
|
||||||
|
expect(config.plugins.CapacitorSQLite.iosDatabaseLocation).toBe(
|
||||||
|
"Documents",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Runs the real migrations against sql.js, executing multi-statement SQL the
|
||||||
|
* way CapacitorPlatformService does: "--" comments stripped, split on ";",
|
||||||
|
* and run in one transaction with foreign keys enforced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("@/utils/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
log: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("@/constants/app", () => ({
|
||||||
|
DEFAULT_ENDORSER_API_SERVER: "https://api.example",
|
||||||
|
}));
|
||||||
|
jest.mock("@/libs/crypto", () => ({
|
||||||
|
arrayBufferToBase64: (buffer: ArrayBuffer) =>
|
||||||
|
Buffer.from(buffer).toString("base64"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { runMigrations } from "@/db-sql/migration";
|
||||||
|
|
||||||
|
// sql.js has no type declarations installed here
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const initSqlJs = require("sql.js");
|
||||||
|
|
||||||
|
type QueryResult = Array<{ columns: string[]; values: unknown[][] }>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type SqlJsDatabase = any;
|
||||||
|
|
||||||
|
const MIGRATION_008 = "008_remove_iViewContent_from_contacts";
|
||||||
|
|
||||||
|
async function newDatabase(): Promise<SqlJsDatabase> {
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const db = new SQL.Database();
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param afterExec runs after each successful sqlExec, e.g. to simulate the app dying */
|
||||||
|
async function migrate(
|
||||||
|
db: SqlJsDatabase,
|
||||||
|
afterExec?: (sql: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const rawExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
db.run(sql, params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statements = sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s);
|
||||||
|
if (statements.length > 1) {
|
||||||
|
// Like the plugin's executeSet: all or nothing
|
||||||
|
db.run("BEGIN");
|
||||||
|
try {
|
||||||
|
statements.forEach((statement) => db.run(statement));
|
||||||
|
db.run("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.run(sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
|
await rawExec(sql, params);
|
||||||
|
afterExec?.(sql);
|
||||||
|
};
|
||||||
|
const sqlQuery = async (
|
||||||
|
sql: string,
|
||||||
|
params?: unknown[],
|
||||||
|
): Promise<QueryResult> => db.exec(sql, params);
|
||||||
|
const extractMigrationNames = (result: QueryResult): Set<string> =>
|
||||||
|
new Set((result[0]?.values || []).map((row) => row[0] as string));
|
||||||
|
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(db: SqlJsDatabase, sql: string): unknown[][] {
|
||||||
|
return (db.exec(sql) as QueryResult)[0]?.values || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a fully migrated database back into the pre-008 shape that older app
|
||||||
|
* versions left behind: no unique index on contacts.did, so duplicates and
|
||||||
|
* dangling labels can exist.
|
||||||
|
*/
|
||||||
|
function revertTo007(db: SqlJsDatabase): void {
|
||||||
|
db.run("PRAGMA foreign_keys = OFF");
|
||||||
|
db.run("DROP INDEX idx_contacts_did");
|
||||||
|
db.run("CREATE INDEX idx_contacts_did ON contacts(did)");
|
||||||
|
db.run(`DELETE FROM migrations WHERE name = '${MIGRATION_008}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrations", () => {
|
||||||
|
it("migrates a fresh database", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
expect(
|
||||||
|
rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives the app dying right after a migration commits", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await expect(
|
||||||
|
migrate(db, (sql) => {
|
||||||
|
if (sql.includes("CREATE TABLE IF NOT EXISTS accounts")) {
|
||||||
|
throw new Error("app killed");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("app killed");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
const clean = await newDatabase();
|
||||||
|
await migrate(clean);
|
||||||
|
const names = "SELECT name FROM migrations ORDER BY name";
|
||||||
|
expect(rows(db, names)).toEqual(rows(clean, names));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-running 001 keeps the existing secret", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
const secret = rows(db, "SELECT secretBase64 FROM secret");
|
||||||
|
db.run("DELETE FROM migrations WHERE name = '001_initial'");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT secretBase64 FROM secret")).toEqual(secret);
|
||||||
|
expect(
|
||||||
|
rows(db, "SELECT name FROM migrations WHERE name = '001_initial'"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("008 keeps the newest contact when a DID was saved twice", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
revertTo007(db);
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO contacts (did, name) VALUES ('did:a', 'A old'), ('did:b', 'B'), ('did:a', 'A new')",
|
||||||
|
);
|
||||||
|
db.run("INSERT INTO contact_labels VALUES ('did:a', 'friends')");
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT did, name FROM contacts ORDER BY did")).toEqual([
|
||||||
|
["did:a", "A new"],
|
||||||
|
["did:b", "B"],
|
||||||
|
]);
|
||||||
|
expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([
|
||||||
|
["did:a", "friends"],
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("008 drops labels whose contact no longer exists", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
revertTo007(db);
|
||||||
|
db.run("INSERT INTO contacts (did, name) VALUES ('did:a', 'A')");
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO contact_labels VALUES ('did:a', 'friends'), ('did:gone', 'orphan')",
|
||||||
|
);
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([
|
||||||
|
["did:a", "friends"],
|
||||||
|
]);
|
||||||
|
expect(rows(db, "PRAGMA foreign_key_check")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
jest.mock("@/libs/endorserServer", () => ({
|
||||||
|
getHeaders: jest.fn(async () => ({ "Content-Type": "application/json" })),
|
||||||
|
}));
|
||||||
|
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
|
||||||
|
jest.mock("@/services/notifications/alertAuthorization", () => ({
|
||||||
|
uploadAlertSearchAuthorization: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("@/services/notifications/smsNotificationApi", () => ({
|
||||||
|
authorizeSmsAlertSearch: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("@/services/notifications/alertAuthorizationBatch", () => ({
|
||||||
|
localTimeTextToUtc: (text: string) =>
|
||||||
|
text === "9:00 AM" ? { notifyHourUtc: 15, notifyMinuteUtc: 0 } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { Axios } from "axios";
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectAlertSearchForward,
|
||||||
|
mergeAlertSearchData,
|
||||||
|
} from "@/libs/alertSearch";
|
||||||
|
import {
|
||||||
|
joinCursorPair,
|
||||||
|
nextWindow,
|
||||||
|
parseCursorParam,
|
||||||
|
parseNewActivityCursorState,
|
||||||
|
SAME_VISIT_MS,
|
||||||
|
seededState,
|
||||||
|
startParams,
|
||||||
|
summaryStart,
|
||||||
|
ulidJustBefore,
|
||||||
|
unseenStart,
|
||||||
|
windowStart,
|
||||||
|
} from "@/libs/newActivity";
|
||||||
|
import {
|
||||||
|
alertSearchUploadState,
|
||||||
|
countNewActivity,
|
||||||
|
countSummaryActivity,
|
||||||
|
countUnseenActivity,
|
||||||
|
latestCursorPair,
|
||||||
|
loadBucket,
|
||||||
|
loadBucketEarlier,
|
||||||
|
NewActivityContext,
|
||||||
|
seedIfNeeded,
|
||||||
|
totalNewActivity,
|
||||||
|
} from "@/services/newActivity";
|
||||||
|
import {
|
||||||
|
enabledNotificationChannels,
|
||||||
|
refreshNotificationAuthorizations,
|
||||||
|
} from "@/services/notifications/newActivityRefresh";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||||
|
const minutesAgo = (m: number) =>
|
||||||
|
new Date(NOW.getTime() - m * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
describe("visit windows", () => {
|
||||||
|
it("inside a visit, counts from the same start and keeps the start time", () => {
|
||||||
|
const window = { from: "10", to: "20", at: minutesAgo(5) };
|
||||||
|
expect(windowStart(window, NOW)).toEqual("10");
|
||||||
|
expect(nextWindow(window, "25", NOW)).toEqual({
|
||||||
|
from: "10",
|
||||||
|
to: "25",
|
||||||
|
at: window.at,
|
||||||
|
seenAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after a visit, the next one starts where it ended", () => {
|
||||||
|
const window = { from: "10", to: "20", at: minutesAgo(31) };
|
||||||
|
expect(SAME_VISIT_MS).toEqual(30 * 60 * 1000);
|
||||||
|
expect(windowStart(window, NOW)).toEqual("20");
|
||||||
|
expect(nextWindow(window, "25", NOW)).toEqual({
|
||||||
|
from: "20",
|
||||||
|
fromSeenAt: window.at,
|
||||||
|
to: "25",
|
||||||
|
at: NOW.toISOString(),
|
||||||
|
seenAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with no window, there is no start and the first window has none", () => {
|
||||||
|
expect(windowStart(undefined, NOW)).toBeUndefined();
|
||||||
|
expect(nextWindow(undefined, "5", NOW)).toEqual({
|
||||||
|
to: "5",
|
||||||
|
at: NOW.toISOString(),
|
||||||
|
seenAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads missing or broken stored state as empty", () => {
|
||||||
|
expect(parseNewActivityCursorState(undefined)).toEqual({});
|
||||||
|
expect(parseNewActivityCursorState("{not json")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notification link cursors", () => {
|
||||||
|
it("round-trips the endorser and partner cursors through 'c'", () => {
|
||||||
|
const pair = { endorser: "48213", partner: "912-48213" };
|
||||||
|
expect(joinCursorPair(pair)).toEqual("48213.912-48213");
|
||||||
|
expect(parseCursorParam("48213.912-48213")).toEqual(pair);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a value without both halves", () => {
|
||||||
|
expect(parseCursorParam("48213")).toBeUndefined();
|
||||||
|
expect(parseCursorParam(".912-48213")).toBeUndefined();
|
||||||
|
expect(parseCursorParam("48213.")).toBeUndefined();
|
||||||
|
expect(parseCursorParam(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("first load without cursors", () => {
|
||||||
|
it("sends no cursor or ID without a start", () => {
|
||||||
|
expect(startParams(undefined)).toEqual({});
|
||||||
|
expect(startParams("7")).toEqual({ afterCursor: "7" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts and lists nothing, but returns where the server is", async () => {
|
||||||
|
const { axios, calls } = fakeAxios((url) =>
|
||||||
|
url.includes("/api/partner/") ? partnerBody : endorserBody(3, "100"),
|
||||||
|
);
|
||||||
|
const counts = await countNewActivity(context(axios), undefined);
|
||||||
|
expect(calls.map((c) => c.body)).toEqual([{}, {}]);
|
||||||
|
expect(totalNewActivity(counts)).toEqual(0);
|
||||||
|
expect(counts.endorser.to).toEqual("100");
|
||||||
|
expect(counts.partner.to).toEqual("5-100");
|
||||||
|
const page = await loadBucket(context(axios), "claims", undefined);
|
||||||
|
expect(page.rows).toEqual([]);
|
||||||
|
expect(page.nextAfterCursor).toEqual("100");
|
||||||
|
expect(page.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds a visit at the latest cursors, which the next load counts from", async () => {
|
||||||
|
const { axios } = fakeAxios((url) =>
|
||||||
|
url.includes("/api/partner/") ? partnerBody : endorserBody(3, "100"),
|
||||||
|
);
|
||||||
|
const { state, seeded } = await seedIfNeeded(context(axios), {}, NOW);
|
||||||
|
expect(seeded).toBe(true);
|
||||||
|
expect(state.visit).toEqual(
|
||||||
|
seededState({}, { endorser: "100", partner: "5-100" }, NOW).visit,
|
||||||
|
);
|
||||||
|
expect(windowStart(state.visit, NOW)).toEqual({
|
||||||
|
endorser: "100",
|
||||||
|
partner: "5-100",
|
||||||
|
});
|
||||||
|
expect(unseenStart(state, "claims")).toEqual("100");
|
||||||
|
// a state with a visit is left alone
|
||||||
|
expect(await seedIfNeeded(context(axios), state, NOW)).toEqual({
|
||||||
|
state,
|
||||||
|
seeded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not seed when a server does not answer", async () => {
|
||||||
|
const { axios } = fakeAxios((url) => {
|
||||||
|
if (url.includes("/api/partner/")) throw new Error("partner down");
|
||||||
|
return endorserBody(0);
|
||||||
|
});
|
||||||
|
expect(await seedIfNeeded(context(axios), {}, NOW)).toEqual({
|
||||||
|
state: {},
|
||||||
|
seeded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ulidJustBefore", () => {
|
||||||
|
it("steps the last character back, borrowing across zeros", () => {
|
||||||
|
expect(ulidJustBefore("01J00000000000000000000003")).toEqual(
|
||||||
|
"01J00000000000000000000002",
|
||||||
|
);
|
||||||
|
expect(ulidJustBefore("01J00000000000000000000010")).toEqual(
|
||||||
|
"01J0000000000000000000000Z",
|
||||||
|
);
|
||||||
|
expect(ulidJustBefore("00000000000000000000000000")).toBeUndefined();
|
||||||
|
expect(ulidJustBefore("01J-bad")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectAlertSearchForward", () => {
|
||||||
|
const page = (
|
||||||
|
claims: string[],
|
||||||
|
nextAfterCursor: string,
|
||||||
|
hasMore: boolean,
|
||||||
|
) => ({
|
||||||
|
data: {
|
||||||
|
claims: claims.map((id) => ({ id, issuedAt: "", issuer: "x" })),
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
},
|
||||||
|
nextAfterCursor,
|
||||||
|
hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages from the cursor while hasMore and drops repeated rows", async () => {
|
||||||
|
const fetchPage = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(page(["a", "b"], "20", true))
|
||||||
|
.mockResolvedValueOnce(page(["b", "c"], "30", false));
|
||||||
|
const result = await collectAlertSearchForward(
|
||||||
|
fetchPage,
|
||||||
|
"endorser",
|
||||||
|
{ afterCursor: "10" },
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
{ afterCursor: "10" },
|
||||||
|
{ afterCursor: "20" },
|
||||||
|
]);
|
||||||
|
expect(result.data.claims.map((c) => c.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(result.nextAfterCursor).toEqual("30");
|
||||||
|
expect(result.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at maxPages and reports more to read", async () => {
|
||||||
|
const fetchPage = jest.fn().mockResolvedValue(page(["a"], "20", true));
|
||||||
|
fetchPage.mockResolvedValueOnce(page(["a"], "15", true));
|
||||||
|
const result = await collectAlertSearchForward(
|
||||||
|
fetchPage,
|
||||||
|
"endorser",
|
||||||
|
{ afterCursor: "10" },
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads one page without a cursor", async () => {
|
||||||
|
const fetchPage = jest.fn().mockResolvedValue(page(["a"], "20", true));
|
||||||
|
await collectAlertSearchForward(fetchPage, "endorser", {}, 4);
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges similar matches by the record inside them", () => {
|
||||||
|
const merged = mergeAlertSearchData(
|
||||||
|
{
|
||||||
|
profilesSimilar: [{ similarity: 0.9, profile: { rowId: 1 } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profilesSimilar: [
|
||||||
|
{ similarity: 0.8, profile: { rowId: 1 } },
|
||||||
|
{ similarity: 0.7, profile: { rowId: 2 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(merged.profilesSimilar).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeAxios(
|
||||||
|
handler: (url: string, body: Record<string, unknown>) => unknown,
|
||||||
|
): {
|
||||||
|
axios: Axios;
|
||||||
|
calls: Array<{ url: string; body: Record<string, unknown> }>;
|
||||||
|
} {
|
||||||
|
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||||
|
const axios = {
|
||||||
|
post: jest.fn(async (url: string, body: Record<string, unknown>) => {
|
||||||
|
calls.push({ url, body });
|
||||||
|
return { data: handler(url, body) };
|
||||||
|
}),
|
||||||
|
} as unknown as Axios;
|
||||||
|
return { axios, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(
|
||||||
|
axios: Axios,
|
||||||
|
overrides: Partial<NewActivityContext> = {},
|
||||||
|
): NewActivityContext {
|
||||||
|
return {
|
||||||
|
axios,
|
||||||
|
endorserServer: "https://api.example",
|
||||||
|
partnerServer: "https://partner.example",
|
||||||
|
activeDid: "did:ethr:0xme",
|
||||||
|
planHandleIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const endorserBody = (claims: number, nextAfterCursor = "100") => ({
|
||||||
|
data: {
|
||||||
|
claims: Array.from({ length: claims }, (_, i) => ({ id: "c" + i })),
|
||||||
|
personalPlanContributions: [{ id: "p1" }],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
},
|
||||||
|
nextAfterCursor,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
const partnerBody = {
|
||||||
|
data: {
|
||||||
|
profilesNearby: [],
|
||||||
|
profilesSimilar: [{ similarity: 0.9, profile: { rowId: 3 } }],
|
||||||
|
},
|
||||||
|
nextAfterCursor: "5-100",
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("countNewActivity", () => {
|
||||||
|
it("counts each available bucket, sends the inputs, and totals like the service", async () => {
|
||||||
|
const { axios, calls } = fakeAxios((url) =>
|
||||||
|
url.includes("/api/partner/") ? partnerBody : endorserBody(2),
|
||||||
|
);
|
||||||
|
const location = { minLocLat: 1, maxLocLat: 2, minLocLon: 3, maxLocLon: 4 };
|
||||||
|
const counts = await countNewActivity(
|
||||||
|
context(axios, { planHandleIds: ["h1"], location }),
|
||||||
|
{ endorser: "90", partner: "4-90" },
|
||||||
|
);
|
||||||
|
expect(counts.endorser.counts).toEqual({
|
||||||
|
claims: 2,
|
||||||
|
personalPlanContributions: 1,
|
||||||
|
trackedPlanUpdates: 0,
|
||||||
|
trackedPlanClaims: 0,
|
||||||
|
plansNearby: 0,
|
||||||
|
});
|
||||||
|
expect(counts.partner.counts).toEqual({
|
||||||
|
profilesNearby: 0,
|
||||||
|
profilesSimilar: 1,
|
||||||
|
plansSimilar: 0,
|
||||||
|
});
|
||||||
|
expect(totalNewActivity(counts)).toEqual(4);
|
||||||
|
const endorserCall = calls.find((c) => c.url.includes("/report/"));
|
||||||
|
expect(endorserCall?.body).toEqual({
|
||||||
|
location,
|
||||||
|
planHandleIds: ["h1"],
|
||||||
|
afterCursor: "90",
|
||||||
|
});
|
||||||
|
const partnerCall = calls.find((c) => c.url.includes("/api/partner/"));
|
||||||
|
expect(partnerCall?.body).toEqual({ location, afterCursor: "4-90" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out buckets whose inputs are missing, and keeps a failed server apart", async () => {
|
||||||
|
const { axios } = fakeAxios((url) => {
|
||||||
|
if (url.includes("/api/partner/")) throw new Error("partner down");
|
||||||
|
return endorserBody(1);
|
||||||
|
});
|
||||||
|
const counts = await countNewActivity(context(axios), {
|
||||||
|
endorser: "90",
|
||||||
|
partner: "4-90",
|
||||||
|
});
|
||||||
|
expect(counts.endorser.counts).toEqual({
|
||||||
|
claims: 1,
|
||||||
|
personalPlanContributions: 1,
|
||||||
|
});
|
||||||
|
expect(counts.partner.error).toEqual("partner down");
|
||||||
|
expect(counts.partner.counts).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Home counts", () => {
|
||||||
|
const visit = {
|
||||||
|
from: { endorser: "10", partner: "1-10" },
|
||||||
|
to: { endorser: "20", partner: "2-20" },
|
||||||
|
at: minutesAgo(5),
|
||||||
|
seenAt: minutesAgo(5),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("starts each bucket at the end of whichever load was seen last", () => {
|
||||||
|
const state = {
|
||||||
|
visit,
|
||||||
|
buckets: {
|
||||||
|
claims: {
|
||||||
|
from: "20",
|
||||||
|
to: "30",
|
||||||
|
at: minutesAgo(2),
|
||||||
|
seenAt: minutesAgo(2),
|
||||||
|
},
|
||||||
|
trackedPlanUpdates: {
|
||||||
|
to: "15",
|
||||||
|
at: minutesAgo(60),
|
||||||
|
seenAt: minutesAgo(60),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// opened after the summary: from the list's end, not the visit's start
|
||||||
|
expect(unseenStart(state, "claims")).toEqual("30");
|
||||||
|
// opened before the summary: from the summary's end
|
||||||
|
expect(unseenStart(state, "trackedPlanUpdates")).toEqual("20");
|
||||||
|
expect(unseenStart(state, "profilesNearby")).toEqual("2-20");
|
||||||
|
expect(unseenStart({}, "claims")).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
unseenStart(
|
||||||
|
{ buckets: { claims: { to: "7", at: minutesAgo(1) } } },
|
||||||
|
"claims",
|
||||||
|
),
|
||||||
|
).toEqual("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one read among buckets with the same start", async () => {
|
||||||
|
const { axios, calls } = fakeAxios((url) =>
|
||||||
|
url.includes("/api/partner/") ? partnerBody : endorserBody(2, "40"),
|
||||||
|
);
|
||||||
|
const counts = await countUnseenActivity(context(axios), {
|
||||||
|
visit,
|
||||||
|
buckets: {
|
||||||
|
claims: { to: "30", at: minutesAgo(2), seenAt: minutesAgo(2) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls.map((c) => c.body.afterCursor).sort()).toEqual([
|
||||||
|
"2-20",
|
||||||
|
"20",
|
||||||
|
"30",
|
||||||
|
]);
|
||||||
|
expect(counts.buckets.claims).toEqual({ count: 2, hitLimit: false });
|
||||||
|
expect(counts.buckets.personalPlanContributions).toEqual({
|
||||||
|
count: 1,
|
||||||
|
hitLimit: false,
|
||||||
|
});
|
||||||
|
expect(counts.buckets.profilesSimilar).toEqual({
|
||||||
|
count: 1,
|
||||||
|
hitLimit: false,
|
||||||
|
});
|
||||||
|
// inputs missing: not counted
|
||||||
|
expect(counts.buckets.trackedPlanUpdates).toBeUndefined();
|
||||||
|
expect(counts.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a failed server's buckets out and reports the error once", async () => {
|
||||||
|
const { axios } = fakeAxios((url) => {
|
||||||
|
if (url.includes("/api/partner/")) throw new Error("partner down");
|
||||||
|
return endorserBody(0);
|
||||||
|
});
|
||||||
|
const counts = await countUnseenActivity(context(axios), { visit });
|
||||||
|
expect(counts.buckets.profilesSimilar).toBeUndefined();
|
||||||
|
expect(counts.buckets.claims).toEqual({ count: 0, hitLimit: false });
|
||||||
|
expect(counts.errors).toEqual(["partner down"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summary counts", () => {
|
||||||
|
// the summary first loaded 5 minutes ago, counting from "10" seen an hour ago
|
||||||
|
const visit = {
|
||||||
|
from: { endorser: "10", partner: "1-10" },
|
||||||
|
fromSeenAt: minutesAgo(60),
|
||||||
|
to: { endorser: "20", partner: "2-20" },
|
||||||
|
at: minutesAgo(5),
|
||||||
|
seenAt: minutesAgo(5),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("counts an opened list from its end and the rest from the visit start", () => {
|
||||||
|
const state = {
|
||||||
|
visit,
|
||||||
|
buckets: {
|
||||||
|
// opened after the visit's start was seen: read up to its end
|
||||||
|
claims: {
|
||||||
|
from: "10",
|
||||||
|
to: "30",
|
||||||
|
at: minutesAgo(2),
|
||||||
|
seenAt: minutesAgo(2),
|
||||||
|
},
|
||||||
|
// opened between the start and this visit: still read
|
||||||
|
plansNearby: { to: "15", at: minutesAgo(20), seenAt: minutesAgo(20) },
|
||||||
|
// opened before the start: the visit start is later
|
||||||
|
trackedPlanUpdates: {
|
||||||
|
to: "5",
|
||||||
|
at: minutesAgo(90),
|
||||||
|
seenAt: minutesAgo(90),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(summaryStart(state, "claims", NOW)).toEqual("30");
|
||||||
|
expect(summaryStart(state, "plansNearby", NOW)).toEqual("15");
|
||||||
|
expect(summaryStart(state, "trackedPlanUpdates", NOW)).toEqual("10");
|
||||||
|
expect(summaryStart(state, "profilesNearby", NOW)).toEqual("1-10");
|
||||||
|
// after the visit, everything counts from its end unless opened since
|
||||||
|
const later = new Date(NOW.getTime() + 40 * 60 * 1000);
|
||||||
|
expect(summaryStart(state, "claims", later)).toEqual("30");
|
||||||
|
expect(summaryStart(state, "plansNearby", later)).toEqual("20");
|
||||||
|
// a notification link stands in for a visit without a start
|
||||||
|
expect(
|
||||||
|
summaryStart({}, "claims", NOW, { endorser: "8", partner: "1-8" }),
|
||||||
|
).toEqual("8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an opened list's count and ends the visit at the visit-start read", async () => {
|
||||||
|
const { axios, calls } = fakeAxios((url, body) => {
|
||||||
|
if (url.includes("/api/partner/")) return partnerBody;
|
||||||
|
// nothing new after the list's end; the visit-start read ends at "40"
|
||||||
|
return body.afterCursor === "30"
|
||||||
|
? { data: {}, nextAfterCursor: "41", hasMore: false }
|
||||||
|
: endorserBody(2, "40");
|
||||||
|
});
|
||||||
|
const counts = await countSummaryActivity(
|
||||||
|
context(axios),
|
||||||
|
{
|
||||||
|
visit,
|
||||||
|
buckets: {
|
||||||
|
claims: { to: "30", at: minutesAgo(2), seenAt: minutesAgo(2) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(calls.map((c) => c.body.afterCursor).sort()).toEqual([
|
||||||
|
"1-10",
|
||||||
|
"10",
|
||||||
|
"30",
|
||||||
|
]);
|
||||||
|
expect(counts.endorser.counts).toEqual({
|
||||||
|
claims: 0,
|
||||||
|
personalPlanContributions: 1,
|
||||||
|
});
|
||||||
|
expect(counts.endorser.to).toEqual("40");
|
||||||
|
expect(counts.endorser.bucketHitLimit).toEqual({
|
||||||
|
claims: false,
|
||||||
|
personalPlanContributions: false,
|
||||||
|
});
|
||||||
|
expect(counts.partner.counts.profilesSimilar).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("latest cursors for a first upload", () => {
|
||||||
|
it("keeps the cursor of the server that answered", async () => {
|
||||||
|
const { axios } = fakeAxios((url) => {
|
||||||
|
if (url.includes("/api/partner/")) throw new Error("partner down");
|
||||||
|
return endorserBody(0, "77");
|
||||||
|
});
|
||||||
|
const latest = await latestCursorPair(context(axios));
|
||||||
|
expect(latest).toEqual({ endorser: "77" });
|
||||||
|
expect(alertSearchUploadState({ planHandleIds: [] }, latest)).toEqual({
|
||||||
|
endorserAfterCursor: "77",
|
||||||
|
planHandleIds: null,
|
||||||
|
location: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadBucketEarlier", () => {
|
||||||
|
it("steps back past pages that hold none of the bucket", async () => {
|
||||||
|
const pages = [
|
||||||
|
{ claims: [], nextBeforeCursor: "80", hasMore: true },
|
||||||
|
{ claims: [{ id: "old" }], nextBeforeCursor: "60", hasMore: true },
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const { axios, calls } = fakeAxios(() => {
|
||||||
|
const p = pages[index++];
|
||||||
|
return {
|
||||||
|
data: { ...endorserBody(0).data, claims: p.claims },
|
||||||
|
nextBeforeCursor: p.nextBeforeCursor,
|
||||||
|
hasMore: p.hasMore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const page = await loadBucketEarlier(context(axios), "claims", "100");
|
||||||
|
expect(calls.map((c) => c.body.beforeCursor)).toEqual(["100", "80"]);
|
||||||
|
expect(page.rows).toEqual([{ id: "old" }]);
|
||||||
|
expect(page.nextBeforeCursor).toEqual("60");
|
||||||
|
expect(page.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notification refresh", () => {
|
||||||
|
const cursors = { endorser: "100", partner: "5-100" };
|
||||||
|
|
||||||
|
it("knows which channels are on", () => {
|
||||||
|
expect(
|
||||||
|
enabledNotificationChannels({
|
||||||
|
notifyingNewActivityTime: "9:00 AM",
|
||||||
|
notifyingNewActivitySmsTime: "9:00 AM",
|
||||||
|
smsNotificationsEnabled: false,
|
||||||
|
}),
|
||||||
|
).toEqual(["fcm"]);
|
||||||
|
expect(
|
||||||
|
enabledNotificationChannels({
|
||||||
|
notifyingNewActivitySmsTime: "9:00 AM",
|
||||||
|
smsNotificationsEnabled: true,
|
||||||
|
}),
|
||||||
|
).toEqual(["sms"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads each channel with the cursors and reports the ones that took", async () => {
|
||||||
|
const state = alertSearchUploadState(
|
||||||
|
{ planHandleIds: ["h1"], location: undefined },
|
||||||
|
cursors,
|
||||||
|
);
|
||||||
|
expect(state).toEqual({
|
||||||
|
endorserAfterCursor: "100",
|
||||||
|
partnerAfterCursor: "5-100",
|
||||||
|
planHandleIds: ["h1"],
|
||||||
|
location: null,
|
||||||
|
});
|
||||||
|
const uploadFcm = jest.fn().mockResolvedValue(false);
|
||||||
|
const uploadSms = jest.fn().mockResolvedValue(true);
|
||||||
|
const uploaded = await refreshNotificationAuthorizations(
|
||||||
|
"did:ethr:0xme",
|
||||||
|
{ notifyingNewActivitySmsTime: "9:00 AM" },
|
||||||
|
["fcm", "sms"],
|
||||||
|
cursors,
|
||||||
|
state,
|
||||||
|
{ uploadFcm, uploadSms },
|
||||||
|
);
|
||||||
|
expect(uploadFcm).toHaveBeenCalledWith(state);
|
||||||
|
expect(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state);
|
||||||
|
expect(uploaded).toEqual({ sms: cursors });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips texts with an unreadable time and survives a thrown upload", async () => {
|
||||||
|
const uploaded = await refreshNotificationAuthorizations(
|
||||||
|
"did:ethr:0xme",
|
||||||
|
{ notifyingNewActivitySmsTime: "whenever" },
|
||||||
|
["fcm", "sms"],
|
||||||
|
cursors,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
uploadFcm: jest.fn().mockRejectedValue(new Error("offline")),
|
||||||
|
uploadSms: jest.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(uploaded).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-2
@@ -219,9 +219,10 @@ export const logger = {
|
|||||||
console.warn(message, ...args);
|
console.warn(message, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database logging
|
// Database logging, plus memory so problems can be shown even when the database is unusable
|
||||||
const argsString =
|
const argsString =
|
||||||
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
||||||
|
appendToMemoryLogs(`[WARN] ${message}${argsString}`);
|
||||||
logToDatabase(message + argsString, "warn");
|
logToDatabase(message + argsString, "warn");
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -231,9 +232,10 @@ export const logger = {
|
|||||||
console.error(message, ...args);
|
console.error(message, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database logging
|
// Database logging, plus memory so problems can be shown even when the database is unusable
|
||||||
const argsString =
|
const argsString =
|
||||||
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
||||||
|
appendToMemoryLogs(`[ERROR] ${message}${argsString}`);
|
||||||
logToDatabase(message + argsString, "error");
|
logToDatabase(message + argsString, "error");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Hand-off of a fatal startup error (usually a database that will not open)
|
||||||
|
* from wherever it is caught to StartView, which shows recovery options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "startupError";
|
||||||
|
|
||||||
|
export interface StartupError {
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the errors thrown when the platform database cannot be opened. */
|
||||||
|
export function isDatabaseStartupError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes("Failed to initialize database") ||
|
||||||
|
message.includes("The database could not be initialized") ||
|
||||||
|
message.includes("The database is being reset")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordStartupError(error: unknown): void {
|
||||||
|
const value: StartupError = {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack || "" : "",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the recorded error, if any, and clears it. */
|
||||||
|
export function takeStartupError(): StartupError | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { message: parsed.message || raw, stack: parsed.stack || "" };
|
||||||
|
} catch {
|
||||||
|
return { message: raw, stack: "" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
color="white"
|
color="white"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></font-awesome>
|
></font-awesome>
|
||||||
<span class="text-white">Unsavedaroonosity</span>
|
<span class="text-white">Unsaved</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="mt-2" role="group" aria-label="Quick server selection">
|
<div class="mt-2" role="group" aria-label="Quick server selection">
|
||||||
<button
|
<button
|
||||||
@@ -1086,6 +1086,19 @@ import {
|
|||||||
UnsupportedIdentityError,
|
UnsupportedIdentityError,
|
||||||
} from "@/services/notifications";
|
} from "@/services/notifications";
|
||||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||||
|
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||||
|
import {
|
||||||
|
type CursorPair,
|
||||||
|
NotificationChannel,
|
||||||
|
parseNewActivityCursorState,
|
||||||
|
seededState,
|
||||||
|
serializeNewActivityCursorState,
|
||||||
|
} from "@/libs/newActivity";
|
||||||
|
import {
|
||||||
|
alertSearchUploadState,
|
||||||
|
latestCursorPair,
|
||||||
|
} from "@/services/newActivity";
|
||||||
|
import { buildNewActivityContext } from "@/services/newActivityContext";
|
||||||
// Profile data interface (inlined from ProfileService)
|
// Profile data interface (inlined from ProfileService)
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -1576,9 +1589,14 @@ export default class AccountViewView extends Vue {
|
|||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
await this.scheduleNewActivityDualNotification(timeText);
|
await this.scheduleNewActivityDualNotification(timeText);
|
||||||
}
|
}
|
||||||
await authorizeInAppNewActivity((message) => {
|
const searchState = await this.newActivityStartState("fcm");
|
||||||
this.reportAlertSearchAuthorizationFailure("upload", message);
|
await authorizeInAppNewActivity(
|
||||||
});
|
(message) => {
|
||||||
|
this.reportAlertSearchAuthorizationFailure("upload", message);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
searchState,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1854,7 +1872,7 @@ export default class AccountViewView extends Vue {
|
|||||||
DAILY_CHECK_TITLE,
|
DAILY_CHECK_TITLE,
|
||||||
async (success: boolean, timeText: string) => {
|
async (success: boolean, timeText: string) => {
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
const authorized = await this.authorizeSmsAlerts(timeText);
|
const authorized = await this.authorizeSmsAlerts(timeText, false);
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1893,6 +1911,48 @@ export default class AccountViewView extends Vue {
|
|||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a notification upload sends when the channel is turned on: the
|
||||||
|
* latest alertSearch cursors, so the first daily count leaves out everything
|
||||||
|
* before now, plus the starred projects and search area to search. The
|
||||||
|
* cursors are recorded as uploaded, and as the New Activity visit when the
|
||||||
|
* person has none yet. A server that does not answer gets the end of the
|
||||||
|
* last visit instead; with neither, that server's cursor is left out and
|
||||||
|
* the service's first run seeds its own.
|
||||||
|
*/
|
||||||
|
private async newActivityStartState(
|
||||||
|
channel: NotificationChannel,
|
||||||
|
): Promise<AlertSearchUploadState> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const context = buildNewActivityContext(
|
||||||
|
this.axios,
|
||||||
|
this.activeDid,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
const state = parseNewActivityCursorState(settings.newActivityCursors);
|
||||||
|
const latest = await latestCursorPair(context);
|
||||||
|
const start: Partial<CursorPair> = {
|
||||||
|
endorser: latest.endorser ?? state.visit?.to.endorser,
|
||||||
|
partner: latest.partner ?? state.visit?.to.partner,
|
||||||
|
};
|
||||||
|
if (start.endorser && start.partner) {
|
||||||
|
const pair: CursorPair = {
|
||||||
|
endorser: start.endorser,
|
||||||
|
partner: start.partner,
|
||||||
|
};
|
||||||
|
const withVisit = state.visit
|
||||||
|
? state
|
||||||
|
: seededState(state, pair, new Date());
|
||||||
|
await this.$saveUserSettings(this.activeDid, {
|
||||||
|
newActivityCursors: serializeNewActivityCursorState({
|
||||||
|
...withVisit,
|
||||||
|
uploaded: { ...state.uploaded, [channel]: pair },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return alertSearchUploadState(context, start);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mint a delegated JWT batch and upload it with the chosen hour, converted
|
* Mint a delegated JWT batch and upload it with the chosen hour, converted
|
||||||
* to the UTC pair the notify-api schedules on.
|
* to the UTC pair the notify-api schedules on.
|
||||||
@@ -1901,9 +1961,15 @@ export default class AccountViewView extends Vue {
|
|||||||
* until the service has the batch.
|
* until the service has the batch.
|
||||||
*
|
*
|
||||||
* @param timeText 12-hour local reading from the time picker
|
* @param timeText 12-hour local reading from the time picker
|
||||||
|
* @param startFromNow true when turning texts on: the first count starts
|
||||||
|
* now. False for a new time, which keeps the service's cursors so nothing
|
||||||
|
* waiting for the next text is dropped.
|
||||||
* @returns true when the service accepted the authorization
|
* @returns true when the service accepted the authorization
|
||||||
*/
|
*/
|
||||||
private async authorizeSmsAlerts(timeText: string): Promise<boolean> {
|
private async authorizeSmsAlerts(
|
||||||
|
timeText: string,
|
||||||
|
startFromNow = true,
|
||||||
|
): Promise<boolean> {
|
||||||
const utcTime = localTimeTextToUtc(timeText);
|
const utcTime = localTimeTextToUtc(timeText);
|
||||||
if (!utcTime) {
|
if (!utcTime) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1923,6 +1989,16 @@ export default class AccountViewView extends Vue {
|
|||||||
this.activeDid,
|
this.activeDid,
|
||||||
utcTime.notifyHourUtc,
|
utcTime.notifyHourUtc,
|
||||||
utcTime.notifyMinuteUtc,
|
utcTime.notifyMinuteUtc,
|
||||||
|
startFromNow
|
||||||
|
? await this.newActivityStartState("sms")
|
||||||
|
: alertSearchUploadState(
|
||||||
|
buildNewActivityContext(
|
||||||
|
this.axios,
|
||||||
|
this.activeDid,
|
||||||
|
await this.$accountSettings(),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -27,8 +27,24 @@
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<textarea v-model="claimStr" rows="20" class="border-2 w-full"></textarea>
|
<textarea v-model="claimStr" rows="20" class="border-2 w-full"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hasHiddenDid"
|
||||||
|
class="my-4 text-sm bg-amber-100 rounded px-3 py-2"
|
||||||
|
data-testId="hiddenDidWarning"
|
||||||
|
>
|
||||||
|
This claim contains identifiers that are hidden from you, shown as "{{
|
||||||
|
HIDDEN_DID
|
||||||
|
}}". Remove or replace each one before you send it.
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
class="block w-full text-center text-lg font-bold uppercase px-2 py-3 rounded-md"
|
||||||
|
:class="
|
||||||
|
hasHiddenDid
|
||||||
|
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
|
||||||
|
: // eslint-disable-next-line max-len
|
||||||
|
'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white'
|
||||||
|
"
|
||||||
|
:disabled="hasHiddenDid"
|
||||||
@click="submitClaim()"
|
@click="submitClaim()"
|
||||||
>
|
>
|
||||||
Sign & Send
|
Sign & Send
|
||||||
@@ -100,6 +116,15 @@ export default class ClaimAddRawView extends Vue {
|
|||||||
apiServer = "";
|
apiServer = "";
|
||||||
claimStr = "";
|
claimStr = "";
|
||||||
|
|
||||||
|
HIDDEN_DID = serverUtil.HIDDEN_DID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the claim text still has a placeholder for a DID hidden from this user
|
||||||
|
*/
|
||||||
|
get hasHiddenDid(): boolean {
|
||||||
|
return this.claimStr.includes(serverUtil.HIDDEN_DID);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook that initializes the view
|
* Lifecycle hook that initializes the view
|
||||||
* Workflow:
|
* Workflow:
|
||||||
@@ -228,6 +253,13 @@ export default class ClaimAddRawView extends Vue {
|
|||||||
* @throws Will show error notification if submission fails
|
* @throws Will show error notification if submission fails
|
||||||
*/
|
*/
|
||||||
async submitClaim() {
|
async submitClaim() {
|
||||||
|
if (this.hasHiddenDid) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Remove or replace the hidden identifiers before you send it.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fullClaim = JSON.parse(this.claimStr);
|
const fullClaim = JSON.parse(this.claimStr);
|
||||||
const result = await serverUtil.createAndSubmitClaim(
|
const result = await serverUtil.createAndSubmitClaim(
|
||||||
fullClaim,
|
fullClaim,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user