Merge branch 'master' into api-notify-deep-link
This commit is contained in:
@@ -5,8 +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.`
|
VITE_APP_SERVER=https://test.timesafari.app
|
||||||
# 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).
|
||||||
|
|
||||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
|
|||||||
## Android Build — MLKit Barcode Scanner
|
## Android Build — MLKit Barcode Scanner
|
||||||
|
|
||||||
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
|
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
|
||||||
|
|
||||||
|
## Android Build — Capacitor Geolocation
|
||||||
|
|
||||||
|
`@capacitor/geolocation` depends on `com.google.android.gms:play-services-location`, an accepted second GMS dependency. On GMS-less devices "Use my location" fails and the interests prompt falls back to picking a point on a map. See `doc/android-firebase-gms.md`.
|
||||||
|
|||||||
+3
-3
@@ -1154,7 +1154,7 @@ 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 76 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.10;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 77 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.11;/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
|
||||||
```
|
```
|
||||||
@@ -1446,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 76/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 77/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.4.10"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.4.11"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ 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.11] - 2026.11.01
|
||||||
|
### Added
|
||||||
|
- The first visit to the feed asks for interests (and an optional location) instead of showing the three welcome pages, then shows the people and projects closest in meaning, nearest first.
|
||||||
|
- After the matches, a registered person sees a red dot on the profile tab, a one-time banner, and a card on the Profile page to save the interests as their public profile; on Android and iOS the card also offers a text-message or in-app alert.
|
||||||
|
### Changed
|
||||||
|
- The three welcome pages open only from Help.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.10] - 2026.10.17
|
## [1.4.10] - 2026.10.17
|
||||||
### Changed
|
### Changed
|
||||||
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
|
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TimeSafari.app - Crowd-Funder for Time - PWA
|
# Giftopa.me AKA TimeSafari.app - Crowd-Funder for Time - PWA
|
||||||
|
|
||||||
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
|
[Giftopia](https://giftopia.tech/) allows people to ease into collaboration: start with expressions of gratitude
|
||||||
and expand to crowd-fund with time & money, then record and see the impact of contributions.
|
and expand to crowd-fund with time & money, then record and see the impact of contributions.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -408,6 +408,6 @@ Gifts make the world go 'round!
|
|||||||
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
|
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
|
||||||
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
|
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
|
||||||
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
|
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
|
||||||
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
|
* TimeSafari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
|
||||||
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
|
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
|
||||||
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
|
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
|
||||||
|
|||||||
@@ -39,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 76
|
versionCode 77
|
||||||
versionName "1.4.10"
|
versionName "1.4.11"
|
||||||
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.
|
||||||
@@ -64,8 +64,13 @@ android {
|
|||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
// R8 shrinking/optimization: Play Vitals "code optimization" (DEX) score.
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
// Plugin keep rules come from each library's consumer rules (Capacitor
|
||||||
|
// core keeps every com.getcapacitor.Plugin subclass); app rules are in
|
||||||
|
// proguard-rules.pro.
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
// Only sign if we have the signing config
|
// Only sign if we have the signing config
|
||||||
if (signingConfigs.release.storeFile != null) {
|
if (signingConfigs.release.storeFile != null) {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-camera')
|
implementation project(':capacitor-camera')
|
||||||
implementation project(':capacitor-clipboard')
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-filesystem')
|
implementation project(':capacitor-filesystem')
|
||||||
|
implementation project(':capacitor-geolocation')
|
||||||
implementation project(':capacitor-preferences')
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
implementation project(':capacitor-push-notifications')
|
||||||
implementation project(':capacitor-share')
|
implementation project(':capacitor-share')
|
||||||
|
|||||||
Vendored
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
# Uncomment this to preserve the line number information for
|
||||||
# debugging stack traces.
|
# debugging stack traces.
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
-renamesourcefileattribute SourceFile
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
|
|
||||||
<!-- Daily Notification Plugin Permissions -->
|
<!-- Daily Notification Plugin Permissions -->
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"pkg": "@capacitor/filesystem",
|
"pkg": "@capacitor/filesystem",
|
||||||
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/geolocation",
|
||||||
|
"classpath": "com.capacitorjs.plugins.geolocation.GeolocationPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/preferences",
|
"pkg": "@capacitor/preferences",
|
||||||
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
|
|||||||
include ':capacitor-filesystem'
|
include ':capacitor-filesystem'
|
||||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
|
include ':capacitor-geolocation'
|
||||||
|
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
|
||||||
|
|
||||||
include ':capacitor-preferences'
|
include ':capacitor-preferences'
|
||||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Plan: On-Device New Activity Notifications Through alertSearch
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A person who picks on-device delivery for in-app New Activity notifications
|
||||||
|
(`newActivityDelivery: "device"`) gets the same count the server push would
|
||||||
|
send, at the same time, computed on the phone:
|
||||||
|
|
||||||
|
1. The device runs the alertSearch query against both servers
|
||||||
|
(`/api/v2/report/alertSearch` on Endorser, `/api/partner/alertSearch` on
|
||||||
|
Partner) with the person's starred projects and search area.
|
||||||
|
2. It counts from the same start as the New Activity screen, and it moves that
|
||||||
|
start forward the way the notification service does, so a notification
|
||||||
|
never repeats what the person already saw and never skips what they have
|
||||||
|
not.
|
||||||
|
3. The notification reads "You have N new updates." and opens New Activity
|
||||||
|
with the run's start cursors as `c`, exactly like a server push.
|
||||||
|
4. The notification service holds nothing for this person.
|
||||||
|
5. A change to those APIs, to what counts, or to the notification text is a
|
||||||
|
change to configuration that the app's TypeScript sends. It needs no
|
||||||
|
plugin release and no Java or Swift change.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- `TimeSafariNativeFetcher` (`android/app/src/main/java/app/timesafari/`,
|
||||||
|
`ios/App/App/`) is app code, not plugin code. The daily-notification-plugin
|
||||||
|
calls it five minutes before the notify time (`buildDualScheduleConfig`,
|
||||||
|
chained dual in plugin 3.x), and what it returns becomes the notification.
|
||||||
|
- The fetcher posts to Endorser `plansLastUpdatedBetween` with the starred plan
|
||||||
|
IDs, so it reports starred-project changes only: roughly the server's
|
||||||
|
`trackedPlanUpdates` list, one of eight.
|
||||||
|
- Its inputs come from `configureNativeFetcher` (API base URL, active DID, a
|
||||||
|
pool of day-scoped JWTs) and `updateStarredPlans` (plan IDs in plugin
|
||||||
|
storage: SharedPreferences on Android, UserDefaults on iOS).
|
||||||
|
- Every change to what the fetcher asks or counts is a Java and a Swift change.
|
||||||
|
- The read state lives in the app's SQLite settings (`newActivityCursors`,
|
||||||
|
`src/libs/newActivity.ts`), which native code does not read.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The plugin becomes a scheduled request runner that knows nothing about
|
||||||
|
alertSearch. It does four things: run a job on its schedule, hold the JWTs
|
||||||
|
that authorize the job's requests, execute the requests a job describes, and
|
||||||
|
keep the raw results for the app to read. `TimeSafariNativeFetcher` is retired.
|
||||||
|
|
||||||
|
A notification has to be decided when the app is not running, so no
|
||||||
|
TypeScript runs at that moment. The plugin therefore interprets responses, but
|
||||||
|
only through a small, fixed vocabulary that the job supplies as data: where
|
||||||
|
the cursor is, whether there is another page, what to count, and what to say.
|
||||||
|
|
||||||
|
### The job
|
||||||
|
|
||||||
|
JS registers a job with one call (`configureRequestJob`), and replaces it the
|
||||||
|
same way. Shape, with the alertSearch values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "newActivity",
|
||||||
|
"schedule": { "notifyCron": "0 9 * * *", "fetchLeadMinutes": 5 },
|
||||||
|
"allowedHosts": ["api.endorser.ch", "partner-api.endorser.ch"],
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "endorser",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.endorser.ch/api/v2/report/alertSearch",
|
||||||
|
"body": { "planHandleIds": ["…"], "location": { "minLocLat": 40 } },
|
||||||
|
"auth": { "jwtPool": "endorser" },
|
||||||
|
"cursor": {
|
||||||
|
"value": "48213",
|
||||||
|
"requestField": "afterCursor",
|
||||||
|
"responseField": "nextAfterCursor"
|
||||||
|
},
|
||||||
|
"paging": { "hasMoreField": "hasMore", "maxPages": 20 },
|
||||||
|
"count": [
|
||||||
|
{ "path": "data.claims" },
|
||||||
|
{ "path": "data.personalPlanContributions" },
|
||||||
|
{ "path": "data.trackedPlanUpdates" },
|
||||||
|
{ "path": "data.trackedPlanClaims" },
|
||||||
|
{ "path": "data.plansNearby" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "partner",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://partner-api.endorser.ch/api/partner/alertSearch",
|
||||||
|
"body": { "location": { "minLocLat": 40 } },
|
||||||
|
"auth": { "jwtPool": "endorser" },
|
||||||
|
"cursor": {
|
||||||
|
"value": "912-48213",
|
||||||
|
"requestField": "afterCursor",
|
||||||
|
"responseField": "nextAfterCursor"
|
||||||
|
},
|
||||||
|
"paging": { "hasMoreField": "hasMore", "maxPages": 20 },
|
||||||
|
"count": [
|
||||||
|
{ "path": "data.profilesNearby" },
|
||||||
|
{ "path": "data.profilesSimilar", "distinctBy": "rowId" },
|
||||||
|
{ "path": "data.plansSimilar", "distinctBy": "handleId" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commit": "allComplete",
|
||||||
|
"notification": {
|
||||||
|
"title": "TimeSafari",
|
||||||
|
"body": { "one": "You have 1 new update.", "other": "You have {count} new updates." },
|
||||||
|
"skipWhenZero": true,
|
||||||
|
"deepLink": "https://giftopia.me/deep-link/new-activity?c={start.endorser}.{start.partner}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What the plugin does with it on each scheduled run:
|
||||||
|
|
||||||
|
- **Requests.** It runs each request from its stored cursor, putting the
|
||||||
|
cursor in the body under `requestField`, and pages while the response's
|
||||||
|
`hasMoreField` is true, up to `maxPages`, passing each page's
|
||||||
|
`responseField` as the next cursor.
|
||||||
|
- **Seeding.** A request with no cursor value makes one call, keeps only the
|
||||||
|
returned cursor, and counts nothing.
|
||||||
|
- **Counting.** The count is the number of array items at each `count` path
|
||||||
|
across all pages, distinct by `distinctBy` where given, summed over requests.
|
||||||
|
- **Commit.** With `allComplete`, the cursors advance only when every request
|
||||||
|
completed. Otherwise they all stay, and nothing is shown, so a source that
|
||||||
|
times out skips the day and the next run covers the whole window.
|
||||||
|
- **Notification.** It renders the `body` template with the count and the
|
||||||
|
run's start cursors, and shows nothing for zero when `skipWhenZero` is set.
|
||||||
|
- **Hosts.** It sends nothing to a host outside `allowedHosts`, so stored JWTs
|
||||||
|
cannot be pointed elsewhere.
|
||||||
|
- **Versions.** It refuses a job whose `schemaVersion` it does not know. Only a
|
||||||
|
new word in the vocabulary, such as a new way to count, needs a plugin
|
||||||
|
release.
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
The plugin holds JWT pools by name (`jwtPool`), each a set of day-scoped
|
||||||
|
delegated JWTs (`BACKGROUND_JWT_SECONDS_PER_DAY`,
|
||||||
|
the pool `configureNativeFetcher` passes), and picks one valid at request time.
|
||||||
|
JS refills a pool whenever it configures the job. The pools live in the
|
||||||
|
platform's protected store (Android Keystore-backed storage, iOS Keychain),
|
||||||
|
not in SharedPreferences or UserDefaults.
|
||||||
|
|
||||||
|
### Results
|
||||||
|
|
||||||
|
The plugin keeps the last runs of each job, pruned to a few:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "newActivity",
|
||||||
|
"ranAt": "2026-10-26T15:00:12Z",
|
||||||
|
"completed": true,
|
||||||
|
"notified": true,
|
||||||
|
"count": 4,
|
||||||
|
"start": { "endorser": "48213", "partner": "912-48213" },
|
||||||
|
"next": { "endorser": "48301", "partner": "930-48301" },
|
||||||
|
"responses": { "endorser": [{ "…": "raw page JSON" }], "partner": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
JS reads them with `getRequestJobResults` and interprets them with the code it
|
||||||
|
already has (`src/libs/alertSearch.ts`, `src/services/newActivity.ts`). A shape
|
||||||
|
change on a server is then handled in TypeScript alone. The raw pages also let
|
||||||
|
New Activity show what the last run found without asking the servers again.
|
||||||
|
|
||||||
|
### State handoff
|
||||||
|
|
||||||
|
JS stays the owner of the read state:
|
||||||
|
|
||||||
|
- It sends the cursors in the job at the points where the push channel
|
||||||
|
uploads: turning on (latest cursors, `newActivityStartState`), a new time
|
||||||
|
(cursors unchanged), and the re-upload after a New Activity visit
|
||||||
|
(`refreshNotificationAuthorizations`, where `"device"` becomes a destination
|
||||||
|
next to `fcm` and `sms`).
|
||||||
|
- It records the cursors it sent as `uploaded.device` in
|
||||||
|
`NewActivityCursorState`, so the visit logic that skips an unchanged channel
|
||||||
|
works unchanged.
|
||||||
|
- When New Activity loads, it reads the latest result's `next` cursors, the
|
||||||
|
same way it treats the service as having moved on after a run.
|
||||||
|
|
||||||
|
### One count definition
|
||||||
|
|
||||||
|
The `count` paths and `distinctBy` keys in the job are the device's definition
|
||||||
|
of the count. They come from the same TypeScript the app uses for its own
|
||||||
|
counts (`NEW_ACTIVITY_BUCKETS`, the Partner deduplication by `rowId` and
|
||||||
|
`handleId`), so the device and the screen cannot disagree. A Jest test builds
|
||||||
|
the job and checks it against the notification service's
|
||||||
|
`ALERT_SEARCH_DIGEST_BUCKETS` fixtures. The count logic exists only in
|
||||||
|
TypeScript, not in Java or Swift.
|
||||||
|
|
||||||
|
### Other notes
|
||||||
|
|
||||||
|
- A source that times out should skip the day.
|
||||||
|
|
||||||
|
- The plugin keeps no daily cap: its schedule fires once a day, as the
|
||||||
|
service's stored hour does on its side. A second notification the same day
|
||||||
|
takes a new time later that day, which is the person's own deliberate
|
||||||
|
reset, with no server or messaging cost for a cap to guard.
|
||||||
|
|
||||||
|
- The Daily Reminder keeps its own schedule and is untouched.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
### daily-notification-plugin
|
||||||
|
|
||||||
|
- [ ] `configureRequestJob` / `cancelRequestJob` (Android and iOS): store the
|
||||||
|
job, schedule the fetch at `fetchLeadMinutes` before `notifyCron`.
|
||||||
|
- [ ] Request runner: cursor in body, paging, seeding, `allComplete` commit,
|
||||||
|
host allowlist, `schemaVersion` check.
|
||||||
|
- [ ] Counting by path with `distinctBy`; notification template with `one` /
|
||||||
|
`other`, `{count}`, `{start.<id>}`; `skipWhenZero`; deep link.
|
||||||
|
- [ ] Named JWT pools in protected storage; pick a JWT valid at request time.
|
||||||
|
- [ ] `getRequestJobResults`, keeping and pruning the last runs.
|
||||||
|
- [ ] Plugin tests: a job run against a stub HTTP server, on both platforms.
|
||||||
|
- [ ] Keep `configureNativeFetcher` / `updateStarredPlans` until this app no
|
||||||
|
longer calls them, then remove them.
|
||||||
|
- [ ] Release, then bump the dependency here.
|
||||||
|
|
||||||
|
### This app, TypeScript
|
||||||
|
|
||||||
|
- [ ] Build the job from settings: server URLs, starred IDs, search area,
|
||||||
|
cursors, bucket paths, time, text.
|
||||||
|
- [ ] Configure it on turn-on, a new time, and the visit refresh in device
|
||||||
|
mode; refill the JWT pool each time; record `uploaded.device`.
|
||||||
|
- [ ] Read results on New Activity load and take their `next` cursors.
|
||||||
|
- [ ] Switch device mode from `scheduleDualNotification` to the job, and
|
||||||
|
cancel it on turn-off and on a switch to server mode.
|
||||||
|
- [ ] Drop "For now it checks only your starred projects." from the delivery
|
||||||
|
choice in `AccountViewView.vue`.
|
||||||
|
- [ ] Jest tests: the job's count definition against the service fixtures, and
|
||||||
|
results read back into `NewActivityCursorState`.
|
||||||
|
|
||||||
|
### This app, native
|
||||||
|
|
||||||
|
- [ ] Remove `TimeSafariNativeFetcher.java` and `.swift`, and their
|
||||||
|
registration in `MainActivity` and `AppDelegate`, once device mode runs
|
||||||
|
on the job.
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
# Plan: Onboarding by Interests
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A person who opens the app to the Home feed for the first time (phone, web,
|
||||||
|
or desktop) is asked one thing before anything else: what they are interested in, in their own words,
|
||||||
|
with an optional "use my location". The app then shows them the people and
|
||||||
|
projects whose descriptions are closest in meaning to what they typed, and
|
||||||
|
closest in distance when they gave a location. After they have seen those
|
||||||
|
results, and only once they are registered, a red dot on the profile tab (and
|
||||||
|
a one-time banner) points them to the follow-up: save the interests and
|
||||||
|
location to their public profile, and, on the phone apps, turn on a daily
|
||||||
|
alert, text message first, so they hear when someone with matching interests
|
||||||
|
joins or posts. Web and desktop have no New Activity alerts, so there the
|
||||||
|
follow-up is the profile save alone.
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
1. The interests prompt replaces the three-page welcome dialog on every
|
||||||
|
platform.
|
||||||
|
- The Help page has a link to enter interests again, which opens the
|
||||||
|
interests prompt. It sits above the link to the welcome pages.
|
||||||
|
- The welcome pages open only from that Help link; they are not part of
|
||||||
|
onboarding.
|
||||||
|
2. The interests and location are saved on the phone as soon as the person
|
||||||
|
enters them, registered or not, so they can be put in a public profile
|
||||||
|
whenever the person chooses, including long after onboarding.
|
||||||
|
3. A results screen for interest matches, reachable again later from Discover.
|
||||||
|
4. A "next step" nudge that does not block the screen: a red dot on the
|
||||||
|
profile tab, a banner that animates in once, and a card at the top of the
|
||||||
|
Profile page that holds the follow-up actions: the profile save, and on
|
||||||
|
the phone apps the alert. It appears only for registered identities,
|
||||||
|
since every action needs registration.
|
||||||
|
5. The follow-up actions reuse what the Profile page already does: save a
|
||||||
|
profile with description and location, verify a phone and authorize SMS
|
||||||
|
New Activity alerts, or authorize in-app New Activity alerts.
|
||||||
|
|
||||||
|
Out of scope: alerts on web and desktop; on-device embedding; changes to what the alerts contain;
|
||||||
|
registration changes beyond text search on `/similar` (profile save and both
|
||||||
|
alert channels keep requiring registration).
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- `src/components/OnboardingDialog.vue` is the welcome. It opens from
|
||||||
|
`HomeView.vue` (`initializeIdentity`, when `settings.finishedOnboarding` is
|
||||||
|
false), again from `DiscoverView.vue` and `ProjectsView.vue`, and re-opens on
|
||||||
|
every visit to those pages until a button sets `finishedOnboarding`. Playwright
|
||||||
|
web tests dismiss it through the `closeOnboardingAndFinish` test ID
|
||||||
|
(`test-playwright/testUtils.ts`, `importUserAndCloseOnboarding`).
|
||||||
|
- `HelpView.vue` has a "see the page-by-page help again" link
|
||||||
|
(`unsetFinishedOnboarding`) that sets `finishedOnboarding` false and goes to
|
||||||
|
Home, where the welcome dialog opens again.
|
||||||
|
- `src/views/HelpOnboardingView.vue` (route `help-onboarding`) is a standalone
|
||||||
|
instructions page for meeting onboarding; `HelpView.vue` holds the general
|
||||||
|
help.
|
||||||
|
- The router guard (`src/router/index.ts`) creates an identity on the first
|
||||||
|
navigation, so Home never loads without an `activeDid`. Deep links land on
|
||||||
|
their own routes (`invite-one-accept`, `claim`, `confirm-gift`,
|
||||||
|
`shared-photo`, `contact-import`, …); the invite flow ends on `contacts`.
|
||||||
|
- Matching lives on the Partner server (`endorser-ch`,
|
||||||
|
`src/api/controllers/partner-router.js`; design in that repo's
|
||||||
|
`CHANGES/PLAN-interest-matching-architecture.md`):
|
||||||
|
- `GET /api/partner/similar?from=text:<query>&to=profile|plan|both` with an
|
||||||
|
optional bounding box (`minLocLat`, `minLocLon`, `maxLocLat`, `maxLocLon`),
|
||||||
|
`minSimilarity`, and `limit` (default 25, max 50). It returns
|
||||||
|
`{ subjectType, subjectId, similarity, record }` best first, with DIDs
|
||||||
|
hidden the way the profile and plan searches hide them. A `text:` source
|
||||||
|
needs a JWT from a **registered** DID (`mainService.isMember`) and is
|
||||||
|
limited to `EMBEDDING_QUERY_MAX_PER_MINUTE` queries per DID (default 20).
|
||||||
|
The query text is embedded in memory and not stored.
|
||||||
|
- `GET /api/partner/userProfile` (the people list Discover uses) and
|
||||||
|
`GET /api/v2/report/plans` / `plansByLocation` (the project lists) need a
|
||||||
|
JWT but not registration. Both take a `claimContents` text filter and a
|
||||||
|
bounding box, which is how Discover searches.
|
||||||
|
- `POST /api/partner/userProfile` (`description`, `locLat`, `locLon`,
|
||||||
|
optional second point) needs registration. Every saved profile is embedded,
|
||||||
|
and the embedding is refreshed when the text changes.
|
||||||
|
- Query text is embedded in-process through one serial inference queue
|
||||||
|
(`embedding.service.js`, `enqueue`) shared with profile saves and the plan
|
||||||
|
sweep. The queue has no length limit. The per-DID text limit is an
|
||||||
|
in-memory map in `partner-router.js` (`allowTextQuery`). The server reads
|
||||||
|
no client IP anywhere and does not set Express `trust proxy`.
|
||||||
|
- Neither alertSearch handler (`/api/partner/alertSearch`,
|
||||||
|
`/api/v2/report/alertSearch`) checks registration; a JWT is enough.
|
||||||
|
- `alertSearch` returns `profilesSimilar` and `plansSimilar`: subjects whose
|
||||||
|
text was embedded inside the window and whose similarity to the
|
||||||
|
**requester's saved profile** is at least the spec's
|
||||||
|
`alertMinimumSimilarity`. They are empty when the requester has no profile
|
||||||
|
text. An optional `similarLocation` box restricts them.
|
||||||
|
- This app's New Activity feature already counts those two lists
|
||||||
|
(`src/libs/newActivity.ts`: "Profiles Like Yours", "Projects Matching Your
|
||||||
|
Profile"), and the notification service (`notification-wakeup-service`,
|
||||||
|
`src/alertSearch/digest.ts`) counts them into the "You have N new updates."
|
||||||
|
push and text. So a saved profile with text is the only precondition for
|
||||||
|
match alerts; no server or service change is needed for the alert itself.
|
||||||
|
- Profile editing is on `AccountViewView.vue` ("Public Profile", a section
|
||||||
|
shown only when `isRegistered`: a textarea,
|
||||||
|
an "Include Location" checkbox, and a Leaflet map the person taps to place a
|
||||||
|
point; `saveProfile` posts `description`, `locLat`, `locLon`). The app has no
|
||||||
|
geolocation plugin and never calls `navigator.geolocation`; location is
|
||||||
|
always chosen on a map (`SearchAreaView.vue` for the search box,
|
||||||
|
`settings.searchBoxes[0]`).
|
||||||
|
- Notification settings are also on `AccountViewView.vue`, under
|
||||||
|
"New Activity Notification", and only for registered identities (the notify
|
||||||
|
API refuses unregistered DIDs). Two channels:
|
||||||
|
- In-App (`notifyingNewActivityTime`), shown only when
|
||||||
|
`showGeneralAdvanced` is on because of iOS delivery problems
|
||||||
|
(`doc/sms-notifications.md`), delivered by server push or on-device
|
||||||
|
schedule (`newActivityDelivery`).
|
||||||
|
- SMS (`smsNotificationsEnabled`, `notifyingNewActivitySmsPhone`,
|
||||||
|
`notifyingNewActivitySmsTime`): `toggleSmsEnabled` →
|
||||||
|
`ChoiceButtonDialog` ("See More Info" / "Enter Phone Number") →
|
||||||
|
`SmsVerificationDialog` (US numbers, verification code) →
|
||||||
|
`promptSmsTimeAndEnable` (`PushNotificationPermission` with
|
||||||
|
`timeOnly`) → `authorizeSmsAlertSearch`. Passkey identities cannot use it.
|
||||||
|
- No speech input exists anywhere in the app.
|
||||||
|
- Settings are one row per account (`src/db/tables/settings.ts`); booleans and
|
||||||
|
JSON columns are listed in `PlatformServiceMixin._mapColumnsToValues`, and a
|
||||||
|
column is added with a migration in `src/db-sql/migration.ts`.
|
||||||
|
- Platform detection: `this.capabilities.isNativeApp` and `isMobile` through
|
||||||
|
`PlatformServiceMixin` (a computed property); the Capacitor service reports both true.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
One JSON settings column, `onboardingState`, following the `newActivityCursors`
|
||||||
|
precedent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "interests",
|
||||||
|
"interests": "vegetable gardening, teaching kids to code",
|
||||||
|
"lat": 40.76,
|
||||||
|
"lon": -111.89,
|
||||||
|
"matchesShownAt": "2026-10-25T16:02:11Z",
|
||||||
|
"bannerShownAt": null,
|
||||||
|
"declinedAt": null,
|
||||||
|
"welcomeTour": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stages, in order: `interests` (prompt pending) → `matches` (interests stored,
|
||||||
|
results not shown, or shown without a reachable server) → `save` (results seen;
|
||||||
|
the nudge is on) → `done`. A person who taps "Not now" on the prompt goes to
|
||||||
|
`done` with `declinedAt` set. `finishedOnboarding` is set true whenever the
|
||||||
|
stage becomes `done`, so everything that reads it keeps working.
|
||||||
|
|
||||||
|
The three welcome pages are not part of onboarding at any stage.
|
||||||
|
`HomeView`, `DiscoverView`, and `ProjectsView` open `OnboardingDialog` only
|
||||||
|
while `welcomeTour` is true. The Help page's "page-by-page help" link sets
|
||||||
|
`welcomeTour`, and finishing or closing the dialog clears it. A
|
||||||
|
`finishedOnboarding` of false, which a fresh install has until the stage
|
||||||
|
reaches `done`, opens nothing by itself.
|
||||||
|
|
||||||
|
The Help page's "enter your interests again" link (above the welcome link)
|
||||||
|
opens `InterestsPromptDialog` from Help, pre-filled with the kept interests and
|
||||||
|
location. It does not change the stage unless the stage is `done`, in which
|
||||||
|
case "Find matches" moves it to `matches` and the flow runs again.
|
||||||
|
|
||||||
|
The stage is per account, like every other setting. An identity that already
|
||||||
|
has a profile with text on the server starts at `done` (a reinstall or a second
|
||||||
|
device): the Home load checks `GET /api/partner/userProfileForIssuer/:did` once
|
||||||
|
when the stage is `interests`.
|
||||||
|
|
||||||
|
### Kept interests and location
|
||||||
|
|
||||||
|
`interests`, `lat`, and `lon` are written to `onboardingState` when the person
|
||||||
|
taps "Find matches", before any network call, so neither the registration
|
||||||
|
gates nor a server error can lose them. They are the draft of a public
|
||||||
|
profile, and they outlive the onboarding stages:
|
||||||
|
|
||||||
|
- They stay when the stage reaches `done`, including after "No thanks" on the
|
||||||
|
Profile card, and after "Not now" on the prompt when the person had typed
|
||||||
|
something before declining.
|
||||||
|
- They stay for as long as the person is unregistered. The server refuses an
|
||||||
|
unregistered profile save, so the phone copy is the only copy until someone
|
||||||
|
registers them.
|
||||||
|
- When a registered person has no saved profile, the Profile page's "Public
|
||||||
|
Profile" editor (shown only to registered identities) pre-fills its textarea and map point from them and shows "Saved only
|
||||||
|
on this device. Not public until you save your profile." with a "Forget"
|
||||||
|
link. This is the path for a person who was unregistered during onboarding,
|
||||||
|
and for anyone who declined the card.
|
||||||
|
- Saving the profile clears them, since the server then holds the text.
|
||||||
|
"Forget" clears them. Running the interest search again with edited text
|
||||||
|
replaces them.
|
||||||
|
- `lat` and `lon` hold the reading rounded to two decimals (about 1 km). The
|
||||||
|
raw reading is never written.
|
||||||
|
|
||||||
|
### Where and when the prompt opens
|
||||||
|
|
||||||
|
Every platform. HomeView sets `data-onboarding-checked` on `#Content` once
|
||||||
|
the prompt has opened or was not due, and the Playwright helper
|
||||||
|
`closeOnboardingIfShown` waits for it and closes the prompt only when it
|
||||||
|
opened: test users with saved profile text never see it.
|
||||||
|
|
||||||
|
The prompt opens from `HomeView` only, in the same place `OnboardingDialog`
|
||||||
|
opens (end of `initializeIdentity`, so the feed is loading underneath), when
|
||||||
|
the stage is `interests`. Nothing else opens it, so an invite, a claim, a
|
||||||
|
shared photo, or a contact import runs to its end without interruption; the
|
||||||
|
person meets the prompt the first time they come to Home afterward.
|
||||||
|
|
||||||
|
No timer and no wait for a gesture. A timer fires while the person is reading or
|
||||||
|
scrolling; waiting for a gesture interrupts the thing they chose to do. Opening
|
||||||
|
with the feed, as the welcome dialog does, is the moment they expect to be
|
||||||
|
greeted, and the feed stays visible behind the overlay as context.
|
||||||
|
|
||||||
|
It opens once. If dismissed with the close "x" (not "Not now"), the stage stays
|
||||||
|
`interests` and the prompt returns on the next cold start of the app, not on
|
||||||
|
the next visit to Home. "Not now" ends it.
|
||||||
|
|
||||||
|
### The prompt (`InterestsPromptDialog.vue`)
|
||||||
|
|
||||||
|
One screen, replacing the three. Content:
|
||||||
|
|
||||||
|
- Title: "What would you like to find?" Sub-line: "Tell us your interests, and
|
||||||
|
we'll look for people and projects nearby that match."
|
||||||
|
- A textarea with a placeholder of examples. A small hint under it: "Tip: tap
|
||||||
|
the microphone on your keyboard to speak." (See Decisions for a dedicated
|
||||||
|
mic button.)
|
||||||
|
- A "Use my location" button. On tap, ask the OS for permission and read one
|
||||||
|
position. On success, show "Near <rounded coordinates>" with a "remove"
|
||||||
|
link. On denial or failure, show "Pick on map" which opens a small map (the
|
||||||
|
Leaflet setup from the Profile page) to tap a point. Location is optional;
|
||||||
|
the search runs without it.
|
||||||
|
- The privacy line, in full: "Your interests stay on this device. Searching
|
||||||
|
sends them to the server to find matches, but the server does not store
|
||||||
|
them. You can choose to add them to your public profile afterward." (See
|
||||||
|
Decisions: the request's "does not leave the device" wording is not true of
|
||||||
|
a server-side text search, and the line must not say it.)
|
||||||
|
- Buttons: "Find matches" (primary; disabled until there is text) and "Not
|
||||||
|
now" (secondary, `data-testId="closeOnboardingAndFinish"` so the existing
|
||||||
|
test helper works if the flow is ever enabled on web).
|
||||||
|
|
||||||
|
"Find matches" stores interests and the point in `onboardingState`, sets the
|
||||||
|
stage to `matches`, and navigates to the results view.
|
||||||
|
|
||||||
|
### The results view (`InterestMatchesView.vue`, route `interest-matches`)
|
||||||
|
|
||||||
|
A dedicated view rather than a mode of `DiscoverView`, which already multiplies
|
||||||
|
projects/people by anywhere/nearby/mapped and would gain a fourth axis.
|
||||||
|
|
||||||
|
- Reads interests and the point from `onboardingState`, or from route query
|
||||||
|
(`text`, `lat`, `lon`) so Discover can link to it later with a "Search by
|
||||||
|
interests" entry.
|
||||||
|
- Calls `GET /api/partner/similar` with `from=text:<interests>`, `to=both`,
|
||||||
|
`limit=50`, and, when there is a point, a bounding box of about 50 km around
|
||||||
|
it (±0.45° latitude; longitude scaled by `cos(lat)`). The box, not the point,
|
||||||
|
is what leaves the device.
|
||||||
|
- Shows two sections, People and Projects, best match first, each row with
|
||||||
|
the description excerpt, the similarity as a short label ("strong" at 0.85
|
||||||
|
and up, "weak" at 0.81 and up, none below), and a tap-through to
|
||||||
|
`user-profile` or `project`. Only strong matches show by default; the rest
|
||||||
|
sit in a collapsed "Show N weaker matches" section, and with no strong
|
||||||
|
matches the view says so above that section, then says how they would
|
||||||
|
hear about one (`noStrongFollowUp` in `src/libs/onboarding.ts`): a
|
||||||
|
registration notice when unregistered; a link to the follow-up card
|
||||||
|
(`/account?nextStep=1`, shown regardless of stage) when the server profile
|
||||||
|
has no text or, on the phone apps, no daily alert is on; otherwise a note
|
||||||
|
that the alert is on, or on the web that alerts are in the phone apps.
|
||||||
|
When the box
|
||||||
|
returns fewer than five rows, a second request without the box fills a
|
||||||
|
"Farther away" section; the server's query cache serves the second
|
||||||
|
request without another inference.
|
||||||
|
- Registered or not, the person gets the same matches by meaning, from the
|
||||||
|
same request (see Registration gates).
|
||||||
|
- Error handling:
|
||||||
|
- 429: "Too many searches right now; try again in a minute." with a retry
|
||||||
|
button. The interests are already kept, so nothing is lost.
|
||||||
|
- 503 (inference queue full): "Matching is busy; try again shortly." with a
|
||||||
|
retry button.
|
||||||
|
- Network failure: a retry button; stage stays `matches`.
|
||||||
|
- On the first successful render, set `matchesShownAt` and the stage to
|
||||||
|
`save`.
|
||||||
|
|
||||||
|
### The nudge
|
||||||
|
|
||||||
|
Three pieces, all driven by `onboardingState.stage === "save"` together with
|
||||||
|
`settings.isRegistered`. An unregistered person is never prompted to fill in a
|
||||||
|
profile or turn on alerts: after the results the stage moves to `save` as
|
||||||
|
usual, but the dot, banner, and card stay hidden. The first page load that
|
||||||
|
finds `isRegistered` true (HomeView re-checks registration on each load)
|
||||||
|
makes them appear, with the banner's one-time animation still unspent. The
|
||||||
|
kept interests wait on the phone for that moment.
|
||||||
|
|
||||||
|
1. **Red dot on the profile tab.** `QuickNav.vue` reads the stage once per
|
||||||
|
launch into a shared reactive state (`src/utils/onboardingNudge.ts`), which
|
||||||
|
the pages that change the stage or registration update, and renders a small red circle at the top-right of
|
||||||
|
the `circle-user` icon. Every page shows QuickNav, so the pointer is
|
||||||
|
everywhere and in nobody's way.
|
||||||
|
2. **One-time banner.** A `NextStepBanner.vue` component, placed under
|
||||||
|
`TopMessage` in `HomeView`, `DiscoverView`, `ProjectsView`, and
|
||||||
|
`InterestMatchesView`. The first time any of those pages renders while the
|
||||||
|
stage is `save` and `bannerShownAt` is null, it slides down from the top:
|
||||||
|
"Want to hear when new matches appear? Save your interests and pick an
|
||||||
|
alert →" on the phone apps, and "Let people with interests like yours find
|
||||||
|
you: save your interests to your profile →" elsewhere. It holds for a few seconds while the profile tab's dot pulses (a CSS
|
||||||
|
keyframe on the dot, triggered by a shared reactive flag), then collapses
|
||||||
|
toward the tab. Tapping it goes to `account`. It sets `bannerShownAt` and
|
||||||
|
never animates again; the dot stays.
|
||||||
|
3. **Next-step card on the Profile page.** `NextStepCard.vue` at the top of
|
||||||
|
`AccountViewView.vue`, above the identity section, shown while the stage is
|
||||||
|
`save`. It carries the messaging for the follow-up and both actions:
|
||||||
|
- **Step 1, "Save to your public profile."** Shows the interests text
|
||||||
|
(editable) and, when a point exists, "Include location (rounded to about
|
||||||
|
1 km)" checked by default. "Save" calls the page's `saveProfile` path with
|
||||||
|
the text and the rounded point, and also sets `settings.searchBoxes[0]`
|
||||||
|
to the same box the search used when no search box exists yet, so Nearby
|
||||||
|
works. Match alerts depend on this step; the card says so: "Alerts about
|
||||||
|
matches use your saved profile."
|
||||||
|
- **Step 2, "Get alerted."** On the phone apps only
|
||||||
|
(`offersAlerts(capabilities)`: native and mobile), since web and desktop
|
||||||
|
have no Notifications section. Two choices, in this order:
|
||||||
|
1. **Text message** (labelled "most reliable"): runs the existing
|
||||||
|
`toggleSmsEnabled` → `SmsVerificationDialog` → time prompt →
|
||||||
|
`authorizeSmsAlertSearch` sequence. Shown disabled with the reason when
|
||||||
|
the identity uses a passkey or SMS is otherwise unavailable.
|
||||||
|
2. **In-app notification**: runs the existing in-app enable path, with the
|
||||||
|
note "may arrive late on iPhone".
|
||||||
|
A link to `help-notification-types` sits under both.
|
||||||
|
- "Done" and "No thanks" at the bottom; both set the stage to `done`, which
|
||||||
|
clears the dot and the card. Completing every step shown (the save, plus
|
||||||
|
an alert on the phone apps) also sets `done`.
|
||||||
|
The card is a thin layer over methods `AccountViewView` already has; the
|
||||||
|
notification section below it is unchanged, so a person who ignores the
|
||||||
|
card finds the same toggles where they always were.
|
||||||
|
|
||||||
|
### Welcome text
|
||||||
|
|
||||||
|
`OnboardingDialog.vue` keeps its three pages unchanged. It opens only from the
|
||||||
|
Help page link, as described under State, and walks the Home → Discover →
|
||||||
|
Projects sequence while `welcomeTour` is set.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
`@capacitor/geolocation` is added for "Use my location". Its Android
|
||||||
|
implementation depends on `play-services-location`, so this is a Play Services
|
||||||
|
dependency on top of the MLKit one (see `AGENTS.md`); on a device without
|
||||||
|
Google services the call fails and the prompt falls back to "Pick on map". The
|
||||||
|
point is used three ways: a ~50 km box for the search, a point rounded to two
|
||||||
|
decimals (about 1 km) for the saved profile, and the same box for
|
||||||
|
`searchBoxes[0]`. The raw reading is never stored.
|
||||||
|
|
||||||
|
### Registration gates
|
||||||
|
|
||||||
|
Text search on `GET /api/partner/similar` opens to any JWT. Profile save and
|
||||||
|
both alert channels keep requiring registration, so an unregistered person
|
||||||
|
sees the same matches as a registered one, and their interests stay on the
|
||||||
|
phone until someone registers them and they choose to save.
|
||||||
|
|
||||||
|
An unregistered DID costs nothing to mint, so the per-DID limit alone stops
|
||||||
|
nobody. The server change adds these protections, in the order a request
|
||||||
|
meets them:
|
||||||
|
|
||||||
|
1. **Per-IP limit on text queries,** keyed by the client address: the IPv4
|
||||||
|
address, or the /64 prefix of an IPv6 address. Mobile carriers put many
|
||||||
|
phones behind one IPv4 address (carrier-grade NAT), so the limit is a
|
||||||
|
burst ceiling, not a per-person budget: default 60 per minute
|
||||||
|
(`EMBEDDING_QUERY_MAX_PER_MINUTE_PER_IP`). The per-DID limit stays at 20
|
||||||
|
for registered DIDs and is 5 for unregistered ones.
|
||||||
|
2. **Correct client address.** `req.ip` is the proxy's address unless Express
|
||||||
|
`trust proxy` names the proxy. The setting is the proxy's address or hop
|
||||||
|
count, from `TRUST_PROXY`, never `true`: with `true`, any client can set
|
||||||
|
`X-Forwarded-For` and pick its own limit key. Unset, the per-IP limit keys
|
||||||
|
on the connecting address, which is right when nothing fronts the API.
|
||||||
|
3. **Query cache.** An LRU of recent query vectors keyed by the hash of the
|
||||||
|
preprocessed text (default 1,000 entries). Repeated text, from one spammer
|
||||||
|
or from many people typing "gardening", costs no inference.
|
||||||
|
4. **Bounded queue with priority.** Text queries wait in their own queue
|
||||||
|
capped at a few entries (`EMBEDDING_QUERY_MAX_QUEUED`, default 10); past
|
||||||
|
that the request gets 503 at once instead of waiting. Profile saves and
|
||||||
|
the sweep go ahead of queued text queries, so a flood delays only other
|
||||||
|
searchers.
|
||||||
|
|
||||||
|
What this exposes: nothing an unregistered JWT cannot read already. The
|
||||||
|
profile list is open to any JWT with DIDs hidden the same way, and plans are
|
||||||
|
public claims. Search by meaning makes the same records easier to find,
|
||||||
|
which is the point of the feature. Spam does not get in through this route:
|
||||||
|
it is read-only, and publishing anything (profile, claim, SMS number) keeps
|
||||||
|
its registration gate.
|
||||||
|
|
||||||
|
## Decisions made
|
||||||
|
|
||||||
|
1. **Privacy wording.** Use the 'privacy line' above.
|
||||||
|
2. **Registration gates.** Open text search on `/similar` to unregistered
|
||||||
|
identities, with the protections under Registration gates. Keep the gates
|
||||||
|
on profile save and on both alert channels. Interests and location stay on
|
||||||
|
the phone until the person is registered and chooses to save them.
|
||||||
|
3. **Speech input.** Keyboard dictation with a hint (for the first
|
||||||
|
release: zero dependencies, works on both platforms as they are). Not a dedicated
|
||||||
|
mic button through `@capacitor-community/speech-recognition`, which needs
|
||||||
|
microphone and speech permissions and an on-device recognizer service
|
||||||
|
(Google's on most Android phones; absent on some FOSS devices).
|
||||||
|
4. **Geolocation dependency.** Accept `@capacitor/geolocation` and its
|
||||||
|
`play-services-location` dependency (with the map fallback).
|
||||||
|
5. **In-app choice visibility.** The card shows the in-app option even when
|
||||||
|
`showGeneralAdvanced` is off. SMS is listed first. The card appears only
|
||||||
|
for registered identities, so neither choice needs a registration
|
||||||
|
message.
|
||||||
|
6. **Default alert time.** Pre-fill the time prompt with 9:00 local and show
|
||||||
|
"change".
|
||||||
|
7. **Re-prompt rule.** After the "x" close, return on the next cold start.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
### endorser-ch
|
||||||
|
|
||||||
|
- [x] `/similar`: drop the registration check for `text:`; per-DID limit 20
|
||||||
|
registered / 5 unregistered.
|
||||||
|
- [x] Per-IP text-query limit (IPv4 address or IPv6 /64), with `TRUST_PROXY`
|
||||||
|
feeding Express `trust proxy`; document the setting for each deployment.
|
||||||
|
- [x] Query-vector LRU cache keyed by preprocessed-text hash.
|
||||||
|
- [x] Separate, capped text-query queue behind profile and sweep work; 503
|
||||||
|
when full.
|
||||||
|
- [x] Tests in `test/controller-partner-9-similar.js`: unregistered text query
|
||||||
|
succeeds; per-IP limit with a spoofed `X-Forwarded-For` ignored when the
|
||||||
|
proxy is not trusted; cache hit skips inference; full queue returns 503.
|
||||||
|
- [ ] Deploy to the test server before the app's results view depends on it.
|
||||||
|
|
||||||
|
### This app: state and gating
|
||||||
|
|
||||||
|
- [x] Migration adding `onboardingState TEXT` to `settings`, and the column in
|
||||||
|
the `Settings` type as a JSON string read with `parseOnboardingState`, like
|
||||||
|
`newActivityCursors` (not in `_mapColumnsToValues`).
|
||||||
|
- [x] `src/libs/onboarding.ts`: the state type, stage transitions, the
|
||||||
|
bounding-box helper (point + radius → `minLocLat` … ), the rounding helper,
|
||||||
|
and `offersAlerts(capabilities)`. Unit tests in `src/test/`.
|
||||||
|
- [x] `HomeView.initializeIdentity`: with stage `interests`, check for an
|
||||||
|
existing server profile, then open `InterestsPromptDialog`; open
|
||||||
|
`OnboardingDialog` instead during a welcome tour.
|
||||||
|
- [x] `HomeView`, `DiscoverView`, `ProjectsView`: open `OnboardingDialog` only
|
||||||
|
while `welcomeTour` is set; clear it when the dialog finishes or closes.
|
||||||
|
- [x] `HelpView.vue`: an "enter your interests again" link above the welcome
|
||||||
|
link, opening `InterestsPromptDialog` pre-filled; the welcome link sets
|
||||||
|
`welcomeTour`.
|
||||||
|
- [x] Web and desktop: the same prompt, results, and nudge, with the card's
|
||||||
|
alert step and the banner's alert wording only on the phone apps.
|
||||||
|
|
||||||
|
### This app: prompt and results
|
||||||
|
|
||||||
|
- [x] `InterestsPromptDialog.vue`: textarea, location button with permission
|
||||||
|
flow and map fallback, privacy line, "Find matches", "Not now".
|
||||||
|
- [x] `@capacitor/geolocation`: install, Android permissions
|
||||||
|
(`ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION`), iOS
|
||||||
|
`NSLocationWhenInUseUsageDescription`, Podfile entry; note the Play Services
|
||||||
|
dependency in `doc/android-firebase-gms.md`.
|
||||||
|
- [ ] iOS `pod install` for `CapacitorGeolocation` (updates `Podfile.lock`;
|
||||||
|
`scripts/build-ios.sh` runs it through `cap sync`) — next: run on a machine
|
||||||
|
with CocoaPods (Trent)
|
||||||
|
- [x] `src/libs/partnerServer.ts`: `fetchSimilar(params)` typed client for
|
||||||
|
`/api/partner/similar`, with the 429, 503, and network cases
|
||||||
|
mapped to a small result type.
|
||||||
|
- [x] `InterestMatchesView.vue` + route `interest-matches` (+ deep-link schema
|
||||||
|
entry with optional `text`, `lat`, `lon`): two sections, "Farther away"
|
||||||
|
fill, error states, stage → `save` on first render.
|
||||||
|
- [x] `DiscoverView.vue`: a "Search by interests" link to the view, so the
|
||||||
|
feature is reachable after onboarding.
|
||||||
|
|
||||||
|
### This app: nudge
|
||||||
|
|
||||||
|
- [x] `QuickNav.vue`: red dot on the profile tab while the stage is `save`
|
||||||
|
and the identity is registered;
|
||||||
|
pulse class driven by a shared reactive flag.
|
||||||
|
- [x] `NextStepBanner.vue`: one-time animated banner, registered identities
|
||||||
|
only; placed in Home,
|
||||||
|
Discover, Projects, and the results view; sets `bannerShownAt`.
|
||||||
|
- [x] `NextStepCard.vue` in `AccountViewView.vue`: Step 1 save (text +
|
||||||
|
rounded point + `searchBoxes[0]`), Step 2 alerts with SMS first and in-app
|
||||||
|
second, reasons when disabled, "Done" / "No thanks"; rendered only for
|
||||||
|
registered identities; clears interests from `onboardingState` only on a
|
||||||
|
successful save.
|
||||||
|
- [x] `AccountViewView.vue` "Public Profile": with no saved profile, pre-fill
|
||||||
|
from the kept interests and point, with the "Saved only on this device" note
|
||||||
|
and "Forget".
|
||||||
|
- [x] Default time pre-fill in `PushNotificationPermission` when opened from
|
||||||
|
the card (per Decision 6).
|
||||||
|
|
||||||
|
### This app: text, docs, tests
|
||||||
|
|
||||||
|
- [x] `doc/usage-guide.md` and `HelpNotificationTypesView.vue`: mention that
|
||||||
|
match alerts come from the saved profile text.
|
||||||
|
- [x] Unit tests: stage transitions, kept-interests lifetime (kept on `done`,
|
||||||
|
"Not now", and while unregistered; cleared on save and "Forget"), bbox and
|
||||||
|
rounding helpers, `fetchSimilar` error mapping.
|
||||||
|
- [x] Playwright web: `closeOnboardingIfShown` (waits for
|
||||||
|
`data-onboarding-checked`, closes the prompt only if it opened) in place of
|
||||||
|
direct `closeOnboardingAndFinish` clicks; no close on Discover, where
|
||||||
|
nothing opens.
|
||||||
|
- [x] Manual script in `doc/onboarding-by-interests-manual-test.md`, phones
|
||||||
|
and web: fresh install → prompt → deny location →
|
||||||
|
map pick → matches → banner → dot → card → SMS path on a registered account;
|
||||||
|
invite deep link on a fresh install → no prompt until Home; unregistered
|
||||||
|
account → matches by meaning → no dot, banner, or card → register → dot and
|
||||||
|
banner appear → card offers "Save" → profile saved with those interests; "No thanks" on
|
||||||
|
the card → Profile editor pre-filled later; Help → welcome pages walk Home,
|
||||||
|
Discover, Projects once and stop.
|
||||||
|
- [x] CHANGELOG entry; bump with `-beta`.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
### A timer before the prompt
|
||||||
|
|
||||||
|
A delay of a few seconds lands while the person is reading the feed or
|
||||||
|
mid-scroll, and the length is arbitrary. The welcome dialog's timing (with the
|
||||||
|
feed load) is the one moment the person expects to be addressed.
|
||||||
|
|
||||||
|
### Waiting for the first gesture
|
||||||
|
|
||||||
|
The first tap or scroll is an action the person chose; a modal at that moment
|
||||||
|
cancels it. It also makes the prompt's appearance feel caused by whatever they
|
||||||
|
touched.
|
||||||
|
|
||||||
|
### Extending `DiscoverView` with a "matches" mode
|
||||||
|
|
||||||
|
Discover already crosses projects/people with anywhere/nearby/mapped; a
|
||||||
|
similarity mode would add a fourth axis and a different result shape
|
||||||
|
(similarity-ranked, two types at once). A separate view is smaller and can be
|
||||||
|
linked from Discover.
|
||||||
|
|
||||||
|
### Running the search on the device
|
||||||
|
|
||||||
|
Requires the vector export and an on-device copy of the embedding model (a few
|
||||||
|
hundred megabytes), which the server plan schedules as a later phase. It is
|
||||||
|
the only way to make "never leaves the device" literally true, and it is not
|
||||||
|
available for this release.
|
||||||
|
|
||||||
|
### Blocking the next step with another modal after the results
|
||||||
|
|
||||||
|
The request rules this out: the person is in a search flow and the follow-up
|
||||||
|
must wait at the edge of the screen until they choose it.
|
||||||
|
|
||||||
|
### Keeping the welcome pages as the web and desktop onboarding
|
||||||
|
|
||||||
|
The first release of this plan limited the interests prompt to the phone apps
|
||||||
|
and left web and Electron on the welcome pages. Closed 2026-10-31: every
|
||||||
|
platform gets the interests prompt, and only the alert step stays phone-only,
|
||||||
|
because web and desktop have no Notifications section.
|
||||||
|
|
||||||
|
### Dropping the three welcome pages entirely
|
||||||
|
|
||||||
|
Their content is the only in-app explanation of the feed, Discover, and
|
||||||
|
Projects tabs, so they stay, reachable from Help.
|
||||||
|
|
||||||
|
### Keyword search for unregistered identities
|
||||||
|
|
||||||
|
The open people and project lists filter on `claimContents`, which needs
|
||||||
|
the person's words to appear in a record. A sentence of interests rarely
|
||||||
|
does, so the results would be thin exactly when the first impression
|
||||||
|
matters.
|
||||||
|
|
||||||
|
### Opening profile save or SMS to unregistered identities
|
||||||
|
|
||||||
|
Profile save would let anyone publish to the list every user reads, which is
|
||||||
|
where spam would land. SMS verification for an unregistered DID would let
|
||||||
|
anyone make the notify API text arbitrary numbers at the project's expense
|
||||||
|
(SMS pumping). Match alerts for unregistered people would need both, so they
|
||||||
|
wait for registration.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Onboarding by interests on mobile — next: deploy the endorser-ch `/similar` change to the test server, run iOS `pod install`, then the manual script `doc/onboarding-by-interests-manual-test.md` on devices; see [PLAN-onboarding-by-interests.md](PLAN-onboarding-by-interests.md) (Trent)
|
||||||
|
- [ ] On-device New Activity notifications through alertSearch — next: plugin request-job runner (`configureRequestJob`), then the app's job builder; see [PLAN-device-alertsearch-notifications.md](PLAN-device-alertsearch-notifications.md) (Trent)
|
||||||
|
- [ ] Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||||
|
(`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
|
||||||
|
`{}`) on its early-return and error paths, so `apiServer` can be undefined;
|
||||||
|
`HomeView` works around this on its own. This gap exists independently of
|
||||||
|
degraded mode.
|
||||||
+43
-29
@@ -1,5 +1,12 @@
|
|||||||
# Plan: New Activity Screen on alertSearch
|
# Plan: New Activity Screen on alertSearch
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built in the app, the notification service and endorser-ch. The last change,
|
||||||
|
leaving a person's own gives and offers out of their
|
||||||
|
`personalPlanContributions`, is in endorser-ch's Unreleased CHANGELOG section
|
||||||
|
and ships with its next release; Playwright spec 50 depends on it.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
|
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
|
||||||
@@ -16,7 +23,7 @@ list's length.
|
|||||||
| Bucket | Server | What it holds | Needs |
|
| 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) | — |
|
| `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) | — |
|
| `personalPlanContributions` | endorser | Gives and offers by others to or from projects you issued or are agent of | — |
|
||||||
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
|
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
|
||||||
| `trackedPlanClaims` | endorser | Gives and offers to or from 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` |
|
| `plansNearby` | endorser | Projects created or edited in the search area | `location` |
|
||||||
@@ -69,16 +76,22 @@ Stored as JSON in one settings column, `newActivityCursors`
|
|||||||
|
|
||||||
- **Visit window**, one cursor per server. The summary counts every bucket from
|
- **Visit window**, one cursor per server. The summary counts every bucket from
|
||||||
its start, the same window the notification service counts from once the
|
its start, the same window the notification service counts from once the
|
||||||
visit is uploaded to it.
|
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
|
- **Bucket window**, one per bucket, on that bucket's server. A detail page
|
||||||
lists everything from its start. A bucket never opened starts where the
|
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.
|
summary does, so its first detail count equals the summary's.
|
||||||
- **Uploaded**, the cursors last sent to each notification channel.
|
- **Uploaded**, the cursors last sent to each notification channel.
|
||||||
|
|
||||||
A window records where a load counted from (`from`), where it ended (`to`) and
|
A window records where a load counted from (`from`) and when that cursor was
|
||||||
when the visit began (`at`). A load less than 30 minutes after `at` is the same
|
recorded (`fromSeenAt`), where it ended (`to`) and when (`seenAt`), and when
|
||||||
visit and counts from the same `from`, so moving between the summary and a
|
the visit began (`at`). A summary load less than 30 minutes after `at` is the
|
||||||
detail page keeps the counts; a later load starts a new visit from the last
|
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
|
`to`. "Mark unread from this item" does not exist, since no cursor points at an
|
||||||
item.
|
item.
|
||||||
|
|
||||||
@@ -140,8 +153,8 @@ summary's order, from the same eight alertSearch buckets
|
|||||||
- A bucket counts from the end of whichever load the person saw last: the
|
- 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
|
summary's visit or that bucket's own list (`unseenStart` in
|
||||||
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
|
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
|
||||||
uses a visit's start, so a list just opened shows no tile, even inside the
|
uses a visit's start, so a summary just loaded shows no tiles, even inside
|
||||||
30-minute visit in which the summary keeps showing its count.
|
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
|
- 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.
|
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
|
- Home writes the cursor state only to seed it on the first load ever; it
|
||||||
@@ -275,15 +288,14 @@ App (`crowd-funder-for-time-pwa`):
|
|||||||
where New Activity starts in parallel with the feed, Affirm Delivery waits
|
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
|
for the offer's project, a WebAssembly explanation on the start-up error
|
||||||
screen, test warm-up, retry and reporter).
|
screen, test warm-up, retry and reporter).
|
||||||
- [ ] Release the app after endorser-ch is deployed, since detail rows read
|
- [x] Release the app after endorser-ch is deployed, since detail rows read
|
||||||
`claim` from `alertSearch` — next: release (Trent)
|
`claim` from `alertSearch`.
|
||||||
|
|
||||||
Notification service (`notification-wakeup-service`):
|
Notification service (`notification-wakeup-service`):
|
||||||
|
|
||||||
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
|
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
|
||||||
CHANGELOG.
|
CHANGELOG.
|
||||||
- [ ] Deploy to test and production — next: deploy, or tick this if done
|
- [x] Deploy to test and production.
|
||||||
(Trent)
|
|
||||||
|
|
||||||
endorser-ch:
|
endorser-ch:
|
||||||
|
|
||||||
@@ -292,27 +304,25 @@ endorser-ch:
|
|||||||
sends) gets `toEntity`, so it reaches the owner's
|
sends) gets `toEntity`, so it reaches the owner's
|
||||||
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
|
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
|
||||||
existing offers without moving `changeSeq`; test, CHANGELOG.
|
existing offers without moving `changeSeq`; test, CHANGELOG.
|
||||||
- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
|
- [x] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
|
||||||
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
|
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
|
||||||
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
|
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js`.
|
||||||
next: commit (Trent)
|
- [x] Deploy to test, then production, running Flyway on the main DB so V23
|
||||||
- [ ] Deploy to test, then production, running Flyway on the main DB so V23
|
applies.
|
||||||
applies — next: deploy (Trent)
|
- [x] Leave a person's own gives and offers out of their
|
||||||
- [ ] Leave a person's own gives and offers out of their
|
`personalPlanContributions`:
|
||||||
`personalPlanContributions` (the query includes them when a person
|
- endorser-ch: `AND j.issuer != ?`, bound to the requesting DID, on both
|
||||||
contributes to their own project) — next: implement after the deploys above (agent)
|
halves of the `UNION` in `jwtsForUserPlanContributions`
|
||||||
- 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.db.service.js`), as `jwtsWithDidAfterId` does.
|
||||||
- endorser-ch test in `controller-endorser-6-plans-totals.js`: user 1's own
|
- 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
|
offer to user 1's plan is absent from user 1's
|
||||||
`personalPlanContributions`, and user 2's offer is present.
|
`personalPlanContributions`, and user 2's offer is present.
|
||||||
- App spec 50 "Record an offer": record the offer as User 1
|
- App spec 50 "Record an offer": User 0 seeds New Activity on Home, User 1
|
||||||
(`importUserFromAccount(page, "01")`) on a project User 0 owns, then
|
records the offer on a project User 0 owns, then User 0 checks the
|
||||||
switch to User 0 to check the `personalPlanContributions` tile and list.
|
`personalPlanContributions` tile and list. "Affirm delivery" gets User 1
|
||||||
"Affirm delivery" then gets User 1 as giver and the project as recipient.
|
as giver and the project as recipient.
|
||||||
- endorser-ch CHANGELOG entry; the notification service needs no change,
|
- endorser-ch CHANGELOG entry (Unreleased); the notification service needs
|
||||||
since its counts come from the same query.
|
no change, since its counts come from the same query.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
@@ -345,3 +355,7 @@ Closed decisions:
|
|||||||
- 2026-10-16: detail pages query `alertSearch` themselves and do not reuse
|
- 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
|
Home's rows; an in-memory hand-off would be a second path that every
|
||||||
entry other than a fresh, uncapped Home tile skips.
|
entry other than a fresh, uncapped Home tile skips.
|
||||||
|
- 2026-10-24: `personalPlanContributions` leaves out the requester's own
|
||||||
|
gives and offers, matching `claims`, which already left out the requester's
|
||||||
|
own claims. Before this, a person who offered to their own project saw it
|
||||||
|
counted as new activity. The plan was closed and moved to history.
|
||||||
+48
-14
@@ -1,5 +1,17 @@
|
|||||||
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Closed without building Parts A–C. The startup failure this plan prepared for
|
||||||
|
had a root cause, and the fix in `PLAN-startup-db-fix-crash.md` removed it: the
|
||||||
|
SQLite plugin's configuration key, migration recording, and Android backup
|
||||||
|
rules. A user who saw the error on every launch started normally after
|
||||||
|
installing a build with that fix.
|
||||||
|
|
||||||
|
The recovery screen from commit d6ccf0e4 remains the answer for any other
|
||||||
|
database failure. Parts A–C below are kept as a design to pick up if startup
|
||||||
|
database failures are reported again; nothing in them has been built.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
When the local database cannot be opened, a user should (1) get a useful
|
When the local database cannot be opened, a user should (1) get a useful
|
||||||
@@ -27,23 +39,25 @@ Requirements:
|
|||||||
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
||||||
to `StartView` through `sessionStorage`.
|
to `StartView` through `sessionStorage`.
|
||||||
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
||||||
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
|
and `beforeEach` guard (`src/router/index.ts`), and the Vue global error
|
||||||
global error handler (`src/main.common.ts`).
|
handler (`src/main.common.ts`).
|
||||||
- The guard calls `retrieveAccountDids()` for every route except the
|
- The guard calls `retrieveAccountDids()` for every route except the
|
||||||
identity-setup routes, so with the database down every route except
|
identity-setup routes, so with the database down every route except
|
||||||
`/account`, `/test` and `/logs` lands on `/start`.
|
`/account`, `/test` and `/logs` lands on `/start`.
|
||||||
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
|
- `StartView` shows the recovery screen: Try Again, Email Details, Copy
|
||||||
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
Details, "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
||||||
(`resetDatabase()`, which copies the file aside before deleting it).
|
(`resetDatabase()`, which copies the file aside before deleting it). When the
|
||||||
|
browser cannot run WebAssembly, the screen says so and points to the
|
||||||
|
settings that block it.
|
||||||
- API reads work without an identity: `getHeaders()`
|
- API reads work without an identity: `getHeaders()`
|
||||||
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
|
(`src/libs/endorserServer.ts`) omits the Authorization header when there
|
||||||
is no DID.
|
is no DID.
|
||||||
- Every path that creates, saves, or signs with an identity reads the `secret`
|
- Every path that creates, saves, or signs with an identity reads the `secret`
|
||||||
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
||||||
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
||||||
the database is down.
|
the database is down.
|
||||||
|
|
||||||
## Part A: One-tap diagnostics report
|
## Part A: One-tap diagnostics report (not built)
|
||||||
|
|
||||||
Contents of the report:
|
Contents of the report:
|
||||||
|
|
||||||
@@ -61,10 +75,10 @@ screen shows the exact payload and a Send button; the email option stays for
|
|||||||
users who decline.
|
users who decline.
|
||||||
|
|
||||||
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
||||||
`createConnection`; `databaseDirectoryUri()` already resolves the directory on
|
`createConnection`; `databaseDirectoryUri()` resolves the directory on
|
||||||
Android and iOS.
|
Android and iOS.
|
||||||
|
|
||||||
## Part B: Degraded mode
|
## Part B: Degraded mode (not built)
|
||||||
|
|
||||||
- Change the router guard so that a database startup error records the error
|
- 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
|
and lets read-only routes through: home feed, projects, project and claim
|
||||||
@@ -77,13 +91,14 @@ Android and iOS.
|
|||||||
confirm, invite, contacts) with that same reason, instead of the
|
confirm, invite, contacts) with that same reason, instead of the
|
||||||
"someone must register you" text an unregistered user sees.
|
"someone must register you" text an unregistered user sees.
|
||||||
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||||
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
|
(`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
|
||||||
the caller's `defaults` (often `{}`) on its early-return and error paths, so
|
`{}`) on its early-return and error paths, so `apiServer` can be undefined;
|
||||||
`apiServer` can be undefined; `HomeView` works around this on its own.
|
`HomeView` works around this on its own. This gap exists independently of
|
||||||
|
degraded mode. (Moved to PLAN.md)
|
||||||
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
||||||
Copy → Start Over.
|
Copy → Start Over.
|
||||||
|
|
||||||
## Part C (optional): An identity that does not need SQLite
|
## Part C (optional): An identity that does not need SQLite (not built)
|
||||||
|
|
||||||
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
||||||
In degraded mode the app could generate an identity, require the user to write
|
In degraded mode the app could generate an identity, require the user to write
|
||||||
@@ -99,6 +114,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
|
|||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
|
These stay open for whoever picks up Parts A–C.
|
||||||
|
|
||||||
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
||||||
- Whether to quarantine a corrupt database automatically ("file is not a
|
- Whether to quarantine a corrupt database automatically ("file is not a
|
||||||
database", "malformed") and start fresh. Automatic quarantine trades a
|
database", "malformed") and start fresh. Automatic quarantine trades a
|
||||||
@@ -109,7 +126,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
|
|||||||
user's identity on a new phone; it also gives the Google backup a copy of the
|
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
|
keys together with the secret that decrypts them. The backup rules
|
||||||
(`android/app/src/main/res/xml/backup_rules.xml`,
|
(`android/app/src/main/res/xml/backup_rules.xml`,
|
||||||
`data_extraction_rules.xml`) include it.
|
`data_extraction_rules.xml`) include it and exclude only the plugin's
|
||||||
|
encrypted preference files.
|
||||||
|
|
||||||
## Rejected
|
## Rejected
|
||||||
|
|
||||||
@@ -119,6 +137,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
|
Parts A–C are deferred, not scheduled.
|
||||||
|
|
||||||
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
||||||
- [ ] A2. Capture "database file existed before first connection" during startup.
|
- [ ] A2. Capture "database file existed before first connection" during startup.
|
||||||
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
||||||
@@ -127,3 +147,17 @@ encryption setting (`androidIsEncryption: false`) in effect.
|
|||||||
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
||||||
- [ ] B4. Reorder the recovery screen.
|
- [ ] B4. Reorder the recovery screen.
|
||||||
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 2026-10-10: commit d6ccf0e4 added the failed step and cause to the startup
|
||||||
|
error, the `startupError.ts` hand-off to `StartView`, and the recovery screen
|
||||||
|
(Try Again, Email/Copy Details, Save a Copy, Start Over).
|
||||||
|
- 2026-10-10: commit f41a807a fixed the root cause (see
|
||||||
|
`PLAN-startup-db-fix-crash.md`) and wrote this plan. Tagged 1.4.8; the CHANGELOG
|
||||||
|
records it under 1.4.9 as "Startup DB error for new installations".
|
||||||
|
- 2026-10-17: commit d2cdd578 added the WebAssembly explanation to the recovery
|
||||||
|
screen (CHANGELOG 1.4.10).
|
||||||
|
- After 1.4.9: a user who had the startup error on every launch reported
|
||||||
|
normal startup on a build with the fix. The plan was closed with Parts A–C
|
||||||
|
unbuilt.
|
||||||
+3
-3
@@ -10,8 +10,7 @@ Error" on every launch:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Reinstalling did not help. The failure never appeared on developer devices
|
Reinstalling did not help. The failure never appeared on developer devices
|
||||||
installed through adb or Android Studio. Full symptom report:
|
installed through adb or Android Studio.
|
||||||
`startup-db-error-symptoms.md`.
|
|
||||||
|
|
||||||
## Causes
|
## Causes
|
||||||
|
|
||||||
@@ -107,7 +106,8 @@ the app on both platforms.
|
|||||||
## Follow-up
|
## Follow-up
|
||||||
|
|
||||||
The one-tap diagnostics report and a degraded mode for users without a working
|
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`.
|
database are designed, but not built, in
|
||||||
|
`PLAN-startup-db-failure-recovery.md` in this directory.
|
||||||
|
|
||||||
## History
|
## History
|
||||||
|
|
||||||
@@ -68,6 +68,14 @@ Practical impact:
|
|||||||
|
|
||||||
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
|
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
|
||||||
|
|
||||||
|
## Known GMS dependency: Capacitor Geolocation
|
||||||
|
|
||||||
|
`@capacitor/geolocation` (the interests prompt's "Use my location" on the phone apps) depends on `com.google.android.gms:play-services-location` on Android. Like the MLKit scanner, it is present regardless of `-PfirebaseEnabled`.
|
||||||
|
|
||||||
|
On a device without Google Play Services the location request fails, and the prompt falls back to "Pick on map", where the person taps a point on a Leaflet map. Location is optional: the search runs without one. Only a point rounded to two decimals (about 1 km) is kept, and only a box of about 50 km around it is sent to the server.
|
||||||
|
|
||||||
|
The same trade-off applies as for MLKit: removing it would mean a `foss` flavor that drops the plugin and keeps only the map.
|
||||||
|
|
||||||
## Incident: June 2026
|
## Incident: June 2026
|
||||||
|
|
||||||
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
|
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Manual Test: Onboarding by Interests
|
||||||
|
|
||||||
|
The app asks for interests on the first visit to the feed, shows matches
|
||||||
|
from the partner server's `/api/partner/similar`, and then nudges a
|
||||||
|
registered person to save a profile and, on Android and iOS, to pick an
|
||||||
|
alert. Sections 1-7 run on the phone apps; section 8 covers web and desktop.
|
||||||
|
Design: `doc/CHANGES/PLAN-onboarding-by-interests.md`. The Playwright suite
|
||||||
|
covers the web dismissal path (`closeOnboardingIfShown`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- A partner server that allows text search on `/similar` for unregistered
|
||||||
|
identities, with some profiles and projects that have descriptions.
|
||||||
|
- A build pointed at that server (Profile → Advanced → Partner Server).
|
||||||
|
- "Fresh install" means uninstalling the app first, so settings are empty.
|
||||||
|
- A registered identity to import for the registered runs, and a US phone
|
||||||
|
number that can receive the verification text.
|
||||||
|
|
||||||
|
## 1. Fresh install, registered, denied location, SMS
|
||||||
|
|
||||||
|
1. Install, open, and import the registered identity (or have it registered
|
||||||
|
from another device) and go to the feed.
|
||||||
|
2. The prompt "What would you like to find?" opens over the feed; the three
|
||||||
|
welcome pages do not.
|
||||||
|
3. Type interests. Tap "Use my location" and deny permission. "Pick on map"
|
||||||
|
appears with a map; tap a point. "Near <lat>, <lon>" shows two decimals.
|
||||||
|
4. Tap "Find matches". The results view shows People and Projects with only
|
||||||
|
"strong" matches (similarity 0.85 and up). Below them, "Show N weaker
|
||||||
|
matches" is collapsed; tapping it reveals the rest, "weak" rows labeled and
|
||||||
|
lower rows unlabeled. With no strong matches, "No strong matches yet"
|
||||||
|
shows above the collapsed row, followed by how they'd hear about one:
|
||||||
|
unregistered, a notice to find someone to register them; registered
|
||||||
|
without a saved profile, or on a phone without a daily alert, a link to
|
||||||
|
the Profile page's follow-up card (`/account?nextStep=1`); with both, a
|
||||||
|
note that the daily alert is on; on the web with a saved profile, a note
|
||||||
|
that alerts are in the phone apps. With fewer than five nearby, "Farther
|
||||||
|
away" appears.
|
||||||
|
5. Go to the feed. The banner slides down once, the profile tab's red dot
|
||||||
|
pulses, and the banner collapses. Visit Discover and Projects: no banner;
|
||||||
|
the dot stays.
|
||||||
|
6. Tap the profile tab. The "Hear about new matches" card is at the top with
|
||||||
|
the interests filled in and "Include location" checked.
|
||||||
|
7. Tap "Save". The profile saves; "Public Profile" below shows the text and
|
||||||
|
the point. Profile → Search Area shows a box around the point if there was
|
||||||
|
none before.
|
||||||
|
8. Tap "Text message". Enter the phone number, enter the code, and accept the
|
||||||
|
time prompt, which starts at 9:00 AM. The card closes, and the dot is gone
|
||||||
|
on every page.
|
||||||
|
|
||||||
|
## 2. Invite deep link on a fresh install
|
||||||
|
|
||||||
|
1. Fresh install. Open an invite link (`invite-one-accept`) on the phone.
|
||||||
|
2. The invite flow runs to the contacts page without the prompt.
|
||||||
|
3. Tap the feed tab. The prompt opens now.
|
||||||
|
|
||||||
|
## 3. Unregistered identity, then registration
|
||||||
|
|
||||||
|
1. Fresh install with a new, unregistered identity. Go to the feed.
|
||||||
|
2. Enter interests with "Use my location" allowed; "Find matches" shows
|
||||||
|
matches the same way as for a registered identity.
|
||||||
|
3. No banner, no dot, and no card on the Profile page. "Public Profile" is
|
||||||
|
not shown (it needs registration).
|
||||||
|
4. Have someone register the identity. Open the feed: after the
|
||||||
|
registration check, the banner animates and the dot appears.
|
||||||
|
5. The card on the Profile page offers "Save" with the interests typed in
|
||||||
|
step 2. Save; the profile holds those interests.
|
||||||
|
|
||||||
|
## 4. "No thanks", then the profile editor
|
||||||
|
|
||||||
|
1. With the card showing on a registered identity that has no saved profile,
|
||||||
|
tap "No thanks". The card and dot go away.
|
||||||
|
2. Reload the Profile page. "Public Profile" is pre-filled with the
|
||||||
|
interests and point, with "Saved only on this phone. Not public until you
|
||||||
|
save your profile." and "Forget".
|
||||||
|
3. "Forget" empties the editor and the note goes away; reloading the page
|
||||||
|
keeps it empty.
|
||||||
|
|
||||||
|
## 5. Close, "Not now", and Help
|
||||||
|
|
||||||
|
1. Fresh install. When the prompt opens, close it with the "x". Visit
|
||||||
|
another tab and return to the feed: no prompt. Force-quit and reopen the
|
||||||
|
app: the prompt opens again.
|
||||||
|
2. Type something and tap "Not now". The prompt does not return on later
|
||||||
|
cold starts.
|
||||||
|
3. Help → "enter your interests again" opens the prompt with the text kept
|
||||||
|
from step 2. "Find matches" runs the flow again (results, then banner and
|
||||||
|
dot for a registered identity).
|
||||||
|
4. Help → "see the page-by-page help again" opens the welcome on the feed.
|
||||||
|
"Show me more!" goes to Discover with its page, then Projects with its
|
||||||
|
page. "Let's go!" closes it. Visiting those pages again opens nothing.
|
||||||
|
|
||||||
|
## 6. Existing profile on a reinstall
|
||||||
|
|
||||||
|
1. Fresh install, then import an identity that already has a saved public
|
||||||
|
profile with text.
|
||||||
|
2. Go to the feed. No prompt opens (the app found the server profile).
|
||||||
|
|
||||||
|
## 7. Server limits
|
||||||
|
|
||||||
|
1. Search many times within a minute from the results view ("Change", then
|
||||||
|
"Find matches"). After the limit, "Too many searches right now; try again
|
||||||
|
in a minute." shows with "Try again".
|
||||||
|
2. With the server's `EMBEDDING_QUERY_MAX_QUEUED=0`, a new uncached text
|
||||||
|
gives "Matching is busy; try again shortly."
|
||||||
|
3. In airplane mode, the results view shows the connection message and
|
||||||
|
"Try again"; the interests are still in Help's prompt afterward.
|
||||||
|
|
||||||
|
## 8. Web and desktop
|
||||||
|
|
||||||
|
1. In a fresh browser profile (or a private window), open the app. The
|
||||||
|
prompt opens over the feed with no keyboard-microphone tip, and the
|
||||||
|
privacy line says "this device".
|
||||||
|
2. "Use my location": the browser asks for permission. Deny it, and "Pick on
|
||||||
|
map" appears.
|
||||||
|
3. With a registered identity, "Find matches", then return to the feed: the
|
||||||
|
banner reads "Let people with interests like yours find you…", and the dot
|
||||||
|
appears.
|
||||||
|
4. The Profile card has only "Save to your public profile", with no alert
|
||||||
|
step. "Save" closes the card and the dot.
|
||||||
|
5. Help → "enter your interests again" is present, above the page-by-page
|
||||||
|
help link, and opens the prompt pre-filled.
|
||||||
@@ -74,6 +74,13 @@ Companion videos will also describe end-to-end workflows for the end-user.
|
|||||||
This section covers application usage for people who will use TimeSafari as intended. It is a
|
This section covers application usage for people who will use TimeSafari as intended. It is a
|
||||||
simplified guide illustrating how to gain value from using TimeSafari.
|
simplified guide illustrating how to gain value from using TimeSafari.
|
||||||
|
|
||||||
|
The first visit to the feed asks what you are interested in, in your own words, with an optional
|
||||||
|
location. The app shows the people and projects whose descriptions are closest in meaning, nearest
|
||||||
|
first. The interests stay on your device until you save them to your public profile. On the
|
||||||
|
Android and iOS apps, a daily New Activity alert (in-app or text message) can report new matches;
|
||||||
|
those matches come only from the saved public profile text, so an alert about them needs a saved
|
||||||
|
profile.
|
||||||
|
|
||||||
\pagebreak
|
\pagebreak
|
||||||
|
|
||||||
# 2 - Software Developers
|
# 2 - Software Developers
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<!-- CORS headers removed to allow images from any domain -->
|
<!-- CORS headers removed to allow images from any domain -->
|
||||||
|
|
||||||
<title>Time Safari</title>
|
<title>Giftopia</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
|
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -80,18 +80,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
|
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */,
|
|
||||||
);
|
|
||||||
explicitFileTypes = {
|
|
||||||
};
|
|
||||||
explicitFolders = (
|
|
||||||
);
|
|
||||||
path = TimeSafariShareExtension;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -340,11 +329,29 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
|
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Fix Privacy Manifest";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "$PROJECT_DIR/app_privacy_manifest_fixer/fixer.sh\n";
|
||||||
|
};
|
||||||
F1REBA5E000000000000A001 /* Bundle Firebase Config */ = {
|
F1REBA5E000000000000A001 /* Bundle Firebase Config */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
enableUserScriptSandboxing = 0;
|
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
@@ -365,7 +372,6 @@
|
|||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
enableUserScriptSandboxing = 0;
|
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
@@ -382,25 +388,6 @@
|
|||||||
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Fix Privacy Manifest";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "$PROJECT_DIR/app_privacy_manifest_fixer/fixer.sh\n";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -581,7 +568,7 @@
|
|||||||
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 = 76;
|
CURRENT_PROJECT_VERSION = 77;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -593,7 +580,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.10;
|
MARKETING_VERSION = 1.4.11;
|
||||||
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)";
|
||||||
@@ -612,7 +599,7 @@
|
|||||||
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 = 76;
|
CURRENT_PROJECT_VERSION = 77;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -624,7 +611,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.10;
|
MARKETING_VERSION = 1.4.11;
|
||||||
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 = "";
|
||||||
@@ -642,7 +629,7 @@
|
|||||||
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 = 76;
|
CURRENT_PROJECT_VERSION = 77;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
FIREBASE_ENABLED = YES;
|
FIREBASE_ENABLED = YES;
|
||||||
@@ -658,7 +645,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.10;
|
MARKETING_VERSION = 1.4.11;
|
||||||
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;
|
||||||
@@ -682,7 +669,7 @@
|
|||||||
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 = 76;
|
CURRENT_PROJECT_VERSION = 77;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
FIREBASE_ENABLED = YES;
|
FIREBASE_ENABLED = YES;
|
||||||
@@ -698,7 +685,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.10;
|
MARKETING_VERSION = 1.4.11;
|
||||||
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)";
|
||||||
|
|||||||
@@ -41,7 +41,11 @@
|
|||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
|
<string>Giftopia allows you to take photos, and also scan QR codes from contacts.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Giftopia uses your location to find people and projects near you. You may also choose to share it with others.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Giftopia uses your location to find people and projects near you. You may also choose to share it with others.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Time Safari allows you to upload photos.</string>
|
<string>Time Safari allows you to upload photos.</string>
|
||||||
<key>NSUserNotificationAlertStyle</key>
|
<key>NSUserNotificationAlertStyle</key>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def capacitor_pods
|
|||||||
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
||||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||||
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
|
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
|
||||||
|
pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation'
|
||||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,9 @@ PODS:
|
|||||||
- CapacitorFilesystem (7.1.8):
|
- CapacitorFilesystem (7.1.8):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- IONFilesystemLib (~> 1.1.1)
|
- IONFilesystemLib (~> 1.1.1)
|
||||||
|
- CapacitorGeolocation (7.1.8):
|
||||||
|
- Capacitor
|
||||||
|
- IONGeolocationLib (= 1.0.1)
|
||||||
- CapacitorMlkitBarcodeScanning (7.5.0):
|
- CapacitorMlkitBarcodeScanning (7.5.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- GoogleMLKit/BarcodeScanning (= 7.0.0)
|
- GoogleMLKit/BarcodeScanning (= 7.0.0)
|
||||||
@@ -52,6 +55,7 @@ PODS:
|
|||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GTMSessionFetcher/Core (3.5.0)
|
- GTMSessionFetcher/Core (3.5.0)
|
||||||
- IONFilesystemLib (1.1.2)
|
- IONFilesystemLib (1.1.2)
|
||||||
|
- IONGeolocationLib (1.0.1)
|
||||||
- MLImage (1.0.0-beta6)
|
- MLImage (1.0.0-beta6)
|
||||||
- MLKitBarcodeScanning (6.0.0):
|
- MLKitBarcodeScanning (6.0.0):
|
||||||
- MLKitCommon (~> 12.0)
|
- MLKitCommon (~> 12.0)
|
||||||
@@ -92,6 +96,7 @@ DEPENDENCIES:
|
|||||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
|
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
|
||||||
|
- "CapacitorGeolocation (from `../../node_modules/@capacitor/geolocation`)"
|
||||||
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
|
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
|
||||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||||
@@ -108,6 +113,7 @@ SPEC REPOS:
|
|||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- GTMSessionFetcher
|
- GTMSessionFetcher
|
||||||
- IONFilesystemLib
|
- IONFilesystemLib
|
||||||
|
- IONGeolocationLib
|
||||||
- MLImage
|
- MLImage
|
||||||
- MLKitBarcodeScanning
|
- MLKitBarcodeScanning
|
||||||
- MLKitCommon
|
- MLKitCommon
|
||||||
@@ -132,6 +138,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/ios"
|
:path: "../../node_modules/@capacitor/ios"
|
||||||
CapacitorFilesystem:
|
CapacitorFilesystem:
|
||||||
:path: "../../node_modules/@capacitor/filesystem"
|
:path: "../../node_modules/@capacitor/filesystem"
|
||||||
|
CapacitorGeolocation:
|
||||||
|
:path: "../../node_modules/@capacitor/geolocation"
|
||||||
CapacitorMlkitBarcodeScanning:
|
CapacitorMlkitBarcodeScanning:
|
||||||
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
|
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
|
||||||
CapacitorPreferences:
|
CapacitorPreferences:
|
||||||
@@ -155,6 +163,7 @@ SPEC CHECKSUMS:
|
|||||||
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
|
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
|
||||||
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
|
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
|
||||||
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
|
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
|
||||||
|
CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
|
||||||
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
|
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
|
||||||
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
|
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
|
||||||
CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319
|
CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319
|
||||||
@@ -167,6 +176,7 @@ SPEC CHECKSUMS:
|
|||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||||
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
||||||
|
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
|
||||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||||
@@ -177,6 +187,6 @@ SPEC CHECKSUMS:
|
|||||||
TimesafariDailyNotificationPlugin: b60e726f8776b5d22e026caef906f1c3b0e963dd
|
TimesafariDailyNotificationPlugin: b60e726f8776b5d22e026caef906f1c3b0e963dd
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
|
PODFILE CHECKSUM: 49f988dae1b57531e4bf9078f55c522b44b56ee7
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
Generated
+2030
-516
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.11-beta",
|
"version": "1.4.12-beta",
|
||||||
"description": "Giftopia App",
|
"description": "Giftopia App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Gift Economies Team"
|
"name": "Gift Economies Team"
|
||||||
@@ -160,6 +160,7 @@
|
|||||||
"@capacitor/clipboard": "^7.0.4",
|
"@capacitor/clipboard": "^7.0.4",
|
||||||
"@capacitor/core": "^7.6.4",
|
"@capacitor/core": "^7.6.4",
|
||||||
"@capacitor/filesystem": "^7.1.8",
|
"@capacitor/filesystem": "^7.1.8",
|
||||||
|
"@capacitor/geolocation": "^7.1.8",
|
||||||
"@capacitor/ios": "^7.6.4",
|
"@capacitor/ios": "^7.6.4",
|
||||||
"@capacitor/preferences": "^7.0.4",
|
"@capacitor/preferences": "^7.0.4",
|
||||||
"@capacitor/push-notifications": "^7.0.7",
|
"@capacitor/push-notifications": "^7.0.7",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default defineConfig({
|
|||||||
* },
|
* },
|
||||||
*
|
*
|
||||||
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
|
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
|
||||||
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
|
* It is worth considering a change such that Giftopia's default Endorser API server is NOT set
|
||||||
* in the user's settings so that it can be blanked out and the default is used.
|
* in the user's settings so that it can be blanked out and the default is used.
|
||||||
*/
|
*/
|
||||||
webServer: {
|
webServer: {
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<!--
|
||||||
|
InterestsPromptDialog.vue - the first question on every platform
|
||||||
|
|
||||||
|
Asks for interests in the person's own words and an optional point, keeps
|
||||||
|
both on the device (settings.onboardingState), and opens the results view.
|
||||||
|
Opened from HomeView on a first visit and from Help to search again.
|
||||||
|
See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog" data-testId="interestsPrompt">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-2 relative">
|
||||||
|
What would you like to find?
|
||||||
|
<div
|
||||||
|
class="text-lg text-center leading-none absolute right-0 -top-1 cursor-pointer"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="onClickClose()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="xmark" class="w-[1em]" />
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
<p class="text-center text-slate-600 mb-4">
|
||||||
|
Tell us your interests, and we'll look for people and projects nearby
|
||||||
|
that match.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-model="interests"
|
||||||
|
class="w-full h-28 p-2 border border-slate-300 rounded-md mb-2"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
aria-label="Your interests"
|
||||||
|
data-testId="interestsInput"
|
||||||
|
></textarea>
|
||||||
|
<p v-if="capabilities.isMobile" class="text-xs text-slate-500 mb-4">
|
||||||
|
Tip: tap the microphone on your keyboard to speak.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Location: optional -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div v-if="point" class="text-sm text-slate-700">
|
||||||
|
<font-awesome icon="location-dot" class="fa-fw text-slate-400" />
|
||||||
|
Near {{ pointText }}
|
||||||
|
<span class="text-blue-500 cursor-pointer ml-2" @click="removePoint()"
|
||||||
|
>remove</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isLocating" class="text-sm text-slate-500">
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||||
|
Finding your location…
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm bg-slate-200 text-slate-700 px-3 py-2 rounded-md"
|
||||||
|
@click="useMyLocation()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="location-dot" class="fa-fw" />
|
||||||
|
Use my location
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="locationFailed || showMap"
|
||||||
|
type="button"
|
||||||
|
class="text-sm bg-slate-200 text-slate-700 px-3 py-2 rounded-md"
|
||||||
|
@click="showMap = !showMap"
|
||||||
|
>
|
||||||
|
<font-awesome icon="location-dot" class="fa-fw" />
|
||||||
|
Pick on map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="locationFailed && !point" class="text-xs text-slate-500 mt-1">
|
||||||
|
Your location isn't available. You can pick a spot on the map, or
|
||||||
|
search without one.
|
||||||
|
</p>
|
||||||
|
<div v-if="showMap && !point" class="mt-2 aspect-video">
|
||||||
|
<l-map
|
||||||
|
class="!z-40 rounded-md"
|
||||||
|
:zoom="2"
|
||||||
|
:center="[20, 0]"
|
||||||
|
@click="onMapClick"
|
||||||
|
>
|
||||||
|
<l-tile-layer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
layer-type="base"
|
||||||
|
name="OpenStreetMap"
|
||||||
|
/>
|
||||||
|
</l-map>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-500 mb-4">
|
||||||
|
Your interests stay on this device. Searching sends them to the server
|
||||||
|
to find matches, but the server does not store them. You can choose to
|
||||||
|
add them to your public profile afterward.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full text-center text-md 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 disabled:opacity-50"
|
||||||
|
:disabled="!interests.trim()"
|
||||||
|
data-testId="findMatches"
|
||||||
|
@click="onClickFindMatches()"
|
||||||
|
>
|
||||||
|
Find matches
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||||
|
data-testId="closeOnboardingAndFinish"
|
||||||
|
@click="onClickNotNow()"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { Geolocation } from "@capacitor/geolocation";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasPoint,
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
roundCoordinate,
|
||||||
|
withDeclined,
|
||||||
|
withInterests,
|
||||||
|
} from "../libs/onboarding";
|
||||||
|
import { markInterestsPromptShown } from "../utils/onboardingNudge";
|
||||||
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: { LMap, LTileLayer },
|
||||||
|
emits: ["find-matches"],
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class InterestsPromptDialog extends Vue {
|
||||||
|
$route!: RouteLocationNormalizedLoaded;
|
||||||
|
$router!: Router;
|
||||||
|
|
||||||
|
interests = "";
|
||||||
|
isLocating = false;
|
||||||
|
locationFailed = false;
|
||||||
|
// from Help, "Not now" only closes: the flow's stage is left as it was
|
||||||
|
openedFromHelp = false;
|
||||||
|
point: { lat: number; lon: number } | null = null;
|
||||||
|
showMap = false;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
placeholder =
|
||||||
|
"For example: vegetable gardening, teaching kids to code, fixing bikes," +
|
||||||
|
" a weekly hiking group...";
|
||||||
|
|
||||||
|
get pointText(): string {
|
||||||
|
if (!this.point) return "";
|
||||||
|
return `${this.point.lat.toFixed(2)}, ${this.point.lon.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens pre-filled with whatever interests and point the phone kept. */
|
||||||
|
async open(options?: { fromHelp?: boolean }): Promise<void> {
|
||||||
|
this.openedFromHelp = !!options?.fromHelp;
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const state = parseOnboardingState(settings.onboardingState);
|
||||||
|
this.interests = state.interests || "";
|
||||||
|
this.point = hasPoint(state) ? { lat: state.lat, lon: state.lon } : null;
|
||||||
|
this.isLocating = false;
|
||||||
|
this.locationFailed = false;
|
||||||
|
this.showMap = false;
|
||||||
|
this.visible = true;
|
||||||
|
markInterestsPromptShown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async useMyLocation(): Promise<void> {
|
||||||
|
this.isLocating = true;
|
||||||
|
try {
|
||||||
|
// browsers ask for permission on the read itself, and the plugin's
|
||||||
|
// web version has no separate request
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
const permission = await Geolocation.requestPermissions({
|
||||||
|
permissions: ["coarseLocation"],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
permission.coarseLocation !== "granted" &&
|
||||||
|
permission.location !== "granted"
|
||||||
|
) {
|
||||||
|
throw new Error("Location permission: " + permission.coarseLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const position = await Geolocation.getCurrentPosition({
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
// only the rounded point is ever kept
|
||||||
|
this.point = {
|
||||||
|
lat: roundCoordinate(position.coords.latitude),
|
||||||
|
lon: roundCoordinate(position.coords.longitude),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("[InterestsPromptDialog] No location:", error);
|
||||||
|
this.locationFailed = true;
|
||||||
|
this.showMap = true;
|
||||||
|
} finally {
|
||||||
|
this.isLocating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMapClick(event: LeafletMouseEvent): void {
|
||||||
|
if (event?.latlng) {
|
||||||
|
this.point = {
|
||||||
|
lat: roundCoordinate(event.latlng.lat),
|
||||||
|
lon: roundCoordinate(event.latlng.lng),
|
||||||
|
};
|
||||||
|
this.showMap = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePoint(): void {
|
||||||
|
this.point = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the text and point first, so nothing after this can lose them. */
|
||||||
|
async onClickFindMatches(): Promise<void> {
|
||||||
|
if (!this.interests.trim()) return;
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const state = withInterests(
|
||||||
|
parseOnboardingState(settings.onboardingState),
|
||||||
|
this.interests,
|
||||||
|
this.point,
|
||||||
|
);
|
||||||
|
await this.$saveSettings(onboardingSettingsUpdate(state));
|
||||||
|
this.visible = false;
|
||||||
|
if (this.$route.name === "interest-matches") {
|
||||||
|
// the results view is underneath and searches again on this event
|
||||||
|
this.$emit("find-matches");
|
||||||
|
} else {
|
||||||
|
await this.$router.push({ name: "interest-matches" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClickNotNow(): Promise<void> {
|
||||||
|
if (this.openedFromHelp) {
|
||||||
|
this.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const state = withDeclined(
|
||||||
|
parseOnboardingState(settings.onboardingState),
|
||||||
|
new Date(),
|
||||||
|
{ interests: this.interests, point: this.point },
|
||||||
|
);
|
||||||
|
await this.$saveSettings(onboardingSettingsUpdate(state));
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "x": the stage stays, and Home asks again on the next cold start. */
|
||||||
|
onClickClose(): void {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!--
|
||||||
|
NextStepBanner.vue - the interests onboarding's one-time pointer to the
|
||||||
|
Profile page's next-step card. It slides down once for a registered person
|
||||||
|
who has seen their matches, pulses the profile tab's dot, then collapses;
|
||||||
|
the dot stays. Where the app has no alerts (web, desktop), it asks only for
|
||||||
|
the profile. See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<transition name="next-step-banner">
|
||||||
|
<div
|
||||||
|
v-if="showing"
|
||||||
|
class="mb-4 rounded-md bg-blue-600 text-white px-4 py-3 shadow cursor-pointer"
|
||||||
|
role="status"
|
||||||
|
data-testId="nextStepBanner"
|
||||||
|
@click="onClick()"
|
||||||
|
>
|
||||||
|
<template v-if="alertsOffered">
|
||||||
|
Want to hear when new matches appear? Save your interests and pick an
|
||||||
|
alert
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Let people with interests like yours find you: save your interests to
|
||||||
|
your profile
|
||||||
|
</template>
|
||||||
|
<font-awesome icon="arrow-right" class="fa-fw" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
offersAlerts,
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
shouldAnimateBanner,
|
||||||
|
withBannerShown,
|
||||||
|
} from "../libs/onboarding";
|
||||||
|
import { pulseNudge, refreshNudge } from "../utils/onboardingNudge";
|
||||||
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
|
|
||||||
|
/** How long the banner holds before collapsing toward the tab. */
|
||||||
|
const HOLD_MS = 5000;
|
||||||
|
|
||||||
|
@Component({ mixins: [PlatformServiceMixin] })
|
||||||
|
export default class NextStepBanner extends Vue {
|
||||||
|
$router!: Router;
|
||||||
|
|
||||||
|
/** When set, the page calls {@link check} itself, e.g. after it re-checks registration. */
|
||||||
|
@Prop({ default: false }) deferred!: boolean;
|
||||||
|
|
||||||
|
showing = false;
|
||||||
|
hideTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
get alertsOffered(): boolean {
|
||||||
|
return offersAlerts(this.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
if (!this.deferred) {
|
||||||
|
await this.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<void> {
|
||||||
|
if (this.showing) return;
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
refreshNudge(settings);
|
||||||
|
const state = parseOnboardingState(settings.onboardingState);
|
||||||
|
if (!shouldAnimateBanner(state, !!settings.isRegistered)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// spent as soon as it shows, so it never animates twice
|
||||||
|
await this.$saveSettings(
|
||||||
|
onboardingSettingsUpdate(withBannerShown(state, new Date())),
|
||||||
|
);
|
||||||
|
this.showing = true;
|
||||||
|
pulseNudge(HOLD_MS);
|
||||||
|
this.hideTimer = setTimeout(() => {
|
||||||
|
this.showing = false;
|
||||||
|
}, HOLD_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeUnmount(): void {
|
||||||
|
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(): void {
|
||||||
|
this.showing = false;
|
||||||
|
this.$router.push({ name: "account" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.next-step-banner-enter-active {
|
||||||
|
transition:
|
||||||
|
transform 0.4s ease-out,
|
||||||
|
opacity 0.4s ease-out;
|
||||||
|
}
|
||||||
|
.next-step-banner-leave-active {
|
||||||
|
transition:
|
||||||
|
transform 0.6s ease-in,
|
||||||
|
opacity 0.6s ease-in;
|
||||||
|
}
|
||||||
|
.next-step-banner-enter-from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
/* collapses down and right, toward the profile tab */
|
||||||
|
.next-step-banner-leave-to {
|
||||||
|
transform: translate(40%, 60vh) scale(0.1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<!--
|
||||||
|
NextStepCard.vue - the interests onboarding's follow-up, at the top of the
|
||||||
|
Profile page: save the interests to the public profile, then, where the
|
||||||
|
app has alerts (the phone apps), pick a daily alert. A thin layer: the
|
||||||
|
page runs the actions with the methods its own profile and notification
|
||||||
|
sections use, and this only shows them.
|
||||||
|
See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-blue-50 border border-blue-200 rounded-md px-4 py-4 mb-8"
|
||||||
|
aria-labelledby="nextStepHeading"
|
||||||
|
data-testId="nextStepCard"
|
||||||
|
>
|
||||||
|
<h2 id="nextStepHeading" class="font-bold mb-1">
|
||||||
|
{{
|
||||||
|
offerAlerts
|
||||||
|
? "Hear about new matches"
|
||||||
|
: "Let people with interests like yours find you"
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<p v-if="offerAlerts" class="text-sm text-slate-600 mb-4">
|
||||||
|
Two steps, and we'll let you know when someone with interests like yours
|
||||||
|
joins or posts.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-slate-600 mb-4">
|
||||||
|
Save your interests to your public profile, and people searching for
|
||||||
|
interests like yours can find you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<h3 class="font-semibold mb-1">
|
||||||
|
<font-awesome
|
||||||
|
v-if="profileSaved"
|
||||||
|
icon="check"
|
||||||
|
class="fa-fw text-green-600"
|
||||||
|
/>
|
||||||
|
<template v-if="offerAlerts">1.</template> Save to your public profile
|
||||||
|
</h3>
|
||||||
|
<p v-if="profileSaved" class="text-sm text-slate-600 mb-4">
|
||||||
|
Saved. You can change it any time under "Public Profile" below.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<textarea
|
||||||
|
v-model="text"
|
||||||
|
class="w-full h-24 p-2 border border-slate-300 rounded-md"
|
||||||
|
aria-label="Interests for your public profile"
|
||||||
|
data-testId="nextStepInterests"
|
||||||
|
></textarea>
|
||||||
|
<label v-if="pointText" class="flex items-center text-sm mb-2">
|
||||||
|
<input v-model="includeLocation" type="checkbox" class="mr-2" />
|
||||||
|
Include location (rounded to about 1 km): {{ pointText }}
|
||||||
|
</label>
|
||||||
|
<p v-if="offerAlerts" class="text-xs text-slate-500 mb-2">
|
||||||
|
Alerts about matches use your saved profile.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="w-full text-md 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-4 py-2 rounded-md mb-4 disabled:opacity-50"
|
||||||
|
:disabled="saving || !text.trim()"
|
||||||
|
data-testId="nextStepSave"
|
||||||
|
@click="$emit('save', { text: text.trim(), includeLocation })"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2, only where the app has alerts -->
|
||||||
|
<template v-if="offerAlerts">
|
||||||
|
<h3 class="font-semibold mb-1">
|
||||||
|
<font-awesome
|
||||||
|
v-if="smsOn || inAppOn"
|
||||||
|
icon="check"
|
||||||
|
class="fa-fw text-green-600"
|
||||||
|
/>
|
||||||
|
2. Get alerted
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
class="text-left px-4 py-2 rounded-md border"
|
||||||
|
:class="
|
||||||
|
smsDisabledReason
|
||||||
|
? 'border-slate-200 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'border-blue-300 bg-white'
|
||||||
|
"
|
||||||
|
:disabled="!!smsDisabledReason || smsOn"
|
||||||
|
data-testId="nextStepSms"
|
||||||
|
@click="$emit('sms')"
|
||||||
|
>
|
||||||
|
<font-awesome icon="message" class="fa-fw" />
|
||||||
|
Text message
|
||||||
|
<span class="text-xs uppercase text-green-700 ml-1"
|
||||||
|
>most reliable</span
|
||||||
|
>
|
||||||
|
<span v-if="smsOn" class="block text-sm text-green-700">
|
||||||
|
On, at {{ smsTime }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="smsDisabledReason" class="block text-xs">
|
||||||
|
{{ smsDisabledReason }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-left px-4 py-2 rounded-md border border-blue-300 bg-white"
|
||||||
|
:disabled="inAppOn"
|
||||||
|
data-testId="nextStepInApp"
|
||||||
|
@click="$emit('in-app')"
|
||||||
|
>
|
||||||
|
<font-awesome icon="comment" class="fa-fw" />
|
||||||
|
In-app notification
|
||||||
|
<span v-if="inAppOn" class="block text-sm text-green-700">
|
||||||
|
On, at {{ inAppTime }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="block text-xs text-slate-500">
|
||||||
|
May arrive late on iPhone.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'help-notification-types' }"
|
||||||
|
class="text-sm text-blue-500"
|
||||||
|
>
|
||||||
|
Which should I choose?
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="text-md 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-4 py-2 rounded-md"
|
||||||
|
data-testId="nextStepDone"
|
||||||
|
@click="$emit('done')"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
|
data-testId="nextStepNoThanks"
|
||||||
|
@click="$emit('done')"
|
||||||
|
>
|
||||||
|
No thanks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Watch } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
name: "NextStepCard",
|
||||||
|
emits: ["save", "sms", "in-app", "done"],
|
||||||
|
})
|
||||||
|
export default class NextStepCard extends Vue {
|
||||||
|
/** Show step 2, the daily alert: false where the app has no alerts */
|
||||||
|
@Prop({ default: true }) offerAlerts!: boolean;
|
||||||
|
@Prop({ default: "" }) interests!: string;
|
||||||
|
/** "lat, lon" of the kept point, or empty when there is none */
|
||||||
|
@Prop({ default: "" }) pointText!: string;
|
||||||
|
@Prop({ default: false }) profileSaved!: boolean;
|
||||||
|
@Prop({ default: false }) saving!: boolean;
|
||||||
|
@Prop({ default: false }) smsOn!: boolean;
|
||||||
|
@Prop({ default: "" }) smsTime!: string;
|
||||||
|
/** Why text messages can't be chosen here, or empty when they can */
|
||||||
|
@Prop({ default: "" }) smsDisabledReason!: string;
|
||||||
|
@Prop({ default: false }) inAppOn!: boolean;
|
||||||
|
@Prop({ default: "" }) inAppTime!: string;
|
||||||
|
|
||||||
|
text = "";
|
||||||
|
includeLocation = true;
|
||||||
|
|
||||||
|
created(): void {
|
||||||
|
this.text = this.interests;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("interests")
|
||||||
|
onInterestsChange(value: string): void {
|
||||||
|
if (!this.text) this.text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -83,7 +83,9 @@
|
|||||||
|
|
||||||
<p class="mt-4 flex items-center">
|
<p class="mt-4 flex items-center">
|
||||||
To see these instructions and more, click above on
|
To see these instructions and more, click above on
|
||||||
<span :class="helpBadgeClasses"> Help </span>
|
<span :class="helpBadgeClasses" aria-label="Help">
|
||||||
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,6 +202,11 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import { AppString, NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
|
import {
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
withWelcomeTour,
|
||||||
|
} from "../libs/onboarding";
|
||||||
import { OnboardPage } from "../libs/util";
|
import { OnboardPage } from "../libs/util";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
@@ -262,10 +269,10 @@ export default class OnboardingDialog extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for help badge styling
|
* CSS classes for the help badge: the same circle as the Help button
|
||||||
*/
|
*/
|
||||||
get helpBadgeClasses() {
|
get helpBadgeClasses() {
|
||||||
return "ml-1 mr-1 text-xs 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-1.5 py-1 rounded-md";
|
return "ml-1 mr-1 inline-block text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,12 +301,23 @@ export default class OnboardingDialog extends Vue {
|
|||||||
this.visible = true;
|
this.visible = true;
|
||||||
if (this.page === OnboardPage.Create) {
|
if (this.page === OnboardPage.Create) {
|
||||||
// we'll assume that they've been through all the other pages
|
// we'll assume that they've been through all the other pages
|
||||||
await this.$updateSettings({
|
await this.finishTour();
|
||||||
finishedOnboarding: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the tour. The pages open only as a tour started from Help; the
|
||||||
|
* interests prompt is the onboarding, so `finishedOnboarding` is its to set.
|
||||||
|
*/
|
||||||
|
private async finishTour() {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
await this.$saveSettings(
|
||||||
|
onboardingSettingsUpdate(
|
||||||
|
withWelcomeTour(parseOnboardingState(settings.onboardingState), false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes the onboarding dialog with optional completion actions
|
* Closes the onboarding dialog with optional completion actions
|
||||||
*
|
*
|
||||||
@@ -309,9 +327,7 @@ export default class OnboardingDialog extends Vue {
|
|||||||
async onClickClose(done?: boolean, goHome?: boolean) {
|
async onClickClose(done?: boolean, goHome?: boolean) {
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
if (done) {
|
if (done) {
|
||||||
await this.$updateSettings({
|
await this.finishTour();
|
||||||
finishedOnboarding: true,
|
|
||||||
});
|
|
||||||
if (goHome) {
|
if (goHome) {
|
||||||
this.$router.push({ name: "home" });
|
this.$router.push({ name: "home" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,9 +185,14 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
skipSchedule?: boolean;
|
skipSchedule?: boolean;
|
||||||
rolloverIntervalMinutes?: number;
|
rolloverIntervalMinutes?: number;
|
||||||
timeOnly?: boolean;
|
timeOnly?: boolean;
|
||||||
|
/** "HH:mm" (24-hour) to start the clock at, which the person can change */
|
||||||
|
defaultTime?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
this.callback = callback || this.callback;
|
this.callback = callback || this.callback;
|
||||||
|
if (options?.defaultTime) {
|
||||||
|
this.timeValue = options.defaultTime;
|
||||||
|
}
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.pushType = pushType;
|
this.pushType = pushType;
|
||||||
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
||||||
|
|||||||
@@ -92,7 +92,17 @@
|
|||||||
class="block text-center py-2 px-1"
|
class="block text-center py-2 px-1"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<font-awesome icon="circle-user" class="fa-fw" />
|
<span class="relative">
|
||||||
|
<font-awesome icon="circle-user" class="fa-fw" />
|
||||||
|
<!-- interests onboarding: points to the next-step card -->
|
||||||
|
<span
|
||||||
|
v-if="nudge.on"
|
||||||
|
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-red-600 ring-2 ring-slate-200"
|
||||||
|
:class="{ 'nudge-pulse': nudge.pulse }"
|
||||||
|
data-testId="profileNudgeDot"
|
||||||
|
aria-label="Next step waiting on your profile"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
<!--
|
<!--
|
||||||
We used to say "account", so we'll keep that in the code,
|
We used to say "account", so we'll keep that in the code,
|
||||||
but it isn't accurate because we don't hold anything for them.
|
but it isn't accurate because we don't hold anything for them.
|
||||||
@@ -111,8 +121,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
@Component({ name: "QuickNav" })
|
import { onboardingNudge, refreshNudge } from "../utils/onboardingNudge";
|
||||||
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
|
|
||||||
|
@Component({ name: "QuickNav", mixins: [PlatformServiceMixin] })
|
||||||
export default class QuickNav extends Vue {
|
export default class QuickNav extends Vue {
|
||||||
@Prop selected = "";
|
@Prop selected = "";
|
||||||
|
|
||||||
|
nudge = onboardingNudge;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
// once per launch; whatever changes the stage or registration after that
|
||||||
|
// updates the shared state itself
|
||||||
|
if (!onboardingNudge.loaded) {
|
||||||
|
refreshNudge(await this.$accountSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes nudge-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.nudge-pulse {
|
||||||
|
animation: nudge-pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -306,6 +306,22 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN newActivityCursors TEXT;
|
ALTER TABLE settings ADD COLUMN newActivityCursors TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "013_add_newActivityDelivery_to_settings",
|
||||||
|
sql: `
|
||||||
|
-- How in-app New Activity notifications arrive: 'server' (push from the
|
||||||
|
-- notification service, the default when blank) or 'device'
|
||||||
|
ALTER TABLE settings ADD COLUMN newActivityDelivery TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "014_add_onboardingState_to_settings",
|
||||||
|
sql: `
|
||||||
|
-- JSON of the mobile interests onboarding: stage, kept interests and
|
||||||
|
-- rounded point, and one-time flags (src/libs/onboarding.ts)
|
||||||
|
ALTER TABLE settings ADD COLUMN onboardingState TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export type Settings = {
|
|||||||
|
|
||||||
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
|
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
|
||||||
newActivityCursors?: string;
|
newActivityCursors?: string;
|
||||||
|
// JSON text of OnboardingState (src/libs/onboarding.ts): the mobile interests onboarding
|
||||||
|
onboardingState?: string;
|
||||||
|
// how in-app New Activity notifications arrive: "server" (push; the default when unset) or "device" (on-device schedule)
|
||||||
|
newActivityDelivery?: 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;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface AccountSettings {
|
|||||||
name: string;
|
name: string;
|
||||||
bbox: BoundingBox;
|
bbox: BoundingBox;
|
||||||
}>;
|
}>;
|
||||||
|
newActivityDelivery?: string;
|
||||||
notifyingNewActivityTime?: string;
|
notifyingNewActivityTime?: string;
|
||||||
notifyingNewActivitySmsPhone?: string;
|
notifyingNewActivitySmsPhone?: string;
|
||||||
notifyingNewActivitySmsTime?: string;
|
notifyingNewActivitySmsTime?: string;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const deepLinkPathSchemas = {
|
|||||||
"help-notifications": z.object({}),
|
"help-notifications": z.object({}),
|
||||||
"help-onboarding": z.object({}),
|
"help-onboarding": z.object({}),
|
||||||
"help-terms": z.object({}),
|
"help-terms": z.object({}),
|
||||||
|
"interest-matches": z.object({}),
|
||||||
"invite-one": z.object({}),
|
"invite-one": z.object({}),
|
||||||
"invite-one-accept": z.object({
|
"invite-one-accept": z.object({
|
||||||
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
|
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
|
||||||
@@ -103,6 +104,13 @@ export const deepLinkQuerySchemas = {
|
|||||||
searchPeople: z.string().optional(),
|
searchPeople: z.string().optional(),
|
||||||
hideOnboarding: z.string().optional(),
|
hideOnboarding: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
// Interests to search for, and an optional point to search around; without
|
||||||
|
// them the view uses the interests kept on the device.
|
||||||
|
"interest-matches": z.object({
|
||||||
|
text: z.string().optional(),
|
||||||
|
lat: z.string().optional(),
|
||||||
|
lon: z.string().optional(),
|
||||||
|
}),
|
||||||
// The start cursors a daily notification counted from, joined by "."
|
// The start cursors a daily notification counted from, joined by "."
|
||||||
"new-activity": z.object({
|
"new-activity": z.object({
|
||||||
c: z.string().optional(),
|
c: z.string().optional(),
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export interface AlertAuthorizationRequestBody {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
/** Exactly 100 entries. */
|
/** Exactly 100 entries. */
|
||||||
jwts: DelegatedAlertJwt[];
|
jwts: DelegatedAlertJwt[];
|
||||||
|
/**
|
||||||
|
* True stores the batch's JWT for a UTC day that already ran, so a reset can
|
||||||
|
* fire again today; absent skips such days. Sent by deliberate resets, not by
|
||||||
|
* the automatic re-upload after a New Activity visit. Each route honors it
|
||||||
|
* only while today's alert can still go out.
|
||||||
|
*/
|
||||||
|
rerunToday?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview How a confirmation names the claim it confirms
|
||||||
|
*
|
||||||
|
* This module depends only on ramda so that it stays loadable and testable
|
||||||
|
* without a database, a network client, or a build-time environment.
|
||||||
|
*
|
||||||
|
* @module confirmationTarget
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as R from "ramda";
|
||||||
|
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
// The lastClaimId is added even when there's an identifier, because one handle
|
||||||
|
// can hold different claims (eg. every Person claim about a DID shares that DID
|
||||||
|
// as its handle) and the server would otherwise take the latest one.
|
||||||
|
export function addLastClaimOrHandleAsIdIfMissing(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
obj: any,
|
||||||
|
lastClaimId?: string,
|
||||||
|
handleId?: string,
|
||||||
|
) {
|
||||||
|
if (!obj.lastClaimId && lastClaimId) {
|
||||||
|
const result = R.clone(obj);
|
||||||
|
result.lastClaimId = lastClaimId;
|
||||||
|
return result;
|
||||||
|
} else if (!obj.identifier && handleId) {
|
||||||
|
const result = R.clone(obj);
|
||||||
|
result.identifier = handleId;
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import { sha256 } from "ethereum-cryptography/sha256";
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
|
|
||||||
|
import { addLastClaimOrHandleAsIdIfMissing } from "./confirmationTarget";
|
||||||
|
|
||||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||||
import { NOTIFICATION_TIMEOUTS } from "../utils/notificationUtils";
|
import { NOTIFICATION_TIMEOUTS } from "../utils/notificationUtils";
|
||||||
import { createNotifyHelpers } from "../utils/notify";
|
import { createNotifyHelpers } from "../utils/notify";
|
||||||
@@ -112,6 +114,8 @@ export {
|
|||||||
addEndorserPrefix,
|
addEndorserPrefix,
|
||||||
stripEndorserPrefix,
|
stripEndorserPrefix,
|
||||||
} from "./endorserHandleIds";
|
} from "./endorserHandleIds";
|
||||||
|
// Same reason as above.
|
||||||
|
export { addLastClaimOrHandleAsIdIfMissing } from "./confirmationTarget";
|
||||||
|
|
||||||
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
|
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
|
||||||
{
|
{
|
||||||
@@ -233,26 +237,6 @@ export function removeSchemaContext(obj: any) {
|
|||||||
: obj;
|
: obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
// similar logic is found in endorser-mobile
|
|
||||||
export function addLastClaimOrHandleAsIdIfMissing(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
obj: any,
|
|
||||||
lastClaimId?: string,
|
|
||||||
handleId?: string,
|
|
||||||
) {
|
|
||||||
if (!obj.identifier && lastClaimId) {
|
|
||||||
const result = R.clone(obj);
|
|
||||||
result.lastClaimId = lastClaimId;
|
|
||||||
return result;
|
|
||||||
} else if (!obj.identifier && handleId) {
|
|
||||||
const result = R.clone(obj);
|
|
||||||
result.identifier = handleId;
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// return clone of object without any nested *VisibleToDids keys
|
// return clone of object without any nested *VisibleToDids keys
|
||||||
// similar code is also contained in endorser-mobile
|
// similar code is also contained in endorser-mobile
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
+79
-7
@@ -7,12 +7,13 @@
|
|||||||
* - The visit window, one cursor per server. The summary counts every bucket
|
* - The visit window, one cursor per server. The summary counts every bucket
|
||||||
* from its start, the same point the notification service counts from once
|
* from its start, the same point the notification service counts from once
|
||||||
* the visit is uploaded to it.
|
* the visit is uploaded to it.
|
||||||
* - A window per bucket, on that bucket's server. A detail page counts from its
|
* - A window per bucket, on that bucket's server. A detail page counts from the
|
||||||
* start, so its count is exact for that bucket.
|
* end of its last load, so opening it marks the bucket read.
|
||||||
*
|
*
|
||||||
* A window started less than {@link SAME_VISIT_MS} ago is the same visit: the
|
* A visit started less than {@link SAME_VISIT_MS} ago is the same visit: the
|
||||||
* next load counts from the same start, so going back and forth between the
|
* next summary load counts from the same start, so reloading the summary does
|
||||||
* summary and a detail page does not zero the counts.
|
* 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 { AlertSearchCursor } from "@/interfaces/alertSearch";
|
||||||
@@ -164,13 +165,14 @@ export interface CursorPair {
|
|||||||
/**
|
/**
|
||||||
* The span one load counted: from `from` (absent before any cursor was known)
|
* 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;
|
* up to `to`, the cursor the server returned. `at` is when the visit began;
|
||||||
* `seenAt` is when `to` was recorded.
|
* `seenAt` is when `to` was recorded, and `fromSeenAt` when `from` was.
|
||||||
*/
|
*/
|
||||||
export interface ActivityWindow<T> {
|
export interface ActivityWindow<T> {
|
||||||
from?: T;
|
from?: T;
|
||||||
to: T;
|
to: T;
|
||||||
at: string;
|
at: string;
|
||||||
seenAt?: string;
|
seenAt?: string;
|
||||||
|
fromSeenAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationChannel = "fcm" | "sms";
|
export type NotificationChannel = "fcm" | "sms";
|
||||||
@@ -232,13 +234,16 @@ export function nextWindow<T>(
|
|||||||
if (window && isSameVisit(window, now)) {
|
if (window && isSameVisit(window, now)) {
|
||||||
return {
|
return {
|
||||||
...(window.from !== undefined ? { from: window.from } : {}),
|
...(window.from !== undefined ? { from: window.from } : {}),
|
||||||
|
...(window.fromSeenAt ? { fromSeenAt: window.fromSeenAt } : {}),
|
||||||
to,
|
to,
|
||||||
at: window.at,
|
at: window.at,
|
||||||
seenAt: now.toISOString(),
|
seenAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...(window?.to !== undefined ? { from: window.to } : {}),
|
...(window?.to !== undefined
|
||||||
|
? { from: window.to, fromSeenAt: window.seenAt ?? window.at }
|
||||||
|
: {}),
|
||||||
to,
|
to,
|
||||||
at: now.toISOString(),
|
at: now.toISOString(),
|
||||||
seenAt: now.toISOString(),
|
seenAt: now.toISOString(),
|
||||||
@@ -250,6 +255,73 @@ function seenTime<T>(window: ActivityWindow<T> | undefined): number {
|
|||||||
return Number.isNaN(time) ? 0 : time;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the cursor that {@link windowStart} returns was recorded, as stored;
|
||||||
|
* undefined when there is no start or its time was never recorded (a first
|
||||||
|
* visit from a notification link).
|
||||||
|
*/
|
||||||
|
export function windowStartSeenAt<T>(
|
||||||
|
window: ActivityWindow<T> | undefined,
|
||||||
|
now: Date,
|
||||||
|
): string | undefined {
|
||||||
|
if (!window) return undefined;
|
||||||
|
if (isSameVisit(window, now)) {
|
||||||
|
return window.from !== undefined ? window.fromSeenAt : undefined;
|
||||||
|
}
|
||||||
|
return window.seenAt ?? window.at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state after the person marks everything up to `end` read: a visit that
|
||||||
|
* starts and ends at `end`, and no list windows, so the summary, Home and
|
||||||
|
* every list count from `end`.
|
||||||
|
*/
|
||||||
|
export function markedReadState(
|
||||||
|
state: NewActivityCursorState,
|
||||||
|
end: CursorPair,
|
||||||
|
now: Date,
|
||||||
|
): NewActivityCursorState {
|
||||||
|
const at = now.toISOString();
|
||||||
|
return {
|
||||||
|
...(state.uploaded ? { uploaded: state.uploaded } : {}),
|
||||||
|
visit: { from: end, fromSeenAt: at, to: end, at, seenAt: at },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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
|
* person saw last, the summary's or this bucket's own list. Unlike
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding by interests, on every platform.
|
||||||
|
*
|
||||||
|
* The state is one JSON settings column, `onboardingState`, per account:
|
||||||
|
* - `stage` moves `interests` (prompt pending) → `matches` (interests kept,
|
||||||
|
* results not shown yet) → `save` (results seen; the nudge to save a profile
|
||||||
|
* is on) → `done`. "Not now" on the prompt goes straight to
|
||||||
|
* `done`.
|
||||||
|
* - `interests`, `lat`, `lon` are the draft of a public profile. They are kept
|
||||||
|
* past `done` and cleared only by a profile save or "Forget"; `lat` and `lon`
|
||||||
|
* are always rounded ({@link roundCoordinate}).
|
||||||
|
* - `welcomeTour` makes Home, Discover, and Projects open the three welcome
|
||||||
|
* pages, which open only as a tour started from Help.
|
||||||
|
*
|
||||||
|
* The nudge asks for a saved profile everywhere, and for a daily alert only
|
||||||
|
* where the app offers alerts ({@link offersAlerts}).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BoundingBox } from "@/db/tables/settings";
|
||||||
|
|
||||||
|
export type OnboardingStage = "interests" | "matches" | "save" | "done";
|
||||||
|
|
||||||
|
export interface OnboardingState {
|
||||||
|
stage: OnboardingStage;
|
||||||
|
interests?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
matchesShownAt?: string | null;
|
||||||
|
bannerShownAt?: string | null;
|
||||||
|
declinedAt?: string | null;
|
||||||
|
welcomeTour?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four corners the partner server's location filters take. */
|
||||||
|
export interface LocBox {
|
||||||
|
minLocLat: number;
|
||||||
|
minLocLon: number;
|
||||||
|
maxLocLat: number;
|
||||||
|
maxLocLon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES: ReadonlyArray<OnboardingStage> = [
|
||||||
|
"interests",
|
||||||
|
"matches",
|
||||||
|
"save",
|
||||||
|
"done",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Radius of the area searched around the person's point. */
|
||||||
|
export const SEARCH_RADIUS_KM = 50;
|
||||||
|
|
||||||
|
const KM_PER_DEGREE_LAT = 111;
|
||||||
|
|
||||||
|
export function parseOnboardingState(
|
||||||
|
text: string | undefined | null,
|
||||||
|
): OnboardingState {
|
||||||
|
if (!text) return { stage: "interests" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== "object") return { stage: "interests" };
|
||||||
|
const stage = STAGES.includes(parsed.stage) ? parsed.stage : "interests";
|
||||||
|
return { ...parsed, stage };
|
||||||
|
} catch {
|
||||||
|
return { stage: "interests" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOnboardingState(state: OnboardingState): string {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings to write for a state: the column, plus `finishedOnboarding`
|
||||||
|
* once the stage is `done` so everything that reads that flag keeps working.
|
||||||
|
*/
|
||||||
|
export function onboardingSettingsUpdate(state: OnboardingState): {
|
||||||
|
onboardingState: string;
|
||||||
|
finishedOnboarding?: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
onboardingState: serializeOnboardingState(state),
|
||||||
|
...(state.stage === "done" ? { finishedOnboarding: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on the Android and iOS apps, the only ones with New Activity alerts,
|
||||||
|
* so the only ones where the nudge offers one.
|
||||||
|
*/
|
||||||
|
export function offersAlerts(capabilities: {
|
||||||
|
isNativeApp?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return !!capabilities?.isNativeApp && !!capabilities?.isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two decimals, about 1 km: the precision of everything stored or saved. */
|
||||||
|
export function roundCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPoint(
|
||||||
|
state: Pick<OnboardingState, "lat" | "lon">,
|
||||||
|
): state is { lat: number; lon: number } {
|
||||||
|
return typeof state.lat === "number" && typeof state.lon === "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Find matches": keep the text and the rounded point, before any network
|
||||||
|
* call. From `interests` or `done` the flow (re)starts at `matches`; from
|
||||||
|
* `matches` or `save` the stage stays, so a re-entry from Help doesn't undo
|
||||||
|
* a nudge already on.
|
||||||
|
*/
|
||||||
|
export function withInterests(
|
||||||
|
state: OnboardingState,
|
||||||
|
interests: string,
|
||||||
|
point: { lat: number; lon: number } | null,
|
||||||
|
): OnboardingState {
|
||||||
|
const stage =
|
||||||
|
state.stage === "interests" || state.stage === "done"
|
||||||
|
? "matches"
|
||||||
|
: state.stage;
|
||||||
|
const next: OnboardingState = {
|
||||||
|
...state,
|
||||||
|
stage,
|
||||||
|
interests: interests.trim(),
|
||||||
|
};
|
||||||
|
if (point) {
|
||||||
|
next.lat = roundCoordinate(point.lat);
|
||||||
|
next.lon = roundCoordinate(point.lon);
|
||||||
|
} else {
|
||||||
|
delete next.lat;
|
||||||
|
delete next.lon;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first successful results render turns the nudge on. */
|
||||||
|
export function withMatchesShown(
|
||||||
|
state: OnboardingState,
|
||||||
|
now: Date,
|
||||||
|
): OnboardingState {
|
||||||
|
if (state.stage !== "matches") return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stage: "save",
|
||||||
|
matchesShownAt: state.matchesShownAt || now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Not now" on the prompt. Text typed before declining is kept as a profile
|
||||||
|
* draft; with nothing typed, any earlier draft stays as it was.
|
||||||
|
*/
|
||||||
|
export function withDeclined(
|
||||||
|
state: OnboardingState,
|
||||||
|
now: Date,
|
||||||
|
typed?: { interests: string; point: { lat: number; lon: number } | null },
|
||||||
|
): OnboardingState {
|
||||||
|
const next: OnboardingState = {
|
||||||
|
...state,
|
||||||
|
stage: "done",
|
||||||
|
declinedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
if (typed && typed.interests.trim()) {
|
||||||
|
return {
|
||||||
|
...withInterests(next, typed.interests, typed.point),
|
||||||
|
stage: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Done", "No thanks", or both card steps finished. The draft stays. */
|
||||||
|
export function withDone(state: OnboardingState): OnboardingState {
|
||||||
|
return { ...state, stage: "done" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A saved profile or "Forget": the phone no longer holds a draft. */
|
||||||
|
export function withoutInterests(state: OnboardingState): OnboardingState {
|
||||||
|
const next = { ...state };
|
||||||
|
delete next.interests;
|
||||||
|
delete next.lat;
|
||||||
|
delete next.lon;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withBannerShown(
|
||||||
|
state: OnboardingState,
|
||||||
|
now: Date,
|
||||||
|
): OnboardingState {
|
||||||
|
return { ...state, bannerShownAt: state.bannerShownAt || now.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withWelcomeTour(
|
||||||
|
state: OnboardingState,
|
||||||
|
welcomeTour: boolean,
|
||||||
|
): OnboardingState {
|
||||||
|
return { ...state, welcomeTour };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dot, banner, and card: results seen, and registered to act on them. */
|
||||||
|
export function isNudgeOn(
|
||||||
|
state: OnboardingState,
|
||||||
|
isRegistered: boolean,
|
||||||
|
): boolean {
|
||||||
|
return state.stage === "save" && isRegistered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAnimateBanner(
|
||||||
|
state: OnboardingState,
|
||||||
|
isRegistered: boolean,
|
||||||
|
): boolean {
|
||||||
|
return isNudgeOn(state, isRegistered) && !state.bannerShownAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a page's welcome dialog opens: only during a tour started from Help. */
|
||||||
|
export function shouldOpenWelcome(settings: {
|
||||||
|
onboardingState?: string;
|
||||||
|
}): boolean {
|
||||||
|
return !!parseOnboardingState(settings.onboardingState).welcomeTour;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A box of about `radiusKm` around a point, the form the partner server
|
||||||
|
* filters on. Longitude is widened by 1/cos(lat) so the box stays square on
|
||||||
|
* the ground; near the poles it is capped at the whole range.
|
||||||
|
*/
|
||||||
|
export function boundingBoxAround(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
radiusKm: number = SEARCH_RADIUS_KM,
|
||||||
|
): LocBox {
|
||||||
|
const dLat = radiusKm / KM_PER_DEGREE_LAT;
|
||||||
|
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||||
|
const dLon = cosLat > 0.01 ? Math.min(dLat / cosLat, 180) : 180;
|
||||||
|
return {
|
||||||
|
minLocLat: Math.max(lat - dLat, -90),
|
||||||
|
maxLocLat: Math.min(lat + dLat, 90),
|
||||||
|
minLocLon: Math.max(lon - dLon, -180),
|
||||||
|
maxLocLon: Math.min(lon + dLon, 180),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same box as a search area (`settings.searchBoxes`). */
|
||||||
|
export function locBoxToBoundingBox(box: LocBox): BoundingBox {
|
||||||
|
return {
|
||||||
|
eastLong: box.maxLocLon,
|
||||||
|
maxLat: box.maxLocLat,
|
||||||
|
minLat: box.minLocLat,
|
||||||
|
westLong: box.minLocLon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The /similar score at and above which a match is shown by default. */
|
||||||
|
export const STRONG_SIMILARITY = 0.85;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short word for a /similar score, or "" for a weak match, which shows no
|
||||||
|
* label. The cuts follow the server's embedding spec: 0.78 is the search
|
||||||
|
* minimum and 0.85 the alert minimum.
|
||||||
|
*/
|
||||||
|
export function similarityLabel(similarity: number): "strong" | "weak" | "" {
|
||||||
|
if (similarity >= STRONG_SIMILARITY) return "strong";
|
||||||
|
if (similarity >= 0.81) return "weak";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the matches view says under "No strong matches yet":
|
||||||
|
* - `register`: not registered, so no saved profile and no alerts
|
||||||
|
* - `alertsOn`: a saved profile and a daily alert, so strong matches arrive
|
||||||
|
* - `setUp`: registered, missing the profile save or (on the phone apps) an
|
||||||
|
* alert; the Profile page's follow-up card does both
|
||||||
|
* - `profileOnly`: a saved profile where the app has no alerts (the web)
|
||||||
|
*/
|
||||||
|
export type NoStrongFollowUp =
|
||||||
|
| "register"
|
||||||
|
| "alertsOn"
|
||||||
|
| "setUp"
|
||||||
|
| "profileOnly";
|
||||||
|
|
||||||
|
export function noStrongFollowUp(input: {
|
||||||
|
isRegistered: boolean;
|
||||||
|
profileSaved: boolean;
|
||||||
|
alertsOn: boolean;
|
||||||
|
offersAlerts: boolean;
|
||||||
|
}): NoStrongFollowUp {
|
||||||
|
if (!input.isRegistered) return "register";
|
||||||
|
if (!input.profileSaved) return "setUp";
|
||||||
|
if (input.alertsOn) return "alertsOn";
|
||||||
|
return input.offersAlerts ? "setUp" : "profileOnly";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a match is strong enough to show without expanding. */
|
||||||
|
export function isStrongMatch(similarity: number): boolean {
|
||||||
|
return similarity >= STRONG_SIMILARITY;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { PlanData } from "@/interfaces/records";
|
||||||
|
import type { LocBox } from "./onboarding";
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
description: string;
|
description: string;
|
||||||
generateEmbedding?: boolean;
|
generateEmbedding?: boolean;
|
||||||
@@ -9,3 +12,140 @@ export interface UserProfile {
|
|||||||
issuerDid: string;
|
issuerDid: string;
|
||||||
rowId?: string; // set on profile retrieved from server
|
rowId?: string; // set on profile retrieved from server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the person has a public profile with text on the server, which
|
||||||
|
* alerts about matches use. A 404 (no profile) or any failure counts as none.
|
||||||
|
*/
|
||||||
|
export async function hasServerProfileText(
|
||||||
|
partnerApiServer: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
did: string,
|
||||||
|
fetchFn: typeof fetch = fetch,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(
|
||||||
|
`${partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(did)}`,
|
||||||
|
{ method: "GET", headers },
|
||||||
|
);
|
||||||
|
if (response.status !== 200) return false;
|
||||||
|
const body = await response.json();
|
||||||
|
return !!body?.data?.description?.trim();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One /api/partner/similar result, best first, with DIDs hidden. */
|
||||||
|
export type SimilarMatch =
|
||||||
|
| {
|
||||||
|
subjectType: "profile";
|
||||||
|
subjectId: string;
|
||||||
|
similarity: number;
|
||||||
|
record: UserProfile & { rowid?: number | string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
subjectType: "plan";
|
||||||
|
subjectId: string;
|
||||||
|
similarity: number;
|
||||||
|
record: PlanData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SimilarParams {
|
||||||
|
text: string;
|
||||||
|
to?: "profile" | "plan" | "both";
|
||||||
|
box?: LocBox | null;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a caller needs to decide between showing results and a retry. */
|
||||||
|
export type SimilarResult =
|
||||||
|
| { kind: "ok"; matches: SimilarMatch[] }
|
||||||
|
| { kind: "rateLimited"; message: string }
|
||||||
|
| { kind: "busy"; message: string }
|
||||||
|
| { kind: "network"; message: string }
|
||||||
|
| { kind: "error"; status: number; message: string };
|
||||||
|
|
||||||
|
export const SIMILAR_RATE_LIMITED_MESSAGE =
|
||||||
|
"Too many searches right now; try again in a minute.";
|
||||||
|
export const SIMILAR_BUSY_MESSAGE = "Matching is busy; try again shortly.";
|
||||||
|
export const SIMILAR_NETWORK_MESSAGE =
|
||||||
|
"Could not reach the matching server. Check your connection and try again.";
|
||||||
|
|
||||||
|
export function similarUrl(partnerApiServer: string, params: SimilarParams) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
from: "text:" + params.text,
|
||||||
|
to: params.to || "both",
|
||||||
|
limit: String(params.limit ?? 50),
|
||||||
|
});
|
||||||
|
if (params.minSimilarity != null) {
|
||||||
|
query.set("minSimilarity", String(params.minSimilarity));
|
||||||
|
}
|
||||||
|
if (params.box) {
|
||||||
|
query.set("minLocLat", String(params.box.minLocLat));
|
||||||
|
query.set("minLocLon", String(params.box.minLocLon));
|
||||||
|
query.set("maxLocLat", String(params.box.maxLocLat));
|
||||||
|
query.set("maxLocLon", String(params.box.maxLocLon));
|
||||||
|
}
|
||||||
|
return `${partnerApiServer}/api/partner/similar?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessageFrom(body: unknown): string | undefined {
|
||||||
|
const error = (body as { error?: unknown })?.error;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
const userMessage = (error as { userMessage?: unknown })?.userMessage;
|
||||||
|
return typeof userMessage === "string" ? userMessage : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search profiles and projects by the meaning of free text. Never throws:
|
||||||
|
* a 429, a 503 (the server's text queue is full), and an unreachable server
|
||||||
|
* each come back as their own kind so the caller can offer a retry.
|
||||||
|
*/
|
||||||
|
export async function fetchSimilar(
|
||||||
|
partnerApiServer: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
params: SimilarParams,
|
||||||
|
fetchFn: typeof fetch = fetch,
|
||||||
|
): Promise<SimilarResult> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchFn(similarUrl(partnerApiServer, params), {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { kind: "network", message: SIMILAR_NETWORK_MESSAGE };
|
||||||
|
}
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
// a proxy error page, or an empty body
|
||||||
|
}
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { kind: "rateLimited", message: SIMILAR_RATE_LIMITED_MESSAGE };
|
||||||
|
}
|
||||||
|
if (response.status === 503) {
|
||||||
|
return { kind: "busy", message: SIMILAR_BUSY_MESSAGE };
|
||||||
|
}
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
status: response.status,
|
||||||
|
message:
|
||||||
|
errorMessageFrom(body) ||
|
||||||
|
`The matching server answered with status ${response.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = (body as { data?: unknown })?.data;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
status: response.status,
|
||||||
|
message: "The matching server sent an unexpected answer.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "ok", matches: data as SimilarMatch[] };
|
||||||
|
}
|
||||||
|
|||||||
+25
-2
@@ -50,6 +50,10 @@ import {
|
|||||||
onNotificationAuthMayBeReady,
|
onNotificationAuthMayBeReady,
|
||||||
runLegacyApiNotificationsCleanupOnce,
|
runLegacyApiNotificationsCleanupOnce,
|
||||||
} from "@/services/notifications";
|
} from "@/services/notifications";
|
||||||
|
import { effectiveNewActivityDelivery } from "@/services/notifications/newActivityDelivery";
|
||||||
|
import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient";
|
||||||
|
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||||
|
import { retrieveSettingsForActiveAccount } from "@/db/databaseUtil";
|
||||||
|
|
||||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||||
@@ -364,8 +368,27 @@ setTimeout(async () => {
|
|||||||
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
||||||
// One-time: remove persisted legacy api_* schedules from pre-retirement installs
|
// One-time: remove persisted legacy api_* schedules from pre-retirement installs
|
||||||
await runLegacyApiNotificationsCleanupOnce();
|
await runLegacyApiNotificationsCleanupOnce();
|
||||||
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
|
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token,
|
||||||
await initializeNativePushAndFirebaseMessaging();
|
// delivery listeners). Skipped for on-device New Activity delivery, so the
|
||||||
|
// device is not registered with the notification service.
|
||||||
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
|
const delivery = effectiveNewActivityDelivery(
|
||||||
|
settings,
|
||||||
|
await serverPushSupported(),
|
||||||
|
);
|
||||||
|
if (delivery === "server") {
|
||||||
|
await initializeNativePushAndFirebaseMessaging();
|
||||||
|
}
|
||||||
|
// Exactly one New Activity notifier runs. Installs from before the
|
||||||
|
// delivery choice also have the on-device schedule; drop it where the
|
||||||
|
// server push is the one that runs.
|
||||||
|
if (settings.notifyingNewActivityTime && delivery === "server") {
|
||||||
|
try {
|
||||||
|
await DailyNotification.cancelDualSchedule?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("[Main] cancelDualSchedule failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
|
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
|
||||||
await configureNativeFetcherIfReady();
|
await configureNativeFetcherIfReady();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ function showInsecureContextError(): void {
|
|||||||
}
|
}
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<section style="max-width: 40rem; margin: 3rem auto; padding: 0 1rem; font-family: sans-serif; line-height: 1.5;">
|
<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>
|
<h1 style="font-size: 1.25rem; font-weight: 600;">TimeSafari Cannot Start Here</h1>
|
||||||
<p>This page must be opened over HTTPS or from <code>localhost</code>.</p>
|
<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>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>
|
<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>
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "import-derive",
|
name: "import-derive",
|
||||||
component: () => import("../views/ImportDerivedAccountView.vue"),
|
component: () => import("../views/ImportDerivedAccountView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/interest-matches",
|
||||||
|
name: "interest-matches",
|
||||||
|
component: () => import("../views/InterestMatchesView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/invite-one",
|
path: "/invite-one",
|
||||||
name: "invite-one",
|
name: "invite-one",
|
||||||
|
|||||||
+87
-19
@@ -26,7 +26,9 @@ import {
|
|||||||
type NewActivityCursorState,
|
type NewActivityCursorState,
|
||||||
seededState,
|
seededState,
|
||||||
startParams,
|
startParams,
|
||||||
|
summaryStart,
|
||||||
unseenStart,
|
unseenStart,
|
||||||
|
windowStart,
|
||||||
} from "@/libs/newActivity";
|
} from "@/libs/newActivity";
|
||||||
|
|
||||||
/** Pages read per server for the summary counts; past that a count shows "+". */
|
/** Pages read per server for the summary counts; past that a count shows "+". */
|
||||||
@@ -94,6 +96,8 @@ export interface ServerCounts {
|
|||||||
counts: Partial<Record<NewActivityBucket, number>>;
|
counts: Partial<Record<NewActivityBucket, number>>;
|
||||||
/** A count is a lower bound: the read stopped with more to go. */
|
/** A count is a lower bound: the read stopped with more to go. */
|
||||||
hitLimit: boolean;
|
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. */
|
/** Where this read ended: the start of the next visit. */
|
||||||
to?: AlertSearchCursor;
|
to?: AlertSearchCursor;
|
||||||
userMessage?: string;
|
userMessage?: string;
|
||||||
@@ -179,47 +183,111 @@ export interface UnseenCounts {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartGroup {
|
||||||
|
server: AlertSearchServer;
|
||||||
|
from?: AlertSearchCursor;
|
||||||
|
buckets: NewActivityBucket[];
|
||||||
|
result: ServerCounts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts for the Home screen: each bucket from {@link unseenStart}. Buckets on
|
* Counts for each available bucket from its own start. Buckets on the same
|
||||||
* the same server with the same start share one read, so a person who has
|
* server with the same start share one read.
|
||||||
* opened no list since the summary costs one read per server.
|
|
||||||
*/
|
*/
|
||||||
export async function countUnseenActivity(
|
async function countFromStarts(
|
||||||
ctx: NewActivityContext,
|
ctx: NewActivityContext,
|
||||||
state: NewActivityCursorState,
|
startOf: (bucket: NewActivityBucket) => AlertSearchCursor | undefined,
|
||||||
): Promise<UnseenCounts> {
|
): Promise<StartGroup[]> {
|
||||||
const groups = new Map<
|
const groups = new Map<string, Omit<StartGroup, "result">>();
|
||||||
string,
|
|
||||||
{
|
|
||||||
server: AlertSearchServer;
|
|
||||||
from?: AlertSearchCursor;
|
|
||||||
buckets: NewActivityBucket[];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
for (const bucket of NEW_ACTIVITY_BUCKETS) {
|
for (const bucket of NEW_ACTIVITY_BUCKETS) {
|
||||||
if (!bucketIsAvailable(ctx, bucket)) continue;
|
if (!bucketIsAvailable(ctx, bucket)) continue;
|
||||||
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
const from = unseenStart(state, bucket);
|
const from = startOf(bucket);
|
||||||
const key = server + " " + (from ?? "");
|
const key = server + " " + (from ?? "");
|
||||||
const group = groups.get(key) ?? { server, from, buckets: [] };
|
const group = groups.get(key) ?? { server, from, buckets: [] };
|
||||||
group.buckets.push(bucket);
|
group.buckets.push(bucket);
|
||||||
groups.set(key, group);
|
groups.set(key, group);
|
||||||
}
|
}
|
||||||
const counted = await Promise.all(
|
return Promise.all(
|
||||||
[...groups.values()].map(async (group) => ({
|
[...groups.values()].map(async (group) => ({
|
||||||
group,
|
...group,
|
||||||
result: await countServer(ctx, group.server, group.from),
|
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: [] };
|
const unseen: UnseenCounts = { buckets: {}, errors: [] };
|
||||||
for (const { group, result } of counted) {
|
for (const { buckets, result } of counted) {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (!unseen.errors.includes(result.error)) {
|
if (!unseen.errors.includes(result.error)) {
|
||||||
unseen.errors.push(result.error);
|
unseen.errors.push(result.error);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const bucket of group.buckets) {
|
for (const bucket of buckets) {
|
||||||
unseen.buckets[bucket] = {
|
unseen.buckets[bucket] = {
|
||||||
count: result.counts[bucket] ?? 0,
|
count: result.counts[bucket] ?? 0,
|
||||||
hitLimit: result.hitLimit,
|
hitLimit: result.hitLimit,
|
||||||
|
|||||||
@@ -80,6 +80,30 @@ export async function registerToken(fcmToken: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every device registration the notification service holds for the
|
||||||
|
* active DID, for someone who chose on-device New Activity notifications.
|
||||||
|
* Throws when the service refuses or cannot be reached.
|
||||||
|
*/
|
||||||
|
export async function unregisterDevicesFromNotificationApi(): Promise<void> {
|
||||||
|
const auth = await getNotificationApiHeaders();
|
||||||
|
if (!auth.ok) {
|
||||||
|
throw new Error(`unregister auth unavailable: ${auth.message}`);
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`${getNotificationApiBaseUrl()}/notifications/register`,
|
||||||
|
{ method: "DELETE", headers: auth.headers },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
notificationApiFailureMessage(
|
||||||
|
res.status,
|
||||||
|
await readNotificationApiBody(res),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for scheduling a daily notification
|
* Options for scheduling a daily notification
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ jest.mock("@/libs/crypto/vc", () => ({
|
|||||||
jest.mock("./alertAuthorizationBatch", () => ({
|
jest.mock("./alertAuthorizationBatch", () => ({
|
||||||
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
|
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
|
||||||
mintAlertAuthorizationBatch: jest.fn(),
|
mintAlertAuthorizationBatch: jest.fn(),
|
||||||
|
localTimeTextToUtc: (text: string) =>
|
||||||
|
text === "8:30 PM" ? { notifyHourUtc: 2, notifyMinuteUtc: 30 } : null,
|
||||||
}));
|
}));
|
||||||
jest.mock("./NotificationDebugConfig", () => ({
|
jest.mock("./NotificationDebugConfig", () => ({
|
||||||
getNotificationApiBaseUrl: jest.fn(),
|
getNotificationApiBaseUrl: jest.fn(),
|
||||||
@@ -145,6 +147,43 @@ describe("uploadAlertSearchAuthorization", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the picked time, and rerunToday only for a reset", async () => {
|
||||||
|
const { dependencies, fetchMock } = createDependencies();
|
||||||
|
await uploadAlertSearchAuthorization(
|
||||||
|
dependencies,
|
||||||
|
{ endorserAfterCursor: "100" },
|
||||||
|
{ timeText: "8:30 PM", rerunToday: true },
|
||||||
|
);
|
||||||
|
const reset = JSON.parse(String(fetchMock.mock.calls[0][1].body));
|
||||||
|
expect(reset.notifyHourUtc).toBe(2);
|
||||||
|
expect(reset.notifyMinuteUtc).toBe(30);
|
||||||
|
expect(reset.rerunToday).toBe(true);
|
||||||
|
expect(reset.endorserAfterCursor).toBe("100");
|
||||||
|
|
||||||
|
await uploadAlertSearchAuthorization(dependencies, undefined, {
|
||||||
|
timeText: "8:30 PM",
|
||||||
|
});
|
||||||
|
const refresh = JSON.parse(String(fetchMock.mock.calls[1][1].body));
|
||||||
|
expect(refresh.notifyHourUtc).toBe(2);
|
||||||
|
expect(refresh).not.toHaveProperty("rerunToday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit an unreadable time", async () => {
|
||||||
|
const { dependencies, fetchMock } = createDependencies();
|
||||||
|
const result = await uploadAlertSearchAuthorization(
|
||||||
|
dependencies,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
timeText: "whenever",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
errorMessage: "Unreadable notification time: whenever",
|
||||||
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not log delegated JWT contents", async () => {
|
it("does not log delegated JWT contents", async () => {
|
||||||
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
||||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
* Push-channel AlertSearch authorization against notification-wakeup-service.
|
* Push-channel AlertSearch authorization against notification-wakeup-service.
|
||||||
*
|
*
|
||||||
* Upload mints a delegated JWT batch for the active identity and
|
* Upload mints a delegated JWT batch for the active identity and
|
||||||
* `POST /notifications/alert-authorization`, with the send time fixed at
|
* `POST /notifications/alert-authorization`, with the send time the person
|
||||||
* 09:00 in the device's zone. Revoke sends `DELETE` on that same route and
|
* picked for in-app New Activity notifications, converted to UTC. Without a
|
||||||
* does not unregister the FCM device. The debug panel uploads manually; the
|
* time (the debug panel) it sends 09:00 in the device's zone. Revoke sends
|
||||||
* in-app New Activity toggle uploads on enable and revokes on disable.
|
* `DELETE` on that same route and does not unregister the FCM device. The
|
||||||
|
* in-app New Activity toggle uploads on enable and on a new time, and revokes
|
||||||
|
* on disable.
|
||||||
*
|
*
|
||||||
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
|
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
|
||||||
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
|
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
|
||||||
@@ -20,6 +22,7 @@ import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
|||||||
import {
|
import {
|
||||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||||
type AlertAuthorizationBatch,
|
type AlertAuthorizationBatch,
|
||||||
|
localTimeTextToUtc,
|
||||||
mintAlertAuthorizationBatch,
|
mintAlertAuthorizationBatch,
|
||||||
} from "./alertAuthorizationBatch";
|
} from "./alertAuthorizationBatch";
|
||||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||||
@@ -70,7 +73,7 @@ export interface AlertAuthorizationDependencies {
|
|||||||
getActiveDid: () => Promise<string | null>;
|
getActiveDid: () => Promise<string | null>;
|
||||||
isAuthBypassEnabled: () => boolean;
|
isAuthBypassEnabled: () => boolean;
|
||||||
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
|
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
|
||||||
/** IANA zone whose 09:00 becomes the batch's UTC send time. */
|
/** IANA zone sent with the batch; its 09:00 is the default send time. */
|
||||||
getTimeZone: () => string;
|
getTimeZone: () => string;
|
||||||
getAuthHeaders: typeof getNotificationApiHeaders;
|
getAuthHeaders: typeof getNotificationApiHeaders;
|
||||||
getBaseUrl: () => string;
|
getBaseUrl: () => string;
|
||||||
@@ -87,10 +90,25 @@ const defaultDependencies: AlertAuthorizationDependencies = {
|
|||||||
fetch: (input, init) => globalThis.fetch(input, init),
|
fetch: (input, init) => globalThis.fetch(input, init),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** How an upload schedules the batch. */
|
||||||
|
export interface AlertAuthorizationUploadOptions {
|
||||||
|
/**
|
||||||
|
* 12-hour local reading from the time picker, such as "8:30 PM". Absent:
|
||||||
|
* 09:00 in the device's zone.
|
||||||
|
*/
|
||||||
|
timeText?: string;
|
||||||
|
/**
|
||||||
|
* A deliberate reset (turning the channel on, a new time): today's run may
|
||||||
|
* go again even after it ran. Left out of the re-upload after a visit.
|
||||||
|
*/
|
||||||
|
rerunToday?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRequestBody(
|
function buildRequestBody(
|
||||||
batch: AlertAuthorizationBatch,
|
batch: AlertAuthorizationBatch,
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
searchState: AlertSearchUploadState = {},
|
searchState: AlertSearchUploadState = {},
|
||||||
|
options: AlertAuthorizationUploadOptions = {},
|
||||||
): AlertAuthorizationRequestBody & 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(
|
||||||
@@ -98,7 +116,13 @@ function buildRequestBody(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone);
|
const notifyTime =
|
||||||
|
options.timeText === undefined
|
||||||
|
? alertSearchNotifyTimeUtcFromLocalNineAm(timeZone)
|
||||||
|
: localTimeTextToUtc(options.timeText);
|
||||||
|
if (!notifyTime) {
|
||||||
|
throw new Error("Unreadable notification time: " + options.timeText);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
batchId: batch.batchId,
|
batchId: batch.batchId,
|
||||||
@@ -107,6 +131,7 @@ function buildRequestBody(
|
|||||||
timezone: timeZone,
|
timezone: timeZone,
|
||||||
jwts: batch.jwts,
|
jwts: batch.jwts,
|
||||||
...searchState,
|
...searchState,
|
||||||
|
...(options.rerunToday ? { rerunToday: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,10 +236,12 @@ async function authorizationHttpFailure(response: Response): Promise<{
|
|||||||
*
|
*
|
||||||
* @param searchState cursors to count from, and the starred handle IDs and
|
* @param searchState cursors to count from, and the starred handle IDs and
|
||||||
* area the daily search uses; see {@link AlertSearchUploadState}
|
* area the daily search uses; see {@link AlertSearchUploadState}
|
||||||
|
* @param options the send time and whether this is a reset
|
||||||
*/
|
*/
|
||||||
export async function uploadAlertSearchAuthorization(
|
export async function uploadAlertSearchAuthorization(
|
||||||
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
||||||
searchState?: AlertSearchUploadState,
|
searchState?: AlertSearchUploadState,
|
||||||
|
options?: AlertAuthorizationUploadOptions,
|
||||||
): Promise<AlertAuthorizationUploadResult> {
|
): Promise<AlertAuthorizationUploadResult> {
|
||||||
try {
|
try {
|
||||||
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
|
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
|
||||||
@@ -224,7 +251,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, searchState);
|
const body = buildRequestBody(batch, timeZone, searchState, options);
|
||||||
|
|
||||||
const response = await dependencies.fetch(
|
const response = await dependencies.fetch(
|
||||||
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function registerRetrievedToken(
|
|||||||
* config, and register() there throws natively, crashing the app before any
|
* config, and register() there throws natively, crashing the app before any
|
||||||
* JS catch can run.
|
* JS catch can run.
|
||||||
*/
|
*/
|
||||||
async function canRegisterNativePush(): Promise<boolean> {
|
export async function canRegisterNativePush(): Promise<boolean> {
|
||||||
if (Capacitor.getPlatform() !== "android") {
|
if (Capacitor.getPlatform() !== "android") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,15 @@ async function canRegisterNativePush(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this build can receive New Activity server pushes: a native app
|
||||||
|
* whose build includes Firebase. iOS builds are assumed to; the FOSS opt-out
|
||||||
|
* is detected on Android only.
|
||||||
|
*/
|
||||||
|
export async function serverPushSupported(): Promise<boolean> {
|
||||||
|
return Capacitor.isNativePlatform() && (await canRegisterNativePush());
|
||||||
|
}
|
||||||
|
|
||||||
/** Most recent FCM token from native/web push registration (for debug UI). */
|
/** Most recent FCM token from native/web push registration (for debug UI). */
|
||||||
export function getLastKnownFcmToken(): string | null {
|
export function getLastKnownFcmToken(): string | null {
|
||||||
return lastSeenFcmToken ?? lastRegisteredFcmToken;
|
return lastSeenFcmToken ?? lastRegisteredFcmToken;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* How in-app New Activity notifications arrive.
|
||||||
|
*
|
||||||
|
* - "server": the notification service runs the daily alertSearch with the
|
||||||
|
* person's delegated JWTs and pushes the count through FCM. The default.
|
||||||
|
* - "device": the daily-notification-plugin fetches and notifies on the
|
||||||
|
* device. The notification service holds no JWTs, cursors, search area or
|
||||||
|
* device registration for the person.
|
||||||
|
*
|
||||||
|
* Exactly one of them runs. A build without Firebase (FOSS / F-Droid) can
|
||||||
|
* only use "device"; `serverPushSupported` in firebaseMessagingClient says
|
||||||
|
* which build this is. "server" is offered only to a registered identity with
|
||||||
|
* general advanced functions on; everyone else gets "device".
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NewActivityDelivery = "server" | "device";
|
||||||
|
|
||||||
|
/** The stored choice, with anything unset or unknown meaning the default. */
|
||||||
|
export function storedNewActivityDelivery(settings: {
|
||||||
|
newActivityDelivery?: string;
|
||||||
|
}): NewActivityDelivery {
|
||||||
|
return settings.newActivityDelivery === "device" ? "device" : "server";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delivery that actually runs: "device" wherever push is unavailable, and
|
||||||
|
* wherever "server" is not offered. A "server" delivery already on keeps
|
||||||
|
* running, so turning it off revokes its batch.
|
||||||
|
*/
|
||||||
|
export function effectiveNewActivityDelivery(
|
||||||
|
settings: {
|
||||||
|
isRegistered?: boolean;
|
||||||
|
newActivityDelivery?: string;
|
||||||
|
notifyingNewActivityTime?: string;
|
||||||
|
showGeneralAdvanced?: boolean;
|
||||||
|
},
|
||||||
|
serverPushSupported: boolean,
|
||||||
|
): NewActivityDelivery {
|
||||||
|
if (
|
||||||
|
!serverPushSupported ||
|
||||||
|
storedNewActivityDelivery(settings) === "device"
|
||||||
|
) {
|
||||||
|
return "device";
|
||||||
|
}
|
||||||
|
const offered = !!settings.isRegistered && !!settings.showGeneralAdvanced;
|
||||||
|
return offered || settings.notifyingNewActivityTime ? "server" : "device";
|
||||||
|
}
|
||||||
@@ -12,23 +12,26 @@ import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
|||||||
import {
|
import {
|
||||||
type AlertAuthorizationDependencies,
|
type AlertAuthorizationDependencies,
|
||||||
type AlertAuthorizationRevokeResult,
|
type AlertAuthorizationRevokeResult,
|
||||||
|
type AlertAuthorizationUploadOptions,
|
||||||
type AlertAuthorizationUploadResult,
|
type AlertAuthorizationUploadResult,
|
||||||
revokeAlertSearchAuthorization,
|
revokeAlertSearchAuthorization,
|
||||||
uploadAlertSearchAuthorization,
|
uploadAlertSearchAuthorization,
|
||||||
} from "./alertAuthorization";
|
} from "./alertAuthorization";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload the AlertSearch batch after the in-app setting has been enabled.
|
* Upload the AlertSearch batch when the in-app setting is enabled or given a
|
||||||
* A failure is reported and returned; it does not throw.
|
* new time. A failure is reported and returned; it does not throw.
|
||||||
*/
|
*/
|
||||||
export async function authorizeInAppNewActivity(
|
export async function authorizeInAppNewActivity(
|
||||||
reportError: (message: string) => void,
|
reportError: (message: string) => void,
|
||||||
dependencies?: AlertAuthorizationDependencies,
|
dependencies?: AlertAuthorizationDependencies,
|
||||||
searchState?: AlertSearchUploadState,
|
searchState?: AlertSearchUploadState,
|
||||||
|
options?: AlertAuthorizationUploadOptions,
|
||||||
): Promise<AlertAuthorizationUploadResult> {
|
): Promise<AlertAuthorizationUploadResult> {
|
||||||
const result = await uploadAlertSearchAuthorization(
|
const result = await uploadAlertSearchAuthorization(
|
||||||
dependencies,
|
dependencies,
|
||||||
searchState,
|
searchState,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
reportError(result.errorMessage);
|
reportError(result.errorMessage);
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import { logger } from "@/utils/logger";
|
|||||||
import { localTimeTextToUtc } from "./alertAuthorizationBatch";
|
import { localTimeTextToUtc } from "./alertAuthorizationBatch";
|
||||||
import { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
import { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
||||||
import { authorizeSmsAlertSearch } from "./smsNotificationApi";
|
import { authorizeSmsAlertSearch } from "./smsNotificationApi";
|
||||||
|
import { storedNewActivityDelivery } from "./newActivityDelivery";
|
||||||
|
|
||||||
/** The settings that say which channels are on. */
|
/** The settings that say which channels are on. */
|
||||||
export interface NotificationChannelSettings {
|
export interface NotificationChannelSettings {
|
||||||
|
newActivityDelivery?: string;
|
||||||
notifyingNewActivityTime?: string;
|
notifyingNewActivityTime?: string;
|
||||||
notifyingNewActivitySmsTime?: string;
|
notifyingNewActivitySmsTime?: string;
|
||||||
smsNotificationsEnabled?: boolean;
|
smsNotificationsEnabled?: boolean;
|
||||||
@@ -26,7 +28,13 @@ export function enabledNotificationChannels(
|
|||||||
settings: NotificationChannelSettings,
|
settings: NotificationChannelSettings,
|
||||||
): NotificationChannel[] {
|
): NotificationChannel[] {
|
||||||
const channels: NotificationChannel[] = [];
|
const channels: NotificationChannel[] = [];
|
||||||
if (settings.notifyingNewActivityTime) channels.push("fcm");
|
// on-device delivery keeps nothing on the service to refresh
|
||||||
|
if (
|
||||||
|
settings.notifyingNewActivityTime &&
|
||||||
|
storedNewActivityDelivery(settings) === "server"
|
||||||
|
) {
|
||||||
|
channels.push("fcm");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
settings.smsNotificationsEnabled &&
|
settings.smsNotificationsEnabled &&
|
||||||
settings.notifyingNewActivitySmsTime
|
settings.notifyingNewActivitySmsTime
|
||||||
@@ -37,7 +45,10 @@ export function enabledNotificationChannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationRefreshDependencies {
|
export interface NotificationRefreshDependencies {
|
||||||
uploadFcm: (state: AlertSearchUploadState) => Promise<boolean>;
|
uploadFcm: (
|
||||||
|
state: AlertSearchUploadState,
|
||||||
|
timeText: string,
|
||||||
|
) => Promise<boolean>;
|
||||||
uploadSms: (
|
uploadSms: (
|
||||||
did: string,
|
did: string,
|
||||||
notifyHourUtc: number,
|
notifyHourUtc: number,
|
||||||
@@ -47,8 +58,10 @@ export interface NotificationRefreshDependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultDependencies: NotificationRefreshDependencies = {
|
const defaultDependencies: NotificationRefreshDependencies = {
|
||||||
uploadFcm: async (state) => {
|
uploadFcm: async (state, timeText) => {
|
||||||
const result = await uploadAlertSearchAuthorization(undefined, state);
|
const result = await uploadAlertSearchAuthorization(undefined, state, {
|
||||||
|
timeText,
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"[NewActivityRefresh] Push re-authorization failed:",
|
"[NewActivityRefresh] Push re-authorization failed:",
|
||||||
@@ -82,7 +95,15 @@ export async function refreshNotificationAuthorizations(
|
|||||||
try {
|
try {
|
||||||
let ok = false;
|
let ok = false;
|
||||||
if (channel === "fcm") {
|
if (channel === "fcm") {
|
||||||
ok = await dependencies.uploadFcm(state);
|
const timeText = settings.notifyingNewActivityTime || "";
|
||||||
|
if (!localTimeTextToUtc(timeText)) {
|
||||||
|
logger.warn(
|
||||||
|
"[NewActivityRefresh] Unreadable push time; push not re-authorized:",
|
||||||
|
settings.notifyingNewActivityTime,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ok = await dependencies.uploadFcm(state, timeText);
|
||||||
} else {
|
} else {
|
||||||
const utc = localTimeTextToUtc(
|
const utc = localTimeTextToUtc(
|
||||||
settings.notifyingNewActivitySmsTime || "",
|
settings.notifyingNewActivitySmsTime || "",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Read-only SQL access to the daily-notification-plugin's own SQLite database.
|
||||||
|
*
|
||||||
|
* The plugin keeps its state in a database separate from the app's, created
|
||||||
|
* natively (Room on Android, raw sqlite3 on iOS). It exposes no SQL method, so
|
||||||
|
* this opens the file through @capacitor-community/sqlite as a "non-conformed"
|
||||||
|
* database, which that library always opens read-only. Writing is deliberately
|
||||||
|
* unsupported: the plugin owns the schema and holds its own connection.
|
||||||
|
*
|
||||||
|
* Locations (both resolve from the sqlite plugin's "default" folder):
|
||||||
|
* - Android: <app data>/databases/daily_notification_plugin.db
|
||||||
|
* - iOS: <app>/Documents/daily_notifications.db
|
||||||
|
*
|
||||||
|
* @see https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite";
|
||||||
|
|
||||||
|
const DATABASE_FILE_BY_PLATFORM: Record<string, string> = {
|
||||||
|
android: "daily_notification_plugin.db",
|
||||||
|
ios: "daily_notifications.db",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tables the plugin creates on each platform, for query presets. */
|
||||||
|
export const NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM: Record<string, string[]> =
|
||||||
|
{
|
||||||
|
android: [
|
||||||
|
"schedules",
|
||||||
|
"content_cache",
|
||||||
|
"callbacks",
|
||||||
|
"history",
|
||||||
|
"notification_content",
|
||||||
|
"notification_delivery",
|
||||||
|
"notification_config",
|
||||||
|
],
|
||||||
|
ios: ["notif_contents", "notif_deliveries", "notif_config"],
|
||||||
|
};
|
||||||
|
|
||||||
|
let sqlite: SQLiteConnection | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the plugin database file name for this platform, or undefined
|
||||||
|
* where the plugin keeps no SQLite database (web, Electron)
|
||||||
|
*/
|
||||||
|
export function notificationPluginDatabaseFile(): string | undefined {
|
||||||
|
return DATABASE_FILE_BY_PLATFORM[Capacitor.getPlatform()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one statement against the plugin database and returns its rows.
|
||||||
|
* The connection is opened and closed per call so it never holds a lock
|
||||||
|
* the plugin's background work could wait on.
|
||||||
|
*
|
||||||
|
* @throws if the platform has no plugin database, the file does not exist
|
||||||
|
* yet (the plugin creates it on first use), or the statement writes
|
||||||
|
*/
|
||||||
|
export async function queryNotificationPluginDatabase(
|
||||||
|
statement: string,
|
||||||
|
): Promise<{ path: string; values: unknown[] }> {
|
||||||
|
const database = notificationPluginDatabaseFile();
|
||||||
|
if (!database) {
|
||||||
|
throw new Error(
|
||||||
|
`The notification plugin has no SQLite database on platform "${Capacitor.getPlatform()}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sqlite = sqlite ?? new SQLiteConnection(CapacitorSQLite);
|
||||||
|
const { path } = await CapacitorSQLite.getNCDatabasePath({
|
||||||
|
path: "default",
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
if (!path) {
|
||||||
|
throw new Error(`Could not resolve a path for ${database}.`);
|
||||||
|
}
|
||||||
|
const exists = await sqlite.isNCDatabase(path);
|
||||||
|
if (!exists.result) {
|
||||||
|
throw new Error(
|
||||||
|
`No plugin database at ${path}. The plugin creates it on first use.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const db = await sqlite.createNCConnection(path, 1);
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const result = await db.query(statement);
|
||||||
|
return { path, values: result.values ?? [] };
|
||||||
|
} finally {
|
||||||
|
await sqlite.closeNCConnection(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,16 +330,20 @@ export async function deleteSmsPhone(
|
|||||||
* @param notifyMinuteUtc UTC minute, 0-59
|
* @param notifyMinuteUtc UTC minute, 0-59
|
||||||
* @param searchState cursors to count from, and the starred handle IDs and
|
* @param searchState cursors to count from, and the starred handle IDs and
|
||||||
* area the daily search uses; see {@link AlertSearchUploadState}
|
* area the daily search uses; see {@link AlertSearchUploadState}
|
||||||
|
* @param rerunToday true for a deliberate reset (turning texts on, a new
|
||||||
|
* time), so today can fire again even if it already did
|
||||||
*/
|
*/
|
||||||
export async function authorizeSmsAlertSearch(
|
export async function authorizeSmsAlertSearch(
|
||||||
did: string,
|
did: string,
|
||||||
notifyHourUtc: number,
|
notifyHourUtc: number,
|
||||||
notifyMinuteUtc: number,
|
notifyMinuteUtc: number,
|
||||||
searchState?: AlertSearchUploadState,
|
searchState?: AlertSearchUploadState,
|
||||||
|
rerunToday = false,
|
||||||
): Promise<AlertAuthorizationResponse> {
|
): Promise<AlertAuthorizationResponse> {
|
||||||
const body = {
|
const body = {
|
||||||
...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)),
|
...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)),
|
||||||
...searchState,
|
...searchState,
|
||||||
|
...(rerunToday ? { rerunToday: true } : {}),
|
||||||
};
|
};
|
||||||
return smsRequest<AlertAuthorizationResponse>(did, {
|
return smsRequest<AlertAuthorizationResponse>(did, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export class CapacitorPlatformService
|
|||||||
toDirectory: Directory.Cache,
|
toDirectory: Directory.Cache,
|
||||||
});
|
});
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: "Time Safari database",
|
title: "TimeSafari database",
|
||||||
url: uri,
|
url: uri,
|
||||||
dialogTitle: "Save or send your database file",
|
dialogTitle: "Save or send your database file",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { addLastClaimOrHandleAsIdIfMissing } from "@/libs/confirmationTarget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A confirmation names the claim it confirms. Every Person claim about a DID
|
||||||
|
* has that DID as its identifier, so the identifier alone points the server at
|
||||||
|
* whichever of them is latest, not at the one on screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DID = "did:ethr:0xce097412b1Cc8E0DBe294b3d8243e2b3Cf8E80ba";
|
||||||
|
const CLAIM_ID = "01M0QQ897FZ15R2KMZFSZ66W7V";
|
||||||
|
const HANDLE_ID = "https://endorser.ch/entity/01M0QQ897FZ15R2KMZFSZ66W7V";
|
||||||
|
|
||||||
|
describe("addLastClaimOrHandleAsIdIfMissing", () => {
|
||||||
|
it("names the exact claim even when it has an identifier", () => {
|
||||||
|
const claim = { "@type": "Person", identifier: DID };
|
||||||
|
expect(addLastClaimOrHandleAsIdIfMissing(claim, CLAIM_ID, DID)).toEqual({
|
||||||
|
"@type": "Person",
|
||||||
|
identifier: DID,
|
||||||
|
lastClaimId: CLAIM_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change the claim it was given", () => {
|
||||||
|
const claim = { "@type": "Person", identifier: DID };
|
||||||
|
addLastClaimOrHandleAsIdIfMissing(claim, CLAIM_ID, DID);
|
||||||
|
expect(claim).toEqual({ "@type": "Person", identifier: DID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a lastClaimId the claim already carries", () => {
|
||||||
|
const claim = { "@type": "Person", lastClaimId: "01EARLIER" };
|
||||||
|
expect(addLastClaimOrHandleAsIdIfMissing(claim, CLAIM_ID)).toBe(claim);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the handle as identifier when there is no claim ID", () => {
|
||||||
|
const claim = { "@type": "GiveAction" };
|
||||||
|
expect(
|
||||||
|
addLastClaimOrHandleAsIdIfMissing(claim, undefined, HANDLE_ID),
|
||||||
|
).toEqual({ "@type": "GiveAction", identifier: HANDLE_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an identifier the claim already carries over the handle", () => {
|
||||||
|
const claim = { "@type": "Person", identifier: DID };
|
||||||
|
expect(addLastClaimOrHandleAsIdIfMissing(claim, undefined, HANDLE_ID)).toBe(
|
||||||
|
claim,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,19 +21,23 @@ import {
|
|||||||
} from "@/libs/alertSearch";
|
} from "@/libs/alertSearch";
|
||||||
import {
|
import {
|
||||||
joinCursorPair,
|
joinCursorPair,
|
||||||
|
markedReadState,
|
||||||
nextWindow,
|
nextWindow,
|
||||||
parseCursorParam,
|
parseCursorParam,
|
||||||
parseNewActivityCursorState,
|
parseNewActivityCursorState,
|
||||||
SAME_VISIT_MS,
|
SAME_VISIT_MS,
|
||||||
seededState,
|
seededState,
|
||||||
startParams,
|
startParams,
|
||||||
|
summaryStart,
|
||||||
ulidJustBefore,
|
ulidJustBefore,
|
||||||
unseenStart,
|
unseenStart,
|
||||||
windowStart,
|
windowStart,
|
||||||
|
windowStartSeenAt,
|
||||||
} from "@/libs/newActivity";
|
} from "@/libs/newActivity";
|
||||||
import {
|
import {
|
||||||
alertSearchUploadState,
|
alertSearchUploadState,
|
||||||
countNewActivity,
|
countNewActivity,
|
||||||
|
countSummaryActivity,
|
||||||
countUnseenActivity,
|
countUnseenActivity,
|
||||||
latestCursorPair,
|
latestCursorPair,
|
||||||
loadBucket,
|
loadBucket,
|
||||||
@@ -69,6 +73,7 @@ describe("visit windows", () => {
|
|||||||
expect(windowStart(window, NOW)).toEqual("20");
|
expect(windowStart(window, NOW)).toEqual("20");
|
||||||
expect(nextWindow(window, "25", NOW)).toEqual({
|
expect(nextWindow(window, "25", NOW)).toEqual({
|
||||||
from: "20",
|
from: "20",
|
||||||
|
fromSeenAt: window.at,
|
||||||
to: "25",
|
to: "25",
|
||||||
at: NOW.toISOString(),
|
at: NOW.toISOString(),
|
||||||
seenAt: NOW.toISOString(),
|
seenAt: NOW.toISOString(),
|
||||||
@@ -90,6 +95,65 @@ describe("visit windows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("counted-since time and mark all read", () => {
|
||||||
|
it("is when the start was recorded, inside and after a visit", () => {
|
||||||
|
const inside = {
|
||||||
|
from: "10",
|
||||||
|
fromSeenAt: minutesAgo(60),
|
||||||
|
to: "20",
|
||||||
|
at: minutesAgo(5),
|
||||||
|
seenAt: minutesAgo(5),
|
||||||
|
};
|
||||||
|
expect(windowStartSeenAt(inside, NOW)).toEqual(minutesAgo(60));
|
||||||
|
const after = { ...inside, at: minutesAgo(40), seenAt: minutesAgo(35) };
|
||||||
|
expect(windowStartSeenAt(after, NOW)).toEqual(minutesAgo(35));
|
||||||
|
expect(windowStartSeenAt({ to: "20", at: minutesAgo(40) }, NOW)).toEqual(
|
||||||
|
minutesAgo(40),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unknown without a start or its time", () => {
|
||||||
|
expect(windowStartSeenAt(undefined, NOW)).toBeUndefined();
|
||||||
|
// a first visit: no start
|
||||||
|
expect(
|
||||||
|
windowStartSeenAt({ to: "20", at: minutesAgo(5) }, NOW),
|
||||||
|
).toBeUndefined();
|
||||||
|
// a first visit from a notification link: a start with no time
|
||||||
|
expect(
|
||||||
|
windowStartSeenAt({ from: "10", to: "20", at: minutesAgo(5) }, NOW),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks every list read up to the end, keeping the uploaded cursors", () => {
|
||||||
|
const end = { endorser: "40", partner: "4-40" };
|
||||||
|
const uploaded = { sms: { endorser: "10", partner: "1-10" } };
|
||||||
|
const state = markedReadState(
|
||||||
|
{
|
||||||
|
visit: {
|
||||||
|
from: { endorser: "10", partner: "1-10" },
|
||||||
|
to: { endorser: "20", partner: "2-20" },
|
||||||
|
at: minutesAgo(5),
|
||||||
|
},
|
||||||
|
buckets: {
|
||||||
|
claims: { to: "30", at: minutesAgo(2), seenAt: minutesAgo(2) },
|
||||||
|
},
|
||||||
|
uploaded,
|
||||||
|
},
|
||||||
|
end,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(state.buckets).toBeUndefined();
|
||||||
|
expect(state.uploaded).toEqual(uploaded);
|
||||||
|
expect(windowStartSeenAt(state.visit, NOW)).toEqual(NOW.toISOString());
|
||||||
|
expect(summaryStart(state, "claims", NOW)).toEqual("40");
|
||||||
|
expect(summaryStart(state, "profilesNearby", NOW)).toEqual("4-40");
|
||||||
|
expect(unseenStart(state, "claims")).toEqual("40");
|
||||||
|
// and after the visit, from the same place
|
||||||
|
const later = new Date(NOW.getTime() + 40 * 60 * 1000);
|
||||||
|
expect(summaryStart(state, "claims", later)).toEqual("40");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("notification link cursors", () => {
|
describe("notification link cursors", () => {
|
||||||
it("round-trips the endorser and partner cursors through 'c'", () => {
|
it("round-trips the endorser and partner cursors through 'c'", () => {
|
||||||
const pair = { endorser: "48213", partner: "912-48213" };
|
const pair = { endorser: "48213", partner: "912-48213" };
|
||||||
@@ -425,6 +489,87 @@ describe("Home counts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("latest cursors for a first upload", () => {
|
||||||
it("keeps the cursor of the server that answered", async () => {
|
it("keeps the cursor of the server that answered", async () => {
|
||||||
const { axios } = fakeAxios((url) => {
|
const { axios } = fakeAxios((url) => {
|
||||||
@@ -483,6 +628,15 @@ describe("notification refresh", () => {
|
|||||||
).toEqual(["sms"]);
|
).toEqual(["sms"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves out push refreshes for on-device delivery", () => {
|
||||||
|
expect(
|
||||||
|
enabledNotificationChannels({
|
||||||
|
notifyingNewActivityTime: "9:00 AM",
|
||||||
|
newActivityDelivery: "device",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads each channel with the cursors and reports the ones that took", async () => {
|
it("uploads each channel with the cursors and reports the ones that took", async () => {
|
||||||
const state = alertSearchUploadState(
|
const state = alertSearchUploadState(
|
||||||
{ planHandleIds: ["h1"], location: undefined },
|
{ planHandleIds: ["h1"], location: undefined },
|
||||||
@@ -498,21 +652,44 @@ describe("notification refresh", () => {
|
|||||||
const uploadSms = jest.fn().mockResolvedValue(true);
|
const uploadSms = jest.fn().mockResolvedValue(true);
|
||||||
const uploaded = await refreshNotificationAuthorizations(
|
const uploaded = await refreshNotificationAuthorizations(
|
||||||
"did:ethr:0xme",
|
"did:ethr:0xme",
|
||||||
{ notifyingNewActivitySmsTime: "9:00 AM" },
|
{
|
||||||
|
notifyingNewActivityTime: "9:00 AM",
|
||||||
|
notifyingNewActivitySmsTime: "9:00 AM",
|
||||||
|
},
|
||||||
["fcm", "sms"],
|
["fcm", "sms"],
|
||||||
cursors,
|
cursors,
|
||||||
state,
|
state,
|
||||||
{ uploadFcm, uploadSms },
|
{ uploadFcm, uploadSms },
|
||||||
);
|
);
|
||||||
expect(uploadFcm).toHaveBeenCalledWith(state);
|
// the push re-upload keeps the person's own time
|
||||||
|
expect(uploadFcm).toHaveBeenCalledWith(state, "9:00 AM");
|
||||||
expect(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state);
|
expect(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state);
|
||||||
expect(uploaded).toEqual({ sms: cursors });
|
expect(uploaded).toEqual({ sms: cursors });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips texts with an unreadable time and survives a thrown upload", async () => {
|
it("skips a channel with an unreadable time", async () => {
|
||||||
|
const uploadFcm = jest.fn();
|
||||||
|
const uploadSms = jest.fn();
|
||||||
const uploaded = await refreshNotificationAuthorizations(
|
const uploaded = await refreshNotificationAuthorizations(
|
||||||
"did:ethr:0xme",
|
"did:ethr:0xme",
|
||||||
{ notifyingNewActivitySmsTime: "whenever" },
|
{
|
||||||
|
notifyingNewActivityTime: "sometime",
|
||||||
|
notifyingNewActivitySmsTime: "whenever",
|
||||||
|
},
|
||||||
|
["fcm", "sms"],
|
||||||
|
cursors,
|
||||||
|
{},
|
||||||
|
{ uploadFcm, uploadSms },
|
||||||
|
);
|
||||||
|
expect(uploadFcm).not.toHaveBeenCalled();
|
||||||
|
expect(uploadSms).not.toHaveBeenCalled();
|
||||||
|
expect(uploaded).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a thrown upload", async () => {
|
||||||
|
const uploaded = await refreshNotificationAuthorizations(
|
||||||
|
"did:ethr:0xme",
|
||||||
|
{ notifyingNewActivityTime: "9:00 AM" },
|
||||||
["fcm", "sms"],
|
["fcm", "sms"],
|
||||||
cursors,
|
cursors,
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
effectiveNewActivityDelivery,
|
||||||
|
storedNewActivityDelivery,
|
||||||
|
} from "@/services/notifications/newActivityDelivery";
|
||||||
|
|
||||||
|
describe("New Activity delivery", () => {
|
||||||
|
it("defaults to the server push", () => {
|
||||||
|
expect(storedNewActivityDelivery({})).toBe("server");
|
||||||
|
expect(storedNewActivityDelivery({ newActivityDelivery: "bogus" })).toBe(
|
||||||
|
"server",
|
||||||
|
);
|
||||||
|
expect(storedNewActivityDelivery({ newActivityDelivery: "device" })).toBe(
|
||||||
|
"device",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const offered = { isRegistered: true, showGeneralAdvanced: true };
|
||||||
|
|
||||||
|
it("runs on the device wherever push is unavailable", () => {
|
||||||
|
expect(effectiveNewActivityDelivery(offered, true)).toBe("server");
|
||||||
|
expect(effectiveNewActivityDelivery(offered, false)).toBe("device");
|
||||||
|
expect(
|
||||||
|
effectiveNewActivityDelivery(
|
||||||
|
{ ...offered, newActivityDelivery: "device" },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe("device");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the server only to registered identities with advanced on", () => {
|
||||||
|
expect(effectiveNewActivityDelivery({}, true)).toBe("device");
|
||||||
|
expect(
|
||||||
|
effectiveNewActivityDelivery({ showGeneralAdvanced: true }, true),
|
||||||
|
).toBe("device");
|
||||||
|
expect(effectiveNewActivityDelivery({ isRegistered: true }, true)).toBe(
|
||||||
|
"device",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a server delivery that is already on", () => {
|
||||||
|
expect(
|
||||||
|
effectiveNewActivityDelivery(
|
||||||
|
{ notifyingNewActivityTime: "9:00 AM" },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe("server");
|
||||||
|
expect(
|
||||||
|
effectiveNewActivityDelivery(
|
||||||
|
{ notifyingNewActivityTime: "9:00 AM" },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe("device");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
boundingBoxAround,
|
||||||
|
isNudgeOn,
|
||||||
|
isStrongMatch,
|
||||||
|
noStrongFollowUp,
|
||||||
|
locBoxToBoundingBox,
|
||||||
|
offersAlerts,
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
type OnboardingState,
|
||||||
|
parseOnboardingState,
|
||||||
|
roundCoordinate,
|
||||||
|
serializeOnboardingState,
|
||||||
|
shouldAnimateBanner,
|
||||||
|
shouldOpenWelcome,
|
||||||
|
similarityLabel,
|
||||||
|
withBannerShown,
|
||||||
|
withDeclined,
|
||||||
|
withDone,
|
||||||
|
withInterests,
|
||||||
|
withMatchesShown,
|
||||||
|
withoutInterests,
|
||||||
|
withWelcomeTour,
|
||||||
|
} from "@/libs/onboarding";
|
||||||
|
import {
|
||||||
|
fetchSimilar,
|
||||||
|
hasServerProfileText,
|
||||||
|
SIMILAR_BUSY_MESSAGE,
|
||||||
|
SIMILAR_NETWORK_MESSAGE,
|
||||||
|
SIMILAR_RATE_LIMITED_MESSAGE,
|
||||||
|
similarUrl,
|
||||||
|
} from "@/libs/partnerServer";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-10-25T16:02:11Z");
|
||||||
|
const POINT = { lat: 40.76543, lon: -111.891234 };
|
||||||
|
|
||||||
|
describe("onboarding state", () => {
|
||||||
|
it("starts at the prompt when nothing is stored or it is unreadable", () => {
|
||||||
|
expect(parseOnboardingState(undefined)).toEqual({ stage: "interests" });
|
||||||
|
expect(parseOnboardingState("")).toEqual({ stage: "interests" });
|
||||||
|
expect(parseOnboardingState("{not json")).toEqual({ stage: "interests" });
|
||||||
|
expect(parseOnboardingState('{"stage":"bogus"}').stage).toBe("interests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through the settings column", () => {
|
||||||
|
const state: OnboardingState = {
|
||||||
|
stage: "save",
|
||||||
|
interests: "gardening",
|
||||||
|
lat: 40.77,
|
||||||
|
lon: -111.89,
|
||||||
|
bannerShownAt: null,
|
||||||
|
};
|
||||||
|
expect(parseOnboardingState(serializeOnboardingState(state))).toEqual(
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks interests → matches → save → done", () => {
|
||||||
|
let state = parseOnboardingState(undefined);
|
||||||
|
state = withInterests(state, " vegetable gardening ", POINT);
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
stage: "matches",
|
||||||
|
interests: "vegetable gardening",
|
||||||
|
lat: 40.77,
|
||||||
|
lon: -111.89,
|
||||||
|
});
|
||||||
|
state = withMatchesShown(state, NOW);
|
||||||
|
expect(state.stage).toBe("save");
|
||||||
|
expect(state.matchesShownAt).toBe(NOW.toISOString());
|
||||||
|
state = withDone(state);
|
||||||
|
expect(state.stage).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only moves to save from matches", () => {
|
||||||
|
const interests: OnboardingState = { stage: "interests" };
|
||||||
|
expect(withMatchesShown(interests, NOW)).toBe(interests);
|
||||||
|
const done: OnboardingState = { stage: "done" };
|
||||||
|
expect(withMatchesShown(done, NOW)).toBe(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts from done but leaves a nudge already on", () => {
|
||||||
|
expect(withInterests({ stage: "done" }, "x", null).stage).toBe("matches");
|
||||||
|
expect(withInterests({ stage: "save" }, "x", null).stage).toBe("save");
|
||||||
|
expect(withInterests({ stage: "matches" }, "x", null).stage).toBe(
|
||||||
|
"matches",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the point when the search runs without one", () => {
|
||||||
|
const state = withInterests(
|
||||||
|
{ stage: "done", interests: "a", lat: 1, lon: 2 },
|
||||||
|
"b",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(state.lat).toBeUndefined();
|
||||||
|
expect(state.lon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets finishedOnboarding only once the stage is done", () => {
|
||||||
|
expect(onboardingSettingsUpdate({ stage: "save" })).toEqual({
|
||||||
|
onboardingState: '{"stage":"save"}',
|
||||||
|
});
|
||||||
|
expect(onboardingSettingsUpdate({ stage: "done" }).finishedOnboarding).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kept interests", () => {
|
||||||
|
const kept = withInterests({ stage: "interests" }, "teaching", POINT);
|
||||||
|
|
||||||
|
it("stay when the stage reaches done", () => {
|
||||||
|
expect(withDone(withMatchesShown(kept, NOW))).toMatchObject({
|
||||||
|
interests: "teaching",
|
||||||
|
lat: 40.77,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are kept from text typed before 'Not now'", () => {
|
||||||
|
const state = withDeclined({ stage: "interests" }, NOW, {
|
||||||
|
interests: "bikes",
|
||||||
|
point: POINT,
|
||||||
|
});
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
stage: "done",
|
||||||
|
declinedAt: NOW.toISOString(),
|
||||||
|
interests: "bikes",
|
||||||
|
lat: 40.77,
|
||||||
|
lon: -111.89,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are untouched by 'Not now' with nothing typed", () => {
|
||||||
|
const state = withDeclined(kept, NOW, { interests: " ", point: null });
|
||||||
|
expect(state).toMatchObject({ stage: "done", interests: "teaching" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stay while the person is unregistered, with no nudge", () => {
|
||||||
|
const state = withMatchesShown(kept, NOW);
|
||||||
|
expect(isNudgeOn(state, false)).toBe(false);
|
||||||
|
expect(shouldAnimateBanner(state, false)).toBe(false);
|
||||||
|
expect(state.interests).toBe("teaching");
|
||||||
|
// registration later turns it on, with the banner still unspent
|
||||||
|
expect(isNudgeOn(state, true)).toBe(true);
|
||||||
|
expect(shouldAnimateBanner(state, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are cleared by a profile save or 'Forget'", () => {
|
||||||
|
const state = withoutInterests(withMatchesShown(kept, NOW));
|
||||||
|
expect(state.interests).toBeUndefined();
|
||||||
|
expect(state.lat).toBeUndefined();
|
||||||
|
expect(state.lon).toBeUndefined();
|
||||||
|
expect(state.stage).toBe("save");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nudge and welcome", () => {
|
||||||
|
it("animates the banner once", () => {
|
||||||
|
const state = withBannerShown({ stage: "save" }, NOW);
|
||||||
|
expect(shouldAnimateBanner(state, true)).toBe(false);
|
||||||
|
expect(isNudgeOn(state, true)).toBe(true);
|
||||||
|
expect(withBannerShown(state, new Date(0)).bannerShownAt).toBe(
|
||||||
|
NOW.toISOString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the welcome pages only on a tour from Help", () => {
|
||||||
|
expect(shouldOpenWelcome({})).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldOpenWelcome({
|
||||||
|
onboardingState: serializeOnboardingState({ stage: "interests" }),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
const tour = serializeOnboardingState(
|
||||||
|
withWelcomeTour({ stage: "done" }, true),
|
||||||
|
);
|
||||||
|
expect(shouldOpenWelcome({ onboardingState: tour })).toBe(true);
|
||||||
|
const ended = serializeOnboardingState(
|
||||||
|
withWelcomeTour({ stage: "done", welcomeTour: true }, false),
|
||||||
|
);
|
||||||
|
expect(shouldOpenWelcome({ onboardingState: ended })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers alerts only on the native phone apps", () => {
|
||||||
|
expect(offersAlerts({ isNativeApp: true, isMobile: true })).toBe(true);
|
||||||
|
// Electron
|
||||||
|
expect(offersAlerts({ isNativeApp: true, isMobile: false })).toBe(false);
|
||||||
|
// a phone's browser
|
||||||
|
expect(offersAlerts({ isNativeApp: false, isMobile: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("location helpers", () => {
|
||||||
|
it("rounds to two decimals", () => {
|
||||||
|
expect(roundCoordinate(40.76543)).toBe(40.77);
|
||||||
|
expect(roundCoordinate(-111.891234)).toBe(-111.89);
|
||||||
|
expect(roundCoordinate(0.004)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a box of about 50 km, widened in longitude by latitude", () => {
|
||||||
|
const equator = boundingBoxAround(0, 0);
|
||||||
|
expect(equator.maxLocLat - equator.minLocLat).toBeCloseTo(0.9, 2);
|
||||||
|
expect(equator.maxLocLon - equator.minLocLon).toBeCloseTo(0.9, 2);
|
||||||
|
const north = boundingBoxAround(60, 10);
|
||||||
|
expect(north.maxLocLat - 60).toBeCloseTo(0.45, 2);
|
||||||
|
// cos(60°) = 0.5, so twice as wide in degrees
|
||||||
|
expect(north.maxLocLon - 10).toBeCloseTo(0.9, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays inside the valid ranges near the poles and date line", () => {
|
||||||
|
const box = boundingBoxAround(89.9, 179.9);
|
||||||
|
expect(box.maxLocLat).toBeLessThanOrEqual(90);
|
||||||
|
expect(box.maxLocLon).toBeLessThanOrEqual(180);
|
||||||
|
expect(box.minLocLon).toBeGreaterThanOrEqual(-180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts to a search area", () => {
|
||||||
|
expect(
|
||||||
|
locBoxToBoundingBox({
|
||||||
|
minLocLat: 1,
|
||||||
|
maxLocLat: 2,
|
||||||
|
minLocLon: 3,
|
||||||
|
maxLocLon: 4,
|
||||||
|
}),
|
||||||
|
).toEqual({ minLat: 1, maxLat: 2, westLong: 3, eastLong: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels similarity by the server's cuts", () => {
|
||||||
|
expect(similarityLabel(0.9)).toBe("strong");
|
||||||
|
expect(similarityLabel(0.85)).toBe("strong");
|
||||||
|
expect(similarityLabel(0.82)).toBe("weak");
|
||||||
|
expect(similarityLabel(0.79)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats only strong matches as shown by default", () => {
|
||||||
|
expect(isStrongMatch(0.85)).toBe(true);
|
||||||
|
expect(isStrongMatch(0.849)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("noStrongFollowUp", () => {
|
||||||
|
const base = {
|
||||||
|
isRegistered: true,
|
||||||
|
profileSaved: true,
|
||||||
|
alertsOn: true,
|
||||||
|
offersAlerts: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("asks the unregistered to get registered, whatever else is set", () => {
|
||||||
|
expect(noStrongFollowUp({ ...base, isRegistered: false })).toBe("register");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only reassures with both a saved profile and an alert", () => {
|
||||||
|
expect(noStrongFollowUp(base)).toBe("alertsOn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a missing profile to the setup, even with an alert on", () => {
|
||||||
|
expect(noStrongFollowUp({ ...base, profileSaved: false })).toBe("setUp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a missing alert to the setup where the app has alerts", () => {
|
||||||
|
expect(noStrongFollowUp({ ...base, alertsOn: false })).toBe("setUp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to set up on the web once the profile is saved", () => {
|
||||||
|
expect(
|
||||||
|
noStrongFollowUp({ ...base, alertsOn: false, offersAlerts: false }),
|
||||||
|
).toBe("profileOnly");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasServerProfileText", () => {
|
||||||
|
function respond(status: number, body: unknown) {
|
||||||
|
return jest.fn().mockResolvedValue({
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is true for a profile with text, and encodes the DID", async () => {
|
||||||
|
const fetchFn = respond(200, { data: { description: "gardening" } });
|
||||||
|
expect(
|
||||||
|
await hasServerProfileText("https://p.example", {}, "did:x:1", fetchFn),
|
||||||
|
).toBe(true);
|
||||||
|
expect(fetchFn.mock.calls[0][0]).toBe(
|
||||||
|
"https://p.example/api/partner/userProfileForIssuer/did%3Ax%3A1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for blank text, a 404, or a failed request", async () => {
|
||||||
|
const blank = respond(200, { data: { description: " " } });
|
||||||
|
expect(await hasServerProfileText("s", {}, "d", blank)).toBe(false);
|
||||||
|
expect(await hasServerProfileText("s", {}, "d", respond(404, {}))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const down = jest.fn().mockRejectedValue(new Error("offline"));
|
||||||
|
expect(await hasServerProfileText("s", {}, "d", down)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchSimilar", () => {
|
||||||
|
const server = "https://partner.example";
|
||||||
|
|
||||||
|
function respond(status: number, body: unknown) {
|
||||||
|
return jest.fn().mockResolvedValue({
|
||||||
|
status,
|
||||||
|
json: () =>
|
||||||
|
body === undefined
|
||||||
|
? Promise.reject(new Error("no body"))
|
||||||
|
: Promise.resolve(body),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sends the text, both targets, and the box", () => {
|
||||||
|
const url = new URL(
|
||||||
|
similarUrl(server, {
|
||||||
|
text: "gardening & kids",
|
||||||
|
box: { minLocLat: 1, minLocLon: 2, maxLocLat: 3, maxLocLon: 4 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(url.pathname).toBe("/api/partner/similar");
|
||||||
|
expect(url.searchParams.get("from")).toBe("text:gardening & kids");
|
||||||
|
expect(url.searchParams.get("to")).toBe("both");
|
||||||
|
expect(url.searchParams.get("limit")).toBe("50");
|
||||||
|
expect(url.searchParams.get("minLocLon")).toBe("2");
|
||||||
|
expect(url.searchParams.get("maxLocLat")).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the box off when there is no point", () => {
|
||||||
|
const url = new URL(similarUrl(server, { text: "x" }));
|
||||||
|
expect(url.searchParams.has("minLocLat")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the matches on 200", async () => {
|
||||||
|
const data = [
|
||||||
|
{ subjectType: "plan", subjectId: "p", similarity: 0.9, record: {} },
|
||||||
|
];
|
||||||
|
const result = await fetchSimilar(
|
||||||
|
server,
|
||||||
|
{},
|
||||||
|
{ text: "x" },
|
||||||
|
respond(200, { data }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ kind: "ok", matches: data });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 429, 503, and an unreachable server", async () => {
|
||||||
|
expect(
|
||||||
|
await fetchSimilar(server, {}, { text: "x" }, respond(429, {})),
|
||||||
|
).toEqual({ kind: "rateLimited", message: SIMILAR_RATE_LIMITED_MESSAGE });
|
||||||
|
expect(
|
||||||
|
await fetchSimilar(server, {}, { text: "x" }, respond(503, undefined)),
|
||||||
|
).toEqual({ kind: "busy", message: SIMILAR_BUSY_MESSAGE });
|
||||||
|
const offline = jest.fn().mockRejectedValue(new TypeError("offline"));
|
||||||
|
expect(await fetchSimilar(server, {}, { text: "x" }, offline)).toEqual({
|
||||||
|
kind: "network",
|
||||||
|
message: SIMILAR_NETWORK_MESSAGE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the server's message on other errors", async () => {
|
||||||
|
const result = await fetchSimilar(
|
||||||
|
server,
|
||||||
|
{},
|
||||||
|
{ text: "x" },
|
||||||
|
respond(400, { error: "The query text is empty." }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "error",
|
||||||
|
status: 400,
|
||||||
|
message: "The query text is empty.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 200 without a data list", async () => {
|
||||||
|
const result = await fetchSimilar(
|
||||||
|
server,
|
||||||
|
{},
|
||||||
|
{ text: "x" },
|
||||||
|
respond(200, { nope: true }),
|
||||||
|
);
|
||||||
|
expect(result.kind).toBe("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
authorizeSmsAlertSearch,
|
||||||
listSmsPhones,
|
listSmsPhones,
|
||||||
registerSmsPhone,
|
registerSmsPhone,
|
||||||
SmsApiError,
|
SmsApiError,
|
||||||
smsErrorMessage,
|
smsErrorMessage,
|
||||||
} from "@/services/notifications/smsNotificationApi";
|
} from "@/services/notifications/smsNotificationApi";
|
||||||
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||||
|
import { buildAlertAuthorizationBody } from "@/services/notifications/alertAuthorizationBatch";
|
||||||
|
|
||||||
jest.mock("@/libs/endorserServer", () => ({
|
jest.mock("@/libs/endorserServer", () => ({
|
||||||
createEndorserJwtForDid: jest.fn(),
|
createEndorserJwtForDid: jest.fn(),
|
||||||
@@ -180,3 +182,46 @@ describe("refusals", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("authorizeSmsAlertSearch", () => {
|
||||||
|
const BODY = {
|
||||||
|
batchId: "b",
|
||||||
|
notifyHourUtc: 12,
|
||||||
|
notifyMinuteUtc: 0,
|
||||||
|
jwts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(buildAlertAuthorizationBody as jest.Mock).mockResolvedValue(BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sentBody(
|
||||||
|
...args: Parameters<typeof authorizeSmsAlertSearch> extends [
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
...infer Rest,
|
||||||
|
]
|
||||||
|
? Rest
|
||||||
|
: never
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
answer(200, { success: true });
|
||||||
|
await authorizeSmsAlertSearch(DID, 12, 0, ...args);
|
||||||
|
const [, init] = mockedFetch.mock.calls[0];
|
||||||
|
return JSON.parse(String(init.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("asks for a rerun today only when told to", async () => {
|
||||||
|
expect(await sentBody({ endorserAfterCursor: "4" }, true)).toEqual({
|
||||||
|
...BODY,
|
||||||
|
endorserAfterCursor: "4",
|
||||||
|
rerunToday: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves rerunToday out by default, as the visit refresh uploads", async () => {
|
||||||
|
const body = await sentBody({ endorserAfterCursor: "4" });
|
||||||
|
expect(body).toEqual({ ...BODY, endorserAfterCursor: "4" });
|
||||||
|
expect(body).not.toHaveProperty("rerunToday");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Shared, in-memory state for the interests onboarding's nudge, so the
|
||||||
|
* profile tab's dot (QuickNav), the banner, and the Profile card agree
|
||||||
|
* without each re-reading settings. The stored truth is
|
||||||
|
* `settings.onboardingState` (src/libs/onboarding.ts); this mirrors it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
import { isNudgeOn, parseOnboardingState } from "@/libs/onboarding";
|
||||||
|
|
||||||
|
export const onboardingNudge = reactive({
|
||||||
|
/** Set by the first {@link refreshNudge} of this launch. */
|
||||||
|
loaded: false,
|
||||||
|
/** The red dot on the profile tab. */
|
||||||
|
on: false,
|
||||||
|
/** The dot's pulse while the banner shows. */
|
||||||
|
pulse: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module state lives for one launch of the app, so this is how the prompt's
|
||||||
|
* "x" waits for the next cold start instead of the next visit to Home.
|
||||||
|
*/
|
||||||
|
let interestsPromptShownThisLaunch = false;
|
||||||
|
|
||||||
|
export function markInterestsPromptShown(): void {
|
||||||
|
interestsPromptShownThisLaunch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasInterestsPromptShown(): boolean {
|
||||||
|
return interestsPromptShownThisLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshNudge(settings: {
|
||||||
|
onboardingState?: string;
|
||||||
|
isRegistered?: boolean;
|
||||||
|
}): void {
|
||||||
|
onboardingNudge.loaded = true;
|
||||||
|
onboardingNudge.on = isNudgeOn(
|
||||||
|
parseOnboardingState(settings.onboardingState),
|
||||||
|
!!settings.isRegistered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNudgeOn(on: boolean): void {
|
||||||
|
onboardingNudge.on = on;
|
||||||
|
if (!on) onboardingNudge.pulse = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pulseNudge(ms: number): void {
|
||||||
|
onboardingNudge.pulse = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
onboardingNudge.pulse = false;
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
+564
-69
@@ -45,6 +45,25 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Interests onboarding: the follow-up to the matches they saw -->
|
||||||
|
<NextStepCard
|
||||||
|
v-if="showNextStepCard"
|
||||||
|
:offer-alerts="nextStepOffersAlerts"
|
||||||
|
:interests="keptInterests"
|
||||||
|
:point-text="keptPointText"
|
||||||
|
:profile-saved="nextStepProfileSaved"
|
||||||
|
:saving="savingProfile"
|
||||||
|
:sms-on="notifyingNewActivitySms"
|
||||||
|
:sms-time="notifyingNewActivitySmsTime"
|
||||||
|
:sms-disabled-reason="nextStepSmsDisabledReason"
|
||||||
|
:in-app-on="notifyingNewActivity"
|
||||||
|
:in-app-time="notifyingNewActivityTime"
|
||||||
|
@save="onNextStepSave"
|
||||||
|
@sms="onNextStepSms"
|
||||||
|
@in-app="onNextStepInApp"
|
||||||
|
@done="onNextStepDone"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Identity Details -->
|
<!-- Identity Details -->
|
||||||
<IdentitySection
|
<IdentitySection
|
||||||
:given-name="givenName"
|
:given-name="givenName"
|
||||||
@@ -144,6 +163,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The notify-api accepts only registered users, so an unregistered
|
||||||
|
identity can turn on just the on-device in-app delivery; the server and
|
||||||
|
SMS controls stay visible and explain why when picked.
|
||||||
|
-->
|
||||||
<!--
|
<!--
|
||||||
New Activity Notification: two delivery channels for the same alert.
|
New Activity Notification: two delivery channels for the same alert.
|
||||||
Both let the user pick a time; they differ only in how the alert is
|
Both let the user pick a time; they differ only in how the alert is
|
||||||
@@ -168,11 +192,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
|
<!-- Channel 1: In-App notification -->
|
||||||
<div
|
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||||||
v-if="showGeneralAdvanced"
|
|
||||||
class="flex items-center justify-between mt-3 mb-2 pl-1"
|
|
||||||
>
|
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
<div>
|
<div>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
@@ -205,15 +226,59 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showGeneralAdvanced" class="mb-2 pl-1">
|
<!-- How in-app notifications arrive; exactly one of the two runs.
|
||||||
|
The choice shows only with general advanced functions on; hidden, the
|
||||||
|
last choice keeps running. -->
|
||||||
|
<div
|
||||||
|
v-if="showGeneralAdvanced"
|
||||||
|
class="mb-2 pl-8 text-sm"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="How in-app notifications arrive"
|
||||||
|
data-testId="newActivityDelivery"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-2 mb-1"
|
||||||
|
:class="serverPushAvailable && isRegistered ? '' : 'text-slate-400'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="newActivityDelivery"
|
||||||
|
class="mt-1"
|
||||||
|
:checked="newActivityDelivery === 'server'"
|
||||||
|
:aria-disabled="!serverPushAvailable || !isRegistered"
|
||||||
|
@click.prevent="chooseNewActivityDelivery('server')"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
From the notification server (default). It checks for you, so it
|
||||||
|
keeps permission to search on your behalf.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="newActivityDelivery"
|
||||||
|
class="mt-1"
|
||||||
|
:checked="newActivityDelivery === 'device'"
|
||||||
|
@click.prevent="chooseNewActivityDelivery('device')"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Only on this device (more private). Your phone checks, and the
|
||||||
|
notification server keeps nothing. For now it checks only your
|
||||||
|
starred projects.
|
||||||
|
<span v-if="isIos" class="block text-slate-500">
|
||||||
|
On iPhone this is less reliable: iOS decides when apps refresh in
|
||||||
|
the background, so a notification can come late or not at all. To
|
||||||
|
learn more, search for "iOS Background App Refresh".
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 pl-1">
|
||||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||||
Troubleshoot your in-app notifications…
|
Troubleshoot your in-app notifications…
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="notifyingNewActivity" class="w-full pl-1">
|
||||||
v-if="notifyingNewActivity && showGeneralAdvanced"
|
|
||||||
class="w-full pl-1"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||||||
>
|
>
|
||||||
@@ -365,6 +430,18 @@
|
|||||||
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="profileDraftFromPhone"
|
||||||
|
class="text-sm text-slate-600 mb-2"
|
||||||
|
data-testId="profileDraftFromPhone"
|
||||||
|
>
|
||||||
|
Saved only on this device. Not public until you save your profile.
|
||||||
|
<span
|
||||||
|
class="text-blue-500 cursor-pointer ml-1"
|
||||||
|
@click="forgetKeptInterests()"
|
||||||
|
>Forget</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="userProfileDesc"
|
v-model="userProfileDesc"
|
||||||
class="w-full h-32 p-2 border border-slate-300 rounded-md"
|
class="w-full h-32 p-2 border border-slate-300 rounded-md"
|
||||||
@@ -1016,7 +1093,7 @@ import "dexie-export-import";
|
|||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
import * as L from "leaflet";
|
import * as L from "leaflet";
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue, Watch } from "vue-facing-decorator";
|
||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { copyToClipboard } from "../services/ClipboardService";
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||||
@@ -1025,6 +1102,7 @@ import { Capacitor } from "@capacitor/core";
|
|||||||
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
|
import NextStepCard from "../components/NextStepCard.vue";
|
||||||
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
|
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
|
||||||
@@ -1077,6 +1155,7 @@ import {
|
|||||||
configureNativeFetcherIfReady,
|
configureNativeFetcherIfReady,
|
||||||
deleteSmsPhone,
|
deleteSmsPhone,
|
||||||
getNotificationApiBaseUrl,
|
getNotificationApiBaseUrl,
|
||||||
|
initializeNativePushAndFirebaseMessaging,
|
||||||
listSmsPhones,
|
listSmsPhones,
|
||||||
localTimeTextToUtc,
|
localTimeTextToUtc,
|
||||||
revokeInAppNewActivity,
|
revokeInAppNewActivity,
|
||||||
@@ -1086,6 +1165,12 @@ import {
|
|||||||
UnsupportedIdentityError,
|
UnsupportedIdentityError,
|
||||||
} from "@/services/notifications";
|
} from "@/services/notifications";
|
||||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||||
|
import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient";
|
||||||
|
import {
|
||||||
|
effectiveNewActivityDelivery,
|
||||||
|
type NewActivityDelivery,
|
||||||
|
} from "@/services/notifications/newActivityDelivery";
|
||||||
|
import { unregisterDevicesFromNotificationApi } from "@/services/notifications/NotificationService";
|
||||||
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||||
import {
|
import {
|
||||||
type CursorPair,
|
type CursorPair,
|
||||||
@@ -1099,6 +1184,22 @@ import {
|
|||||||
latestCursorPair,
|
latestCursorPair,
|
||||||
} from "@/services/newActivity";
|
} from "@/services/newActivity";
|
||||||
import { buildNewActivityContext } from "@/services/newActivityContext";
|
import { buildNewActivityContext } from "@/services/newActivityContext";
|
||||||
|
import {
|
||||||
|
boundingBoxAround,
|
||||||
|
hasPoint,
|
||||||
|
isNudgeOn,
|
||||||
|
locBoxToBoundingBox,
|
||||||
|
offersAlerts,
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
type OnboardingState,
|
||||||
|
parseOnboardingState,
|
||||||
|
withDone,
|
||||||
|
withoutInterests,
|
||||||
|
} from "@/libs/onboarding";
|
||||||
|
import { setNudgeOn } from "@/utils/onboardingNudge";
|
||||||
|
|
||||||
|
/** Where the next-step card starts the alert clock (Decision 6 of the plan). */
|
||||||
|
const NEXT_STEP_DEFAULT_TIME = "09:00";
|
||||||
// Profile data interface (inlined from ProfileService)
|
// Profile data interface (inlined from ProfileService)
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -1119,6 +1220,7 @@ interface PushNotificationPermissionRef {
|
|||||||
skipSchedule?: boolean;
|
skipSchedule?: boolean;
|
||||||
rolloverIntervalMinutes?: number;
|
rolloverIntervalMinutes?: number;
|
||||||
timeOnly?: boolean;
|
timeOnly?: boolean;
|
||||||
|
defaultTime?: string;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
hourInput?: string;
|
hourInput?: string;
|
||||||
@@ -1135,6 +1237,7 @@ interface PushNotificationPermissionRef {
|
|||||||
LMap,
|
LMap,
|
||||||
LMarker,
|
LMarker,
|
||||||
LTileLayer,
|
LTileLayer,
|
||||||
|
NextStepCard,
|
||||||
PushNotificationPermission,
|
PushNotificationPermission,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
SmsVerificationDialog,
|
SmsVerificationDialog,
|
||||||
@@ -1198,6 +1301,12 @@ export default class AccountViewView extends Vue {
|
|||||||
// Notification properties
|
// Notification properties
|
||||||
notifyingNewActivity: boolean = false;
|
notifyingNewActivity: boolean = false;
|
||||||
notifyingNewActivityTime: string = "";
|
notifyingNewActivityTime: string = "";
|
||||||
|
/** The in-app delivery that runs; see newActivityDelivery.ts. */
|
||||||
|
newActivityDelivery: NewActivityDelivery = "server";
|
||||||
|
/** False in a build without Firebase, where only "device" can run. */
|
||||||
|
serverPushAvailable = true;
|
||||||
|
deliveryChangeInProgress = false;
|
||||||
|
isIos = Capacitor.getPlatform() === "ios";
|
||||||
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
||||||
// Settings hold what the service cannot report back: it has a route to list
|
// Settings hold what the service cannot report back: it has a route to list
|
||||||
// registered numbers but none to read an authorization, so the chosen time
|
// registered numbers but none to read an authorization, so the chosen time
|
||||||
@@ -1223,6 +1332,16 @@ export default class AccountViewView extends Vue {
|
|||||||
downloadUrl: string = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
downloadUrl: string = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
||||||
loadingLimits: boolean = false;
|
loadingLimits: boolean = false;
|
||||||
loadingProfile: boolean = true;
|
loadingProfile: boolean = true;
|
||||||
|
|
||||||
|
// Interests onboarding (src/libs/onboarding.ts): the next-step card, and
|
||||||
|
// the profile draft the phone kept
|
||||||
|
onboardingState: OnboardingState = { stage: "interests" };
|
||||||
|
nextStepProfileSaved: boolean = false;
|
||||||
|
/** `?nextStep=1`, from the matches view: show the card outside the nudge */
|
||||||
|
nextStepRequested: boolean = false;
|
||||||
|
nextStepSmsDisabledReason: string = "";
|
||||||
|
// the profile editor holds the kept interests, not a saved profile
|
||||||
|
profileDraftFromPhone: boolean = false;
|
||||||
showAdvanced: boolean = false;
|
showAdvanced: boolean = false;
|
||||||
showB64Copy: boolean = false;
|
showB64Copy: boolean = false;
|
||||||
showContactGives: boolean = false;
|
showContactGives: boolean = false;
|
||||||
@@ -1287,6 +1406,7 @@ export default class AccountViewView extends Vue {
|
|||||||
try {
|
try {
|
||||||
await this.initializeState();
|
await this.initializeState();
|
||||||
await this.processIdentity();
|
await this.processIdentity();
|
||||||
|
await this.loadOnboardingState();
|
||||||
|
|
||||||
// Profile service logic now inlined - no need for external service
|
// Profile service logic now inlined - no need for external service
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1312,6 +1432,11 @@ export default class AccountViewView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
// Profile not created yet; leave defaults
|
// Profile not created yet; leave defaults
|
||||||
}
|
}
|
||||||
|
if (profile?.description) {
|
||||||
|
this.nextStepProfileSaved = true;
|
||||||
|
} else {
|
||||||
|
this.fillProfileFromKeptInterests();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error loading profile:", error);
|
logger.error("Error loading profile:", error);
|
||||||
this.notify.error(
|
this.notify.error(
|
||||||
@@ -1396,6 +1521,11 @@ export default class AccountViewView extends Vue {
|
|||||||
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
||||||
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
||||||
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
||||||
|
this.serverPushAvailable = await serverPushSupported();
|
||||||
|
this.newActivityDelivery = effectiveNewActivityDelivery(
|
||||||
|
settings,
|
||||||
|
this.serverPushAvailable,
|
||||||
|
);
|
||||||
this.notifyingNewActivitySmsPhone =
|
this.notifyingNewActivitySmsPhone =
|
||||||
settings.notifyingNewActivitySmsPhone || "";
|
settings.notifyingNewActivitySmsPhone || "";
|
||||||
this.notifyingNewActivitySmsTime =
|
this.notifyingNewActivitySmsTime =
|
||||||
@@ -1517,6 +1647,11 @@ export default class AccountViewView extends Vue {
|
|||||||
await this.$saveSettings({
|
await this.$saveSettings({
|
||||||
showGeneralAdvanced: this.showGeneralAdvanced,
|
showGeneralAdvanced: this.showGeneralAdvanced,
|
||||||
});
|
});
|
||||||
|
// the server delivery is offered only with these on
|
||||||
|
this.newActivityDelivery = effectiveNewActivityDelivery(
|
||||||
|
await this.$accountSettings(),
|
||||||
|
this.serverPushAvailable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleProdWarning(): Promise<void> {
|
async toggleProdWarning(): Promise<void> {
|
||||||
@@ -1575,62 +1710,205 @@ export default class AccountViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async showNewActivityNotificationChoice(): Promise<void> {
|
/** @param defaultTime "HH:mm" to start the clock at when turning on */
|
||||||
|
async showNewActivityNotificationChoice(defaultTime?: string): Promise<void> {
|
||||||
if (!this.notifyingNewActivity) {
|
if (!this.notifyingNewActivity) {
|
||||||
|
if (
|
||||||
|
this.newActivityDelivery === "server" &&
|
||||||
|
!this.warnIfUnregistered("Server notifications")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
(
|
(
|
||||||
this.$refs.pushNotificationPermission as PushNotificationPermission
|
this.$refs.pushNotificationPermission as PushNotificationPermission
|
||||||
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
).open(
|
||||||
if (success) {
|
DAILY_CHECK_TITLE,
|
||||||
await this.$saveSettings({
|
async (success: boolean, timeText: string) => {
|
||||||
notifyingNewActivityTime: timeText,
|
if (success) {
|
||||||
});
|
await this.$saveSettings({
|
||||||
this.notifyingNewActivity = true;
|
notifyingNewActivityTime: timeText,
|
||||||
this.notifyingNewActivityTime = timeText;
|
// saved, so startup and refresh don't treat it as "server"
|
||||||
if (Capacitor.isNativePlatform()) {
|
newActivityDelivery: this.newActivityDelivery,
|
||||||
await this.scheduleNewActivityDualNotification(timeText);
|
});
|
||||||
|
this.notifyingNewActivity = true;
|
||||||
|
this.notifyingNewActivityTime = timeText;
|
||||||
|
if (this.newActivityDelivery === "device") {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
await this.scheduleNewActivityDualNotification(timeText);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.uploadServerNewActivity(timeText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const searchState = await this.newActivityStartState("fcm");
|
},
|
||||||
await authorizeInAppNewActivity(
|
defaultTime ? { defaultTime } : undefined,
|
||||||
(message) => {
|
);
|
||||||
this.reportAlertSearchAuthorizationFailure("upload", message);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
searchState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Revoke before clearing the local setting. A failed delete leaves
|
if (!(await this.turnOffInAppNewActivity())) {
|
||||||
// the toggle on so the dialog does not claim the subscription is gone.
|
|
||||||
const revoked = await revokeInAppNewActivity((message) => {
|
|
||||||
this.reportAlertSearchAuthorizationFailure("revoke", message);
|
|
||||||
});
|
|
||||||
if (!revoked) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
Capacitor.isNativePlatform() &&
|
|
||||||
DailyNotification?.cancelDualSchedule
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await DailyNotification.cancelDualSchedule();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingNewActivityTime: "",
|
|
||||||
});
|
|
||||||
this.notifyingNewActivity = false;
|
|
||||||
this.notifyingNewActivityTime = "";
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke the in-app New Activity authorization, then cancel the device
|
||||||
|
* schedule and clear the setting. Revoking first means a failed delete
|
||||||
|
* leaves the toggle on, so nothing claims the subscription is gone.
|
||||||
|
*
|
||||||
|
* @returns false when the revoke failed and nothing changed
|
||||||
|
*/
|
||||||
|
private async turnOffInAppNewActivity(): Promise<boolean> {
|
||||||
|
if (this.newActivityDelivery === "server") {
|
||||||
|
const revoked = await revokeInAppNewActivity((message) => {
|
||||||
|
this.reportAlertSearchAuthorizationFailure("revoke", message);
|
||||||
|
});
|
||||||
|
if (!revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// also in server mode, to clear a device schedule set up before the
|
||||||
|
// delivery choice existed
|
||||||
|
await this.cancelDeviceNewActivity();
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivityTime: "",
|
||||||
|
});
|
||||||
|
// with nothing on, "server" may no longer be offered
|
||||||
|
this.newActivityDelivery = effectiveNewActivityDelivery(
|
||||||
|
await this.$accountSettings(),
|
||||||
|
this.serverPushAvailable,
|
||||||
|
);
|
||||||
|
this.notifyingNewActivity = false;
|
||||||
|
this.notifyingNewActivityTime = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel the on-device New Activity schedule, if the plugin has one. */
|
||||||
|
private async cancelDeviceNewActivity(): Promise<void> {
|
||||||
|
if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) {
|
||||||
|
try {
|
||||||
|
await DailyNotification.cancelDualSchedule();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register this device for pushes and upload a batch counting from now, at
|
||||||
|
* `timeText`. A deliberate reset, so it may fire today even after today's
|
||||||
|
* push went.
|
||||||
|
*
|
||||||
|
* @returns false when the upload failed (the failure was reported)
|
||||||
|
*/
|
||||||
|
private async uploadServerNewActivity(timeText: string): Promise<boolean> {
|
||||||
|
await initializeNativePushAndFirebaseMessaging();
|
||||||
|
const searchState = await this.newActivityStartState("fcm");
|
||||||
|
const result = await authorizeInAppNewActivity(
|
||||||
|
(message) => {
|
||||||
|
this.reportAlertSearchAuthorizationFailure("upload", message);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
searchState,
|
||||||
|
{ timeText, rerunToday: true },
|
||||||
|
);
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove this identity's device registrations from the notification
|
||||||
|
* service. Best effort: a failure is logged, and the service only pushes to
|
||||||
|
* an identity that also holds an alert authorization.
|
||||||
|
*/
|
||||||
|
private async forgetPushRegistration(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unregisterDevicesFromNotificationApi();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
"[AccountViewView] Could not remove device registrations:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch how in-app New Activity notifications arrive. With notifications
|
||||||
|
* on, the old delivery is stopped and the new one started at the same
|
||||||
|
* time; a failure on the way leaves the old delivery in place.
|
||||||
|
*/
|
||||||
|
async chooseNewActivityDelivery(choice: NewActivityDelivery): Promise<void> {
|
||||||
|
if (choice === this.newActivityDelivery || this.deliveryChangeInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === "server" && !this.serverPushAvailable) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Server notifications are not supported in this build of the app: " +
|
||||||
|
"it was built without Google's push service (Firebase), as F-Droid " +
|
||||||
|
"builds are. On-device notifications still work.",
|
||||||
|
TIMEOUTS.VERY_LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
choice === "server" &&
|
||||||
|
!this.warnIfUnregistered("Server notifications")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deliveryChangeInProgress = true;
|
||||||
|
try {
|
||||||
|
if (choice === "device") {
|
||||||
|
if (this.notifyingNewActivity) {
|
||||||
|
const revoked = await revokeInAppNewActivity((message) => {
|
||||||
|
this.reportAlertSearchAuthorizationFailure("revoke", message);
|
||||||
|
});
|
||||||
|
if (!revoked) return;
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
await this.scheduleNewActivityDualNotification(
|
||||||
|
this.notifyingNewActivityTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.forgetPushRegistration();
|
||||||
|
} else if (this.notifyingNewActivity) {
|
||||||
|
const uploaded = await this.uploadServerNewActivity(
|
||||||
|
this.notifyingNewActivityTime,
|
||||||
|
);
|
||||||
|
if (!uploaded) return;
|
||||||
|
await this.cancelDeviceNewActivity();
|
||||||
|
} else {
|
||||||
|
await initializeNativePushAndFirebaseMessaging();
|
||||||
|
}
|
||||||
|
await this.$saveSettings({ newActivityDelivery: choice });
|
||||||
|
this.newActivityDelivery = choice;
|
||||||
|
} finally {
|
||||||
|
this.deliveryChangeInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notify-api serves only registered identities. Warn when this one is
|
||||||
|
* not, before it tries to turn on a channel that needs the service.
|
||||||
|
*
|
||||||
|
* @param feature what is unavailable, such as "Text messages"
|
||||||
|
* @returns true when the identity is registered
|
||||||
|
*/
|
||||||
|
private warnIfUnregistered(feature: string): boolean {
|
||||||
|
if (this.isRegistered) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.notify.warning(
|
||||||
|
`${feature} are available once someone registers you. ` +
|
||||||
|
"In-app notifications that check only on this device work now.",
|
||||||
|
TIMEOUTS.VERY_LONG,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surface an AlertSearch upload or revoke failure. The message states the
|
* Surface an AlertSearch upload or revoke failure. The message states the
|
||||||
* failure; callers must not follow it with a success announcement.
|
* failure; callers must not follow it with a success announcement.
|
||||||
@@ -1689,6 +1967,10 @@ export default class AccountViewView extends Vue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.warnIfUnregistered("Text messages")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.notifyingNewActivitySmsPhone) {
|
if (this.notifyingNewActivitySmsPhone) {
|
||||||
// Number already verified: just switch the channel back on. Nothing is
|
// Number already verified: just switch the channel back on. Nothing is
|
||||||
// authorized yet -- the per-notification toggle is what asks for texts.
|
// authorized yet -- the per-notification toggle is what asks for texts.
|
||||||
@@ -1708,23 +1990,33 @@ export default class AccountViewView extends Vue {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onOption2: () => {
|
onOption2: () => {
|
||||||
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
|
this.openSmsVerification();
|
||||||
this.activeDid,
|
|
||||||
async (success: boolean, phoneNumber?: string) => {
|
|
||||||
if (success && phoneNumber) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingNewActivitySmsPhone: phoneNumber,
|
|
||||||
smsNotificationsEnabled: true,
|
|
||||||
});
|
|
||||||
this.notifyingNewActivitySmsPhone = phoneNumber;
|
|
||||||
this.smsEnabled = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a phone number and switch the SMS channel on.
|
||||||
|
*
|
||||||
|
* @param afterVerified runs once the number is verified and saved
|
||||||
|
*/
|
||||||
|
private openSmsVerification(afterVerified?: () => Promise<void>): void {
|
||||||
|
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
|
||||||
|
this.activeDid,
|
||||||
|
async (success: boolean, phoneNumber?: string) => {
|
||||||
|
if (success && phoneNumber) {
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivitySmsPhone: phoneNumber,
|
||||||
|
smsNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
this.notifyingNewActivitySmsPhone = phoneNumber;
|
||||||
|
this.smsEnabled = true;
|
||||||
|
await afterVerified?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forget the verified phone number entirely: the authorization is revoked,
|
* Forget the verified phone number entirely: the authorization is revoked,
|
||||||
* the registration is deleted from the notify-api, and every SMS
|
* the registration is deleted from the notify-api, and every SMS
|
||||||
@@ -1798,6 +2090,9 @@ export default class AccountViewView extends Vue {
|
|||||||
this.notifyingNewActivitySmsTime = "";
|
this.notifyingNewActivitySmsTime = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.warnIfUnregistered("Text messages")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.promptSmsTimeAndEnable();
|
await this.promptSmsTimeAndEnable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,7 +2132,7 @@ export default class AccountViewView extends Vue {
|
|||||||
* The time picker is used for its clock only: nothing is scheduled on this
|
* The time picker is used for its clock only: nothing is scheduled on this
|
||||||
* device, since the texts come from the server.
|
* device, since the texts come from the server.
|
||||||
*/
|
*/
|
||||||
async promptSmsTimeAndEnable(): Promise<void> {
|
async promptSmsTimeAndEnable(defaultTime?: string): Promise<void> {
|
||||||
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
|
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
|
||||||
DAILY_CHECK_TITLE,
|
DAILY_CHECK_TITLE,
|
||||||
async (success: boolean, timeText: string) => {
|
async (success: boolean, timeText: string) => {
|
||||||
@@ -1852,7 +2147,7 @@ export default class AccountViewView extends Vue {
|
|||||||
this.notifyingNewActivitySms = true;
|
this.notifyingNewActivitySms = true;
|
||||||
this.notifyingNewActivitySmsTime = timeText;
|
this.notifyingNewActivitySmsTime = timeText;
|
||||||
},
|
},
|
||||||
{ timeOnly: true },
|
{ timeOnly: true, defaultTime },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,6 +2160,9 @@ export default class AccountViewView extends Vue {
|
|||||||
* change, since the stored time is UTC and does not follow the wall clock.
|
* change, since the stored time is UTC and does not follow the wall clock.
|
||||||
*/
|
*/
|
||||||
async editNewActivitySmsNotification(): Promise<void> {
|
async editNewActivitySmsNotification(): Promise<void> {
|
||||||
|
if (!this.warnIfUnregistered("Text messages")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dialog = this.$refs
|
const dialog = this.$refs
|
||||||
.pushNotificationPermission as PushNotificationPermission;
|
.pushNotificationPermission as PushNotificationPermission;
|
||||||
|
|
||||||
@@ -1964,6 +2262,9 @@ export default class AccountViewView extends Vue {
|
|||||||
* @param startFromNow true when turning texts on: the first count starts
|
* @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
|
* now. False for a new time, which keeps the service's cursors so nothing
|
||||||
* waiting for the next text is dropped.
|
* waiting for the next text is dropped.
|
||||||
|
*
|
||||||
|
* Both are deliberate resets, so the upload asks for `rerunToday`: the new
|
||||||
|
* time fires today even when today's text already went.
|
||||||
* @returns true when the service accepted the authorization
|
* @returns true when the service accepted the authorization
|
||||||
*/
|
*/
|
||||||
private async authorizeSmsAlerts(
|
private async authorizeSmsAlerts(
|
||||||
@@ -1999,6 +2300,7 @@ export default class AccountViewView extends Vue {
|
|||||||
),
|
),
|
||||||
undefined,
|
undefined,
|
||||||
),
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2315,6 +2617,12 @@ export default class AccountViewView extends Vue {
|
|||||||
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
|
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
|
||||||
*/
|
*/
|
||||||
async editNewActivityNotification(): Promise<void> {
|
async editNewActivityNotification(): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.newActivityDelivery === "server" &&
|
||||||
|
!this.warnIfUnregistered("Server notifications")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dialog = this.$refs
|
const dialog = this.$refs
|
||||||
.pushNotificationPermission as PushNotificationPermission;
|
.pushNotificationPermission as PushNotificationPermission;
|
||||||
|
|
||||||
@@ -2322,7 +2630,30 @@ 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;
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (this.newActivityDelivery === "server") {
|
||||||
|
// The service schedules the push from its batch, so a new time
|
||||||
|
// means a new batch. It keeps its cursors, so nothing waiting for
|
||||||
|
// the next push is dropped, and it may fire today: a new time is a
|
||||||
|
// reset.
|
||||||
|
const uploaded = await authorizeInAppNewActivity(
|
||||||
|
(message) => {
|
||||||
|
this.reportAlertSearchAuthorizationFailure("upload", message);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
alertSearchUploadState(
|
||||||
|
buildNewActivityContext(
|
||||||
|
this.axios,
|
||||||
|
this.activeDid,
|
||||||
|
await this.$accountSettings(),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
{ timeText, rerunToday: true },
|
||||||
|
);
|
||||||
|
if (!uploaded.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (Capacitor.isNativePlatform()) {
|
||||||
const time24h = this.parseTimeTo24Hour(timeText);
|
const time24h = this.parseTimeTo24Hour(timeText);
|
||||||
const config = buildDualScheduleConfig({ notifyTime: time24h });
|
const config = buildDualScheduleConfig({ notifyTime: time24h });
|
||||||
const plugin = DailyNotification as unknown as {
|
const plugin = DailyNotification as unknown as {
|
||||||
@@ -2811,7 +3142,8 @@ export default class AccountViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveProfile(): Promise<void> {
|
/** @returns true when the server saved it */
|
||||||
|
async saveProfile(): Promise<boolean> {
|
||||||
this.savingProfile = true;
|
this.savingProfile = true;
|
||||||
try {
|
try {
|
||||||
const profileData: ProfileData = {
|
const profileData: ProfileData = {
|
||||||
@@ -2830,6 +3162,14 @@ export default class AccountViewView extends Vue {
|
|||||||
if (success) {
|
if (success) {
|
||||||
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
|
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
|
||||||
|
|
||||||
|
// the server holds the text now, so the phone's draft goes
|
||||||
|
if (this.onboardingState.interests || hasPoint(this.onboardingState)) {
|
||||||
|
await this.saveOnboardingState(
|
||||||
|
withoutInterests(this.onboardingState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.profileDraftFromPhone = false;
|
||||||
|
|
||||||
// Show seed phrase backup reminder if needed
|
// Show seed phrase backup reminder if needed
|
||||||
try {
|
try {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
@@ -2840,14 +3180,169 @@ export default class AccountViewView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error saving profile:", error);
|
logger.error("Error saving profile:", error);
|
||||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.savingProfile = false;
|
this.savingProfile = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showNextStepCard(): boolean {
|
||||||
|
return (
|
||||||
|
isNudgeOn(this.onboardingState, this.isRegistered) ||
|
||||||
|
(this.nextStepRequested && this.isRegistered)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone apps; elsewhere the card asks only for the profile. */
|
||||||
|
get nextStepOffersAlerts(): boolean {
|
||||||
|
return offersAlerts(this.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
get keptInterests(): string {
|
||||||
|
return this.onboardingState.interests || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get keptPointText(): string {
|
||||||
|
const state = this.onboardingState;
|
||||||
|
return hasPoint(state)
|
||||||
|
? `${state.lat.toFixed(2)}, ${state.lon.toFixed(2)}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOnboardingState(): Promise<void> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.onboardingState = parseOnboardingState(settings.onboardingState);
|
||||||
|
this.nextStepRequested = this.$route.query["nextStep"] === "1";
|
||||||
|
setNudgeOn(isNudgeOn(this.onboardingState, this.isRegistered));
|
||||||
|
this.nextStepSmsDisabledReason = (await this.activeIdentityIsPasskey())
|
||||||
|
? "Not available for passkey identities."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveOnboardingState(next: OnboardingState): Promise<void> {
|
||||||
|
this.onboardingState = next;
|
||||||
|
await this.$saveSettings(onboardingSettingsUpdate(next));
|
||||||
|
setNudgeOn(isNudgeOn(next, this.isRegistered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With no saved profile text, the editor starts from the phone's draft. */
|
||||||
|
private fillProfileFromKeptInterests(): void {
|
||||||
|
const state = this.onboardingState;
|
||||||
|
if (!state.interests) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.userProfileDesc = state.interests;
|
||||||
|
if (hasPoint(state) && !this.includeUserProfileLocation) {
|
||||||
|
this.userProfileLatitude = state.lat;
|
||||||
|
this.userProfileLongitude = state.lon;
|
||||||
|
this.includeUserProfileLocation = true;
|
||||||
|
this.isMapReady = false;
|
||||||
|
}
|
||||||
|
this.profileDraftFromPhone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgetKeptInterests(): Promise<void> {
|
||||||
|
await this.saveOnboardingState(withoutInterests(this.onboardingState));
|
||||||
|
if (this.profileDraftFromPhone) {
|
||||||
|
this.userProfileDesc = "";
|
||||||
|
this.userProfileLatitude = 0;
|
||||||
|
this.userProfileLongitude = 0;
|
||||||
|
this.includeUserProfileLocation = false;
|
||||||
|
this.profileDraftFromPhone = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card step 1: save the interests (and the rounded point, if kept) as the
|
||||||
|
* public profile, and make the same area the Nearby search box when there
|
||||||
|
* is none yet.
|
||||||
|
*/
|
||||||
|
async onNextStepSave(input: {
|
||||||
|
text: string;
|
||||||
|
includeLocation: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const state = this.onboardingState;
|
||||||
|
this.userProfileDesc = input.text;
|
||||||
|
if (input.includeLocation && hasPoint(state)) {
|
||||||
|
this.userProfileLatitude = state.lat;
|
||||||
|
this.userProfileLongitude = state.lon;
|
||||||
|
this.includeUserProfileLocation = true;
|
||||||
|
} else {
|
||||||
|
this.userProfileLatitude = 0;
|
||||||
|
this.userProfileLongitude = 0;
|
||||||
|
this.includeUserProfileLocation = false;
|
||||||
|
}
|
||||||
|
const box = hasPoint(state)
|
||||||
|
? boundingBoxAround(state.lat, state.lon)
|
||||||
|
: null;
|
||||||
|
if (!(await this.saveProfile())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nextStepProfileSaved = true;
|
||||||
|
if (box && !this.searchBox) {
|
||||||
|
const searchBox = { name: "Local", bbox: locBoxToBoundingBox(box) };
|
||||||
|
await this.$saveSettings({ searchBoxes: [searchBox] });
|
||||||
|
this.searchBox = searchBox;
|
||||||
|
this.isSearchAreasSet = true;
|
||||||
|
}
|
||||||
|
await this.finishNextStepIfComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card step 2, text: verify a phone if needed, then pick the time. */
|
||||||
|
async onNextStepSms(): Promise<void> {
|
||||||
|
if (this.smsRequestInProgress || this.nextStepSmsDisabledReason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.notifyingNewActivitySmsPhone) {
|
||||||
|
if (!this.smsEnabled) {
|
||||||
|
await this.$saveSettings({ smsNotificationsEnabled: true });
|
||||||
|
this.smsEnabled = true;
|
||||||
|
}
|
||||||
|
await this.promptSmsTimeAndEnable(NEXT_STEP_DEFAULT_TIME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.openSmsVerification(() =>
|
||||||
|
this.promptSmsTimeAndEnable(NEXT_STEP_DEFAULT_TIME),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card step 2, in-app: the same path as the toggle below. */
|
||||||
|
async onNextStepInApp(): Promise<void> {
|
||||||
|
if (!this.notifyingNewActivity) {
|
||||||
|
await this.showNewActivityNotificationChoice(NEXT_STEP_DEFAULT_TIME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onNextStepDone(): Promise<void> {
|
||||||
|
this.nextStepRequested = false;
|
||||||
|
await this.saveOnboardingState(withDone(this.onboardingState));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("notifyingNewActivity")
|
||||||
|
@Watch("notifyingNewActivitySms")
|
||||||
|
async onAlertChannelChange(on: boolean): Promise<void> {
|
||||||
|
if (on) {
|
||||||
|
await this.finishNextStepIfComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every card step done ends the onboarding; without alerts, that's the save. */
|
||||||
|
private async finishNextStepIfComplete(): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.showNextStepCard &&
|
||||||
|
this.nextStepProfileSaved &&
|
||||||
|
(!this.nextStepOffersAlerts ||
|
||||||
|
this.notifyingNewActivity ||
|
||||||
|
this.notifyingNewActivitySms)
|
||||||
|
) {
|
||||||
|
await this.onNextStepDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleUserProfileLocation(): void {
|
toggleUserProfileLocation(): void {
|
||||||
try {
|
try {
|
||||||
const updated = this.toggleProfileLocation({
|
const updated = this.toggleProfileLocation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<TopMessage />
|
<TopMessage />
|
||||||
|
<NextStepBanner />
|
||||||
|
|
||||||
<!-- Main View Heading -->
|
<!-- Main View Heading -->
|
||||||
<div class="flex gap-4 items-center mb-4">
|
<div class="flex gap-4 items-center mb-4">
|
||||||
@@ -167,6 +168,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right text-sm mt-2">
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'interest-matches' }"
|
||||||
|
class="text-blue-500"
|
||||||
|
data-testId="searchByInterests"
|
||||||
|
>
|
||||||
|
Search by interests…
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Search -->
|
<!-- Quick Search -->
|
||||||
<div
|
<div
|
||||||
id="QuickSearch"
|
id="QuickSearch"
|
||||||
@@ -372,6 +383,7 @@ import QuickNav from "../components/QuickNav.vue";
|
|||||||
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
||||||
|
import NextStepBanner from "../components/NextStepBanner.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import {
|
import {
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -382,6 +394,7 @@ import { BoundingBox } from "../db/tables/settings";
|
|||||||
import { PlanData } from "../interfaces";
|
import { PlanData } from "../interfaces";
|
||||||
import { didInfo, errorStringForLog, getHeaders } from "../libs/endorserServer";
|
import { didInfo, errorStringForLog, getHeaders } from "../libs/endorserServer";
|
||||||
import { OnboardPage, retrieveAccountDids } from "../libs/util";
|
import { OnboardPage, retrieveAccountDids } from "../libs/util";
|
||||||
|
import { shouldOpenWelcome } from "../libs/onboarding";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { UserProfile } from "@/libs/partnerServer";
|
import { UserProfile } from "@/libs/partnerServer";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
@@ -408,6 +421,7 @@ interface Tile {
|
|||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
LMap,
|
LMap,
|
||||||
LTileLayer,
|
LTileLayer,
|
||||||
|
NextStepBanner,
|
||||||
OnboardingDialog,
|
OnboardingDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
@@ -485,7 +499,7 @@ export default class DiscoverView extends Vue {
|
|||||||
|
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
if (!settings.finishedOnboarding && !hideOnboarding) {
|
if (shouldOpenWelcome(settings) && !hideOnboarding) {
|
||||||
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
||||||
OnboardPage.Discover,
|
OnboardPage.Discover,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Matches for Your Interests</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Both kinds of New Activity Notification below also count people and projects whose
|
||||||
|
descriptions are close in meaning to your saved public profile. Those matches come only
|
||||||
|
from the profile text you saved on the Profile page; interests kept only on this device
|
||||||
|
are not used until you save them to your profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
|
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
+37
-9
@@ -31,7 +31,16 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="ml-4">
|
<p class="ml-4">
|
||||||
If you'd like to see the page-by-page help again,
|
To find people and projects that match your interests,
|
||||||
|
<span
|
||||||
|
class="text-blue-500 cursor-pointer"
|
||||||
|
data-testId="enterInterestsAgain"
|
||||||
|
@click="openInterestsPrompt()"
|
||||||
|
>enter your interests again</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="ml-4">
|
||||||
|
If you'd like to see the page-by-page intro,
|
||||||
<span
|
<span
|
||||||
class="text-blue-500 cursor-pointer"
|
class="text-blue-500 cursor-pointer"
|
||||||
@click="unsetFinishedOnboarding()"
|
@click="unsetFinishedOnboarding()"
|
||||||
@@ -548,6 +557,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- eslint enable -->
|
<!-- eslint enable -->
|
||||||
|
<InterestsPromptDialog ref="interestsPromptDialog" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -558,8 +568,14 @@ import { copyToClipboard } from "../services/ClipboardService";
|
|||||||
// Capacitor import removed - using QRNavigationService instead
|
// Capacitor import removed - using QRNavigationService instead
|
||||||
|
|
||||||
import * as Package from "../../package.json";
|
import * as Package from "../../package.json";
|
||||||
|
import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import { APP_SERVER, NotificationIface, SUPPORT_EMAIL } from "../constants/app";
|
import { APP_SERVER, NotificationIface, SUPPORT_EMAIL } from "../constants/app";
|
||||||
|
import {
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
withWelcomeTour,
|
||||||
|
} from "../libs/onboarding";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { QRNavigationService } from "@/services/QRNavigationService";
|
import { QRNavigationService } from "@/services/QRNavigationService";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
@@ -592,7 +608,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|||||||
* @vue-facing-decorator
|
* @vue-facing-decorator
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
components: { QuickNav },
|
components: { InterestsPromptDialog, QuickNav },
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class HelpView extends Vue {
|
export default class HelpView extends Vue {
|
||||||
@@ -651,12 +667,19 @@ export default class HelpView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the interests prompt, pre-filled with what the phone kept. */
|
||||||
|
async openInterestsPrompt(): Promise<void> {
|
||||||
|
await (this.$refs.interestsPromptDialog as InterestsPromptDialog).open({
|
||||||
|
fromHelp: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset onboarding state for the active account
|
* Reset onboarding state for the active account
|
||||||
*
|
*
|
||||||
* This method allows users to restart the onboarding process by setting
|
* This starts the page-by-page help as a tour (`welcomeTour` in
|
||||||
* the finishedOnboarding flag to false for their active DID. This is useful
|
* `onboardingState`): Home, Discover, and Projects each open their page
|
||||||
* when users want to see the page-by-page help guide again.
|
* once, until the last page or a close ends it.
|
||||||
*
|
*
|
||||||
* @returns {Promise<void>} Promise that resolves when onboarding is reset
|
* @returns {Promise<void>} Promise that resolves when onboarding is reset
|
||||||
*/
|
*/
|
||||||
@@ -668,10 +691,15 @@ export default class HelpView extends Vue {
|
|||||||
const activeIdentity = await this.$getActiveIdentity();
|
const activeIdentity = await this.$getActiveIdentity();
|
||||||
|
|
||||||
if (activeIdentity.activeDid) {
|
if (activeIdentity.activeDid) {
|
||||||
await this.$updateSettings({
|
// the pages are a tour; the interests prompt is the onboarding
|
||||||
...settings,
|
await this.$saveSettings(
|
||||||
finishedOnboarding: false,
|
onboardingSettingsUpdate(
|
||||||
});
|
withWelcomeTour(
|
||||||
|
parseOnboardingState(settings.onboardingState),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
this.$log(
|
this.$log(
|
||||||
"[HelpView] Onboarding reset successfully for DID: " +
|
"[HelpView] Onboarding reset successfully for DID: " +
|
||||||
|
|||||||
+63
-2
@@ -13,8 +13,10 @@ Raymer * @version 1.0.0 */
|
|||||||
class="p-6 pb-24 max-w-3xl mx-auto"
|
class="p-6 pb-24 max-w-3xl mx-auto"
|
||||||
:data-active-did="activeDid"
|
:data-active-did="activeDid"
|
||||||
:data-new-activity-loaded="newActivityLoaded"
|
:data-new-activity-loaded="newActivityLoaded"
|
||||||
|
:data-onboarding-checked="onboardingChecked"
|
||||||
>
|
>
|
||||||
<TopMessage />
|
<TopMessage />
|
||||||
|
<NextStepBanner ref="nextStepBanner" deferred />
|
||||||
|
|
||||||
<!-- Main View Heading -->
|
<!-- Main View Heading -->
|
||||||
<div class="flex gap-4 items-center mb-4">
|
<div class="flex gap-4 items-center mb-4">
|
||||||
@@ -35,6 +37,7 @@ Raymer * @version 1.0.0 */
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OnboardingDialog ref="onboardingDialog" />
|
<OnboardingDialog ref="onboardingDialog" />
|
||||||
|
<InterestsPromptDialog ref="interestsPromptDialog" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
prompt to install notifications with notificationsSupported, which we're making an advanced
|
prompt to install notifications with notificationsSupported, which we're making an advanced
|
||||||
@@ -262,6 +265,8 @@ import GiftedDialog from "../components/GiftedDialog.vue";
|
|||||||
import GiftedPrompts from "../components/GiftedPrompts.vue";
|
import GiftedPrompts from "../components/GiftedPrompts.vue";
|
||||||
import FeedFilters from "../components/FeedFilters.vue";
|
import FeedFilters from "../components/FeedFilters.vue";
|
||||||
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
||||||
|
import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
|
||||||
|
import NextStepBanner from "../components/NextStepBanner.vue";
|
||||||
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
@@ -270,6 +275,7 @@ import ActivityListItem from "../components/ActivityListItem.vue";
|
|||||||
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||||
import {
|
import {
|
||||||
AppString,
|
AppString,
|
||||||
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
PASSKEYS_ENABLED,
|
PASSKEYS_ENABLED,
|
||||||
} from "../constants/app";
|
} from "../constants/app";
|
||||||
@@ -290,6 +296,17 @@ import {
|
|||||||
parseNewActivityCursorState,
|
parseNewActivityCursorState,
|
||||||
serializeNewActivityCursorState,
|
serializeNewActivityCursorState,
|
||||||
} from "../libs/newActivity";
|
} from "../libs/newActivity";
|
||||||
|
import { hasServerProfileText } from "../libs/partnerServer";
|
||||||
|
import {
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
shouldOpenWelcome,
|
||||||
|
withDone,
|
||||||
|
} from "../libs/onboarding";
|
||||||
|
import {
|
||||||
|
refreshNudge,
|
||||||
|
wasInterestsPromptShown,
|
||||||
|
} from "../utils/onboardingNudge";
|
||||||
import {
|
import {
|
||||||
countUnseenActivity,
|
countUnseenActivity,
|
||||||
seedIfNeeded,
|
seedIfNeeded,
|
||||||
@@ -387,6 +404,8 @@ interface FeedError {
|
|||||||
GiftedDialog,
|
GiftedDialog,
|
||||||
GiftedPrompts,
|
GiftedPrompts,
|
||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
|
InterestsPromptDialog,
|
||||||
|
NextStepBanner,
|
||||||
OnboardingDialog,
|
OnboardingDialog,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
@@ -424,6 +443,8 @@ export default class HomeView extends Vue {
|
|||||||
newActivityCounts: UnseenCounts | null = null;
|
newActivityCounts: UnseenCounts | null = null;
|
||||||
// the New Activity load finished (and stored any first start), for tests
|
// the New Activity load finished (and stored any first start), for tests
|
||||||
newActivityLoaded = false;
|
newActivityLoaded = false;
|
||||||
|
// the welcome or interests prompt has opened, or was not due, for tests
|
||||||
|
onboardingChecked = false;
|
||||||
searchBoxes: Array<{
|
searchBoxes: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
bbox: BoundingBox;
|
bbox: BoundingBox;
|
||||||
@@ -713,11 +734,24 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.finishedOnboarding) {
|
if (shouldOpenWelcome(settings)) {
|
||||||
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
await (this.$refs.onboardingDialog as OnboardingDialog).open(
|
||||||
OnboardPage.Home,
|
OnboardPage.Home,
|
||||||
);
|
);
|
||||||
|
this.onboardingChecked = true;
|
||||||
|
} else {
|
||||||
|
// not awaited: its server check must not hold up the feed
|
||||||
|
void this.openInterestsPromptIfDue(settings.onboardingState).finally(
|
||||||
|
() => {
|
||||||
|
this.onboardingChecked = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registration may have just been found, which turns the nudge on
|
||||||
|
const onboardingState = (await this.$accountSettings()).onboardingState;
|
||||||
|
refreshNudge({ onboardingState, isRegistered: this.isRegistered });
|
||||||
|
void (this.$refs.nextStepBanner as NextStepBanner | undefined)?.check();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.error("[HomeView Settings Trace] ❌ initializeIdentity() failed", {
|
logger.error("[HomeView Settings Trace] ❌ initializeIdentity() failed", {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
@@ -726,6 +760,33 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first visit to Home asks for interests. An identity
|
||||||
|
* that already has profile text on the server (a reinstall, a second device)
|
||||||
|
* skips it. The prompt's "x" leaves the stage, and this asks again on the
|
||||||
|
* next cold start.
|
||||||
|
*/
|
||||||
|
private async openInterestsPromptIfDue(onboardingState?: string) {
|
||||||
|
const state = parseOnboardingState(onboardingState);
|
||||||
|
if (state.stage !== "interests" || wasInterestsPromptShown()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await this.hasServerProfileText()) {
|
||||||
|
await this.$saveSettings(onboardingSettingsUpdate(withDone(state)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await (this.$refs.interestsPromptDialog as InterestsPromptDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasServerProfileText(): Promise<boolean> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
return hasServerProfileText(
|
||||||
|
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER,
|
||||||
|
await getHeaders(this.activeDid),
|
||||||
|
this.activeDid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures correct API server configuration
|
* Ensures correct API server configuration
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
<!--
|
||||||
|
InterestMatchesView.vue - people and projects closest in meaning to the
|
||||||
|
person's interests (GET /api/partner/similar), nearest first when they gave
|
||||||
|
a point. Reads the interests kept in settings.onboardingState, or `text`,
|
||||||
|
`lat`, `lon` from the route query. See
|
||||||
|
doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<QuickNav selected="Discover" />
|
||||||
|
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<TopMessage />
|
||||||
|
<NextStepBanner />
|
||||||
|
|
||||||
|
<div class="flex gap-4 items-center mb-4">
|
||||||
|
<a
|
||||||
|
class="text-lg text-center px-2 py-1 cursor-pointer"
|
||||||
|
aria-label="Back"
|
||||||
|
@click="$router.back()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||||
|
</a>
|
||||||
|
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
|
||||||
|
Matches for Your Interests
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="text"
|
||||||
|
class="bg-slate-100 rounded-md px-4 py-3 mb-4 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<div class="italic">"{{ text }}"</div>
|
||||||
|
<div v-if="point" class="text-slate-500 mt-1">
|
||||||
|
<font-awesome icon="location-dot" class="fa-fw" />
|
||||||
|
Within about {{ SEARCH_RADIUS_KM }} km of {{ point.lat.toFixed(2) }},
|
||||||
|
{{ point.lon.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-blue-500 cursor-pointer"
|
||||||
|
data-testId="changeInterests"
|
||||||
|
@click="openPrompt()"
|
||||||
|
>Change</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center mt-8">
|
||||||
|
<p class="mb-4">Tell us what you're interested in, and we'll search.</p>
|
||||||
|
<button
|
||||||
|
class="text-md 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-4 py-2 rounded-md"
|
||||||
|
@click="openPrompt()"
|
||||||
|
>
|
||||||
|
Enter your interests
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="text-center text-slate-500 mt-8">
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||||
|
Finding matches…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="text-center mt-8"
|
||||||
|
data-testId="interestMatchesError"
|
||||||
|
>
|
||||||
|
<p class="mb-4 text-slate-700">{{ errorMessage }}</p>
|
||||||
|
<button
|
||||||
|
class="text-md 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-4 py-2 rounded-md"
|
||||||
|
@click="search()"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="searched">
|
||||||
|
<p
|
||||||
|
v-if="nearby.length === 0 && farther.length === 0"
|
||||||
|
class="text-center text-slate-500 mt-8"
|
||||||
|
>
|
||||||
|
Nothing matches yet. Try describing your interests another way, or check
|
||||||
|
back later as more people join.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="strongGroups.length === 0"
|
||||||
|
class="text-center text-slate-500 mt-8"
|
||||||
|
data-testId="interestMatchesNoStrong"
|
||||||
|
>
|
||||||
|
No strong matches yet. Try describing your interests another way, or
|
||||||
|
check back later as more people join.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- With no strong match, how they'd hear about one later -->
|
||||||
|
<template v-if="strongGroups.length === 0 && followUp">
|
||||||
|
<RegistrationNotice
|
||||||
|
v-if="followUp === 'register'"
|
||||||
|
:passkeys-enabled="PASSKEYS_ENABLED"
|
||||||
|
:given-name="givenName"
|
||||||
|
:message="REGISTER_MESSAGE"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else-if="followUp === 'alertsOn'"
|
||||||
|
class="text-center text-slate-600 mt-4"
|
||||||
|
data-testId="interestMatchesAlertsOn"
|
||||||
|
>
|
||||||
|
Your daily alert is on, so we'll let you know when a strong match
|
||||||
|
joins or posts.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-else-if="followUp === 'setUp'"
|
||||||
|
class="text-center mt-4"
|
||||||
|
data-testId="interestMatchesSetUp"
|
||||||
|
>
|
||||||
|
<p class="text-slate-600 mb-2">
|
||||||
|
{{ setUpMessage }}
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'account', query: { nextStep: '1' } }"
|
||||||
|
class="inline-block text-md 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-4 py-2 rounded-md"
|
||||||
|
data-testId="interestMatchesSetUpLink"
|
||||||
|
>
|
||||||
|
{{ setUpLinkText }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="followUp === 'profileOnly'"
|
||||||
|
class="text-center text-slate-600 mt-4"
|
||||||
|
data-testId="interestMatchesProfileOnly"
|
||||||
|
>
|
||||||
|
Your saved profile lets people with interests like yours find you. To
|
||||||
|
be alerted about new matches, use the Android or iPhone app.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="group in visibleGroups"
|
||||||
|
:key="group.key"
|
||||||
|
class="mt-6"
|
||||||
|
:data-testId="'interestMatches-' + group.key"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="group.key === WEAK_TOGGLE_KEY"
|
||||||
|
class="w-full text-left text-slate-600 py-2"
|
||||||
|
data-testId="interestMatchesWeakToggle"
|
||||||
|
:aria-expanded="showWeak"
|
||||||
|
@click="showWeak = !showWeak"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="showWeak ? 'chevron-down' : 'chevron-right'"
|
||||||
|
class="fa-fw"
|
||||||
|
/>
|
||||||
|
{{ showWeak ? "Hide" : "Show" }} {{ weakCount }} weaker
|
||||||
|
{{ weakCount === 1 ? "match" : "matches" }}
|
||||||
|
</button>
|
||||||
|
<h2 v-else class="text-lg font-semibold mb-1">{{ group.title }}</h2>
|
||||||
|
<ul v-if="group.matches.length > 0" class="border-t border-slate-300">
|
||||||
|
<li
|
||||||
|
v-for="match in group.matches"
|
||||||
|
:key="match.subjectType + ':' + match.subjectId"
|
||||||
|
class="border-b border-slate-300"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="block py-3 flex gap-4 cursor-pointer"
|
||||||
|
@click="onClickMatch(match)"
|
||||||
|
>
|
||||||
|
<div v-if="match.subjectType === 'plan'">
|
||||||
|
<ProjectIcon
|
||||||
|
:entity-id="match.record.handleId"
|
||||||
|
:icon-size="40"
|
||||||
|
:image-url="match.record.image"
|
||||||
|
class="block border border-slate-300 rounded-md max-h-10 max-w-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-slate-400 text-2xl w-10 text-center">
|
||||||
|
<font-awesome icon="user" class="fa-fw" />
|
||||||
|
</div>
|
||||||
|
<div class="grow overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-base font-semibold truncate grow">
|
||||||
|
{{ titleFor(match) }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="similarityLabel(match.similarity)"
|
||||||
|
class="text-xs uppercase px-1.5 py-0.5 rounded"
|
||||||
|
:class="labelClasses(match.similarity)"
|
||||||
|
>{{ similarityLabel(match.similarity) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-600 line-clamp-2">
|
||||||
|
{{ match.record.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InterestsPromptDialog
|
||||||
|
ref="interestsPrompt"
|
||||||
|
@find-matches="onNewInterests"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
|
|
||||||
|
import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
|
||||||
|
import NextStepBanner from "../components/NextStepBanner.vue";
|
||||||
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
|
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||||
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
|
import { DEFAULT_PARTNER_API_SERVER, PASSKEYS_ENABLED } from "../constants/app";
|
||||||
|
import { UNNAMED_PROJECT } from "../constants/entities";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { didInfo, getHeaders } from "../libs/endorserServer";
|
||||||
|
import {
|
||||||
|
boundingBoxAround,
|
||||||
|
hasPoint,
|
||||||
|
isStrongMatch,
|
||||||
|
noStrongFollowUp,
|
||||||
|
NoStrongFollowUp,
|
||||||
|
offersAlerts,
|
||||||
|
onboardingSettingsUpdate,
|
||||||
|
parseOnboardingState,
|
||||||
|
SEARCH_RADIUS_KM,
|
||||||
|
similarityLabel,
|
||||||
|
withMatchesShown,
|
||||||
|
} from "../libs/onboarding";
|
||||||
|
import {
|
||||||
|
fetchSimilar,
|
||||||
|
hasServerProfileText,
|
||||||
|
SimilarMatch,
|
||||||
|
SimilarResult,
|
||||||
|
} from "../libs/partnerServer";
|
||||||
|
import { retrieveAccountDids } from "../libs/util";
|
||||||
|
import { refreshNudge } from "../utils/onboardingNudge";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
|
|
||||||
|
/** Fewer nearby results than this, and a second search fills "Farther away". */
|
||||||
|
const FEW_NEARBY = 5;
|
||||||
|
|
||||||
|
const REGISTER_MESSAGE =
|
||||||
|
"Find someone to register you, and then you can be notified when " +
|
||||||
|
"someone with interests like yours joins.";
|
||||||
|
|
||||||
|
/** The group key of the row that expands the weaker matches. */
|
||||||
|
const WEAK_TOGGLE_KEY = "weakToggle";
|
||||||
|
|
||||||
|
interface MatchGroup {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
matches: SimilarMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** People and projects nearby, then everything farther, minus empty groups. */
|
||||||
|
function groupMatches(
|
||||||
|
nearby: SimilarMatch[],
|
||||||
|
farther: SimilarMatch[],
|
||||||
|
keyPrefix: string,
|
||||||
|
): MatchGroup[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: keyPrefix + "people",
|
||||||
|
title: "People",
|
||||||
|
matches: nearby.filter((m) => m.subjectType === "profile"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: keyPrefix + "projects",
|
||||||
|
title: "Projects",
|
||||||
|
matches: nearby.filter((m) => m.subjectType === "plan"),
|
||||||
|
},
|
||||||
|
{ key: keyPrefix + "farther", title: "Farther away", matches: farther },
|
||||||
|
].filter((group) => group.matches.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
name: "InterestMatchesView",
|
||||||
|
components: {
|
||||||
|
InterestsPromptDialog,
|
||||||
|
NextStepBanner,
|
||||||
|
ProjectIcon,
|
||||||
|
QuickNav,
|
||||||
|
RegistrationNotice,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class InterestMatchesView extends Vue {
|
||||||
|
$route!: RouteLocationNormalizedLoaded;
|
||||||
|
$router!: Router;
|
||||||
|
|
||||||
|
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||||
|
REGISTER_MESSAGE = REGISTER_MESSAGE;
|
||||||
|
SEARCH_RADIUS_KM = SEARCH_RADIUS_KM;
|
||||||
|
WEAK_TOGGLE_KEY = WEAK_TOGGLE_KEY;
|
||||||
|
similarityLabel = similarityLabel;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
allContacts: Contact[] = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
|
errorMessage = "";
|
||||||
|
farther: SimilarMatch[] = [];
|
||||||
|
/** Set after a search with no strong match; empty until then */
|
||||||
|
followUp: NoStrongFollowUp | "" = "";
|
||||||
|
givenName = "";
|
||||||
|
isLoading = false;
|
||||||
|
/** Known only once followUp is set */
|
||||||
|
needsAlert = false;
|
||||||
|
needsProfile = false;
|
||||||
|
nearby: SimilarMatch[] = [];
|
||||||
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
|
point: { lat: number; lon: number } | null = null;
|
||||||
|
searched = false;
|
||||||
|
showWeak = false;
|
||||||
|
text = "";
|
||||||
|
|
||||||
|
get strongGroups(): MatchGroup[] {
|
||||||
|
const strong = (m: SimilarMatch) => isStrongMatch(m.similarity);
|
||||||
|
return groupMatches(
|
||||||
|
this.nearby.filter(strong),
|
||||||
|
this.farther.filter(strong),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get weakGroups(): MatchGroup[] {
|
||||||
|
const weak = (m: SimilarMatch) => !isStrongMatch(m.similarity);
|
||||||
|
return groupMatches(
|
||||||
|
this.nearby.filter(weak),
|
||||||
|
this.farther.filter(weak),
|
||||||
|
"weak-",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get setUpMessage(): string {
|
||||||
|
if (this.needsProfile && this.needsAlert) {
|
||||||
|
return "Save your interests to your public profile and turn on a daily alert, and we'll let you know when a strong match joins or posts.";
|
||||||
|
}
|
||||||
|
if (this.needsProfile) {
|
||||||
|
return "Alerts about matches use your public profile. Save your interests there, and we'll let you know when a strong match joins or posts.";
|
||||||
|
}
|
||||||
|
return "Turn on a daily alert, and we'll let you know when a strong match joins or posts.";
|
||||||
|
}
|
||||||
|
|
||||||
|
get setUpLinkText(): string {
|
||||||
|
if (this.needsProfile && this.needsAlert)
|
||||||
|
return "Save profile and set up alerts";
|
||||||
|
return this.needsProfile ? "Save to your profile" : "Set up alerts";
|
||||||
|
}
|
||||||
|
|
||||||
|
get weakCount(): number {
|
||||||
|
return this.weakGroups.reduce((n, g) => n + g.matches.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strong matches, then a row that expands the weaker ones, which follow
|
||||||
|
* only while expanded.
|
||||||
|
*/
|
||||||
|
get visibleGroups(): MatchGroup[] {
|
||||||
|
if (this.weakCount === 0) return this.strongGroups;
|
||||||
|
const toggle = { key: WEAK_TOGGLE_KEY, title: "", matches: [] };
|
||||||
|
return [
|
||||||
|
...this.strongGroups,
|
||||||
|
toggle,
|
||||||
|
...(this.showWeak ? this.weakGroups : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const activeIdentity = await this.$getActiveIdentity();
|
||||||
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
|
this.givenName = settings.firstName || "";
|
||||||
|
this.allContacts = await this.$getAllContacts();
|
||||||
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
|
const queryText = this.$route.query["text"]?.toString() || "";
|
||||||
|
if (queryText) {
|
||||||
|
this.text = queryText;
|
||||||
|
const lat = Number(this.$route.query["lat"]);
|
||||||
|
const lon = Number(this.$route.query["lon"]);
|
||||||
|
this.point =
|
||||||
|
this.$route.query["lat"] != null &&
|
||||||
|
this.$route.query["lon"] != null &&
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Number.isFinite(lon)
|
||||||
|
? { lat, lon }
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
this.loadFromState(settings.onboardingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.text) {
|
||||||
|
await this.search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFromState(onboardingState: string | undefined): void {
|
||||||
|
const state = parseOnboardingState(onboardingState);
|
||||||
|
this.text = state.interests || "";
|
||||||
|
this.point = hasPoint(state) ? { lat: state.lat, lon: state.lon } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(): Promise<void> {
|
||||||
|
if (!this.text) return;
|
||||||
|
this.isLoading = true;
|
||||||
|
this.errorMessage = "";
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const box = this.point
|
||||||
|
? boundingBoxAround(this.point.lat, this.point.lon)
|
||||||
|
: null;
|
||||||
|
const first = await fetchSimilar(this.partnerApiServer, headers, {
|
||||||
|
text: this.text,
|
||||||
|
to: "both",
|
||||||
|
limit: 50,
|
||||||
|
box,
|
||||||
|
});
|
||||||
|
if (first.kind !== "ok") {
|
||||||
|
this.showError(first);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nearby = first.matches;
|
||||||
|
this.farther = [];
|
||||||
|
this.showWeak = false;
|
||||||
|
if (box && first.matches.length < FEW_NEARBY) {
|
||||||
|
// the server caches the query's vector, so this costs no inference
|
||||||
|
const wide = await fetchSimilar(this.partnerApiServer, headers, {
|
||||||
|
text: this.text,
|
||||||
|
to: "both",
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
if (wide.kind === "ok") {
|
||||||
|
const seen = new Set(
|
||||||
|
first.matches.map((m) => m.subjectType + ":" + m.subjectId),
|
||||||
|
);
|
||||||
|
this.farther = wide.matches.filter(
|
||||||
|
(m) => !seen.has(m.subjectType + ":" + m.subjectId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.searched = true;
|
||||||
|
await this.recordMatchesShown();
|
||||||
|
await this.loadFollowUp(headers);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[InterestMatchesView] Search failed:", error);
|
||||||
|
this.errorMessage =
|
||||||
|
"Something went wrong while searching. Please try again.";
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With no strong match, work out how they'd hear about one later. */
|
||||||
|
private async loadFollowUp(headers: Record<string, string>): Promise<void> {
|
||||||
|
this.followUp = "";
|
||||||
|
if (this.strongGroups.length > 0) return;
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const isRegistered = !!settings.isRegistered;
|
||||||
|
const alertsOn =
|
||||||
|
!!settings.notifyingNewActivityTime ||
|
||||||
|
!!settings.notifyingNewActivitySmsTime;
|
||||||
|
const appOffersAlerts = offersAlerts(this.capabilities);
|
||||||
|
const profileSaved =
|
||||||
|
isRegistered &&
|
||||||
|
(await hasServerProfileText(
|
||||||
|
this.partnerApiServer,
|
||||||
|
headers,
|
||||||
|
this.activeDid,
|
||||||
|
));
|
||||||
|
this.needsProfile = !profileSaved;
|
||||||
|
this.needsAlert = appOffersAlerts && !alertsOn;
|
||||||
|
this.followUp = noStrongFollowUp({
|
||||||
|
isRegistered,
|
||||||
|
profileSaved,
|
||||||
|
alertsOn,
|
||||||
|
offersAlerts: appOffersAlerts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError(result: Exclude<SimilarResult, { kind: "ok" }>): void {
|
||||||
|
logger.warn("[InterestMatchesView] Search not answered:", result);
|
||||||
|
this.errorMessage = result.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first results seen turn on the nudge (stage `matches` → `save`). */
|
||||||
|
private async recordMatchesShown(): Promise<void> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const state = parseOnboardingState(settings.onboardingState);
|
||||||
|
const next = withMatchesShown(state, new Date());
|
||||||
|
if (next !== state) {
|
||||||
|
const update = onboardingSettingsUpdate(next);
|
||||||
|
await this.$saveSettings(update);
|
||||||
|
refreshNudge({ ...settings, ...update });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
titleFor(match: SimilarMatch): string {
|
||||||
|
if (match.subjectType === "plan") {
|
||||||
|
return match.record.name || UNNAMED_PROJECT;
|
||||||
|
}
|
||||||
|
return didInfo(
|
||||||
|
match.record.issuerDid,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
labelClasses(similarity: number): string {
|
||||||
|
switch (similarityLabel(similarity)) {
|
||||||
|
case "strong":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "weak":
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickMatch(match: SimilarMatch): void {
|
||||||
|
if (match.subjectType === "plan") {
|
||||||
|
this.$router.push({
|
||||||
|
path: "/project/" + encodeURIComponent(match.record.handleId),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$router.push({
|
||||||
|
path: "/user-profile/" + encodeURIComponent(match.subjectId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openPrompt(): void {
|
||||||
|
(this.$refs.interestsPrompt as InterestsPromptDialog).open({
|
||||||
|
fromHelp: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New words from the prompt, which already kept them in settings. */
|
||||||
|
async onNewInterests(): Promise<void> {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.loadFromState(settings.onboardingState);
|
||||||
|
if (Object.keys(this.$route.query).length > 0) {
|
||||||
|
await this.$router.replace({ name: "interest-matches" });
|
||||||
|
}
|
||||||
|
this.searched = false;
|
||||||
|
await this.search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -140,7 +140,6 @@ import {
|
|||||||
NEW_ACTIVITY_BUCKET_INFO,
|
NEW_ACTIVITY_BUCKET_INFO,
|
||||||
NewActivityBucket,
|
NewActivityBucket,
|
||||||
NewActivityBucketInfo,
|
NewActivityBucketInfo,
|
||||||
nextWindow,
|
|
||||||
parseNewActivityCursorState,
|
parseNewActivityCursorState,
|
||||||
serializeNewActivityCursorState,
|
serializeNewActivityCursorState,
|
||||||
ulidJustBefore,
|
ulidJustBefore,
|
||||||
@@ -237,9 +236,10 @@ export default class NewActivityBucketView extends Vue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const bucketWindow = state.buckets?.[bucket];
|
const bucketWindow = state.buckets?.[bucket];
|
||||||
|
// from where this list last ended, so what it showed then stays read;
|
||||||
// a bucket never opened starts where the summary does
|
// a bucket never opened starts where the summary does
|
||||||
const from = bucketWindow
|
const from = bucketWindow
|
||||||
? windowStart(bucketWindow, now)
|
? bucketWindow.to
|
||||||
: windowStart(state.visit, now)?.[server];
|
: windowStart(state.visit, now)?.[server];
|
||||||
|
|
||||||
const page = await loadBucket(context, bucket, from);
|
const page = await loadBucket(context, bucket, from);
|
||||||
@@ -254,14 +254,13 @@ export default class NewActivityBucketView extends Vue {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
if (page.nextAfterCursor) {
|
if (page.nextAfterCursor) {
|
||||||
const window = bucketWindow
|
const window = {
|
||||||
? nextWindow(bucketWindow, page.nextAfterCursor, now)
|
...(from ? { from } : {}),
|
||||||
: {
|
...(bucketWindow?.seenAt ? { fromSeenAt: bucketWindow.seenAt } : {}),
|
||||||
...(from ? { from } : {}),
|
to: page.nextAfterCursor,
|
||||||
to: page.nextAfterCursor,
|
at: now.toISOString(),
|
||||||
at: now.toISOString(),
|
seenAt: now.toISOString(),
|
||||||
seenAt: now.toISOString(),
|
};
|
||||||
};
|
|
||||||
// re-read: the summary may have saved the visit meanwhile
|
// re-read: the summary may have saved the visit meanwhile
|
||||||
const latest = parseNewActivityCursorState(
|
const latest = parseNewActivityCursorState(
|
||||||
(await this.$accountSettings()).newActivityCursors,
|
(await this.$accountSettings()).newActivityCursors,
|
||||||
@@ -402,7 +401,11 @@ export default class NewActivityBucketView extends Vue {
|
|||||||
...latest,
|
...latest,
|
||||||
buckets: {
|
buckets: {
|
||||||
...latest.buckets,
|
...latest.buckets,
|
||||||
[this.bucket]: { ...window, to: page.nextAfterCursor },
|
[this.bucket]: {
|
||||||
|
...window,
|
||||||
|
to: page.nextAfterCursor,
|
||||||
|
seenAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,12 +31,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="mb-2" data-testId="newActivityTotal">
|
<div class="flex items-baseline gap-4 mb-2">
|
||||||
<span class="text-lg font-semibold"
|
<p class="grow" data-testId="newActivityTotal">
|
||||||
>{{ total }}{{ anyHitLimit ? "+" : "" }}</span
|
<span class="text-lg font-semibold"
|
||||||
|
>{{ total }}{{ anyHitLimit ? "+" : "" }}</span
|
||||||
|
>
|
||||||
|
new since
|
||||||
|
<template v-if="countedSince"
|
||||||
|
>{{ formatTime(countedSince) }}.</template
|
||||||
|
>
|
||||||
|
<template v-else>your last visit here.</template>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="total > 0 || anyHitLimit"
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-blue-500 whitespace-nowrap"
|
||||||
|
:disabled="markingRead"
|
||||||
|
data-testId="newActivityMarkAllRead"
|
||||||
|
@click="confirmMarkAllRead"
|
||||||
>
|
>
|
||||||
new since your last visit here.
|
Mark all read
|
||||||
</p>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="notificationNote"
|
v-if="notificationNote"
|
||||||
@@ -121,18 +137,21 @@ import {
|
|||||||
NewActivityBucket,
|
NewActivityBucket,
|
||||||
NewActivityCursorState,
|
NewActivityCursorState,
|
||||||
NewActivityPrerequisite,
|
NewActivityPrerequisite,
|
||||||
|
markedReadState,
|
||||||
nextWindow,
|
nextWindow,
|
||||||
parseCursorParam,
|
parseCursorParam,
|
||||||
parseNewActivityCursorState,
|
parseNewActivityCursorState,
|
||||||
sameCursorPair,
|
sameCursorPair,
|
||||||
serializeNewActivityCursorState,
|
serializeNewActivityCursorState,
|
||||||
windowStart,
|
windowStart,
|
||||||
|
windowStartSeenAt,
|
||||||
} from "../libs/newActivity";
|
} from "../libs/newActivity";
|
||||||
import {
|
import {
|
||||||
alertSearchUploadState,
|
alertSearchUploadState,
|
||||||
bucketIsAvailable,
|
bucketIsAvailable,
|
||||||
countNewActivity,
|
countNewActivity,
|
||||||
countsEndPair,
|
countsEndPair,
|
||||||
|
countSummaryActivity,
|
||||||
NewActivityContext,
|
NewActivityContext,
|
||||||
NewActivityCounts,
|
NewActivityCounts,
|
||||||
totalNewActivity,
|
totalNewActivity,
|
||||||
@@ -149,7 +168,8 @@ import { createNotifyHelpers } from "@/utils/notify";
|
|||||||
/**
|
/**
|
||||||
* New Activity summary: one count per alertSearch bucket since the last visit,
|
* New Activity summary: one count per alertSearch bucket since the last visit,
|
||||||
* the same window the daily notification counts. Opening it starts a new
|
* the same window the daily notification counts. Opening it starts a new
|
||||||
* visit and re-authorizes the notifications from its end.
|
* visit and re-authorizes the notifications from its end. "Mark all read"
|
||||||
|
* moves every start to the end of the counts on screen.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
components: { QuickNav },
|
components: { QuickNav },
|
||||||
@@ -167,6 +187,18 @@ export default class NewActivityView extends Vue {
|
|||||||
counts: NewActivityCounts | null = null;
|
counts: NewActivityCounts | null = null;
|
||||||
loading = true;
|
loading = true;
|
||||||
notificationNote = "";
|
notificationNote = "";
|
||||||
|
/** When the visit's start cursor was recorded: the counts are new since then. */
|
||||||
|
countedSince: string | undefined = undefined;
|
||||||
|
/** When the counts on screen were read; "Mark all read" stops there. */
|
||||||
|
countedAt: Date | undefined = undefined;
|
||||||
|
markingRead = false;
|
||||||
|
|
||||||
|
formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get total(): number {
|
get total(): number {
|
||||||
return this.counts ? totalNewActivity(this.counts) : 0;
|
return this.counts ? totalNewActivity(this.counts) : 0;
|
||||||
@@ -201,7 +233,8 @@ export default class NewActivityView extends Vue {
|
|||||||
|
|
||||||
bucketHitLimit(bucket: NewActivityBucket): boolean {
|
bucketHitLimit(bucket: NewActivityBucket): boolean {
|
||||||
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
||||||
return !!this.counts?.[server].hitLimit;
|
const counts = this.counts?.[server];
|
||||||
|
return !!(counts?.bucketHitLimit?.[bucket] ?? counts?.hitLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
bucketError(bucket: NewActivityBucket): string | undefined {
|
bucketError(bucket: NewActivityBucket): string | undefined {
|
||||||
@@ -243,8 +276,11 @@ export default class NewActivityView extends Vue {
|
|||||||
// becomes the start of the next visit
|
// becomes the start of the next visit
|
||||||
const from: CursorPair | undefined =
|
const from: CursorPair | undefined =
|
||||||
windowStart(state.visit, now) ?? notified;
|
windowStart(state.visit, now) ?? notified;
|
||||||
|
this.countedSince = windowStartSeenAt(state.visit, now);
|
||||||
|
|
||||||
this.counts = await countNewActivity(context, from);
|
// a bucket whose list was opened since then counts from that list's end
|
||||||
|
this.counts = await countSummaryActivity(context, state, now, notified);
|
||||||
|
this.countedAt = now;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
if (notified && from && !sameCursorPair(notified, from)) {
|
if (notified && from && !sameCursorPair(notified, from)) {
|
||||||
@@ -305,6 +341,52 @@ export default class NewActivityView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmMarkAllRead(): void {
|
||||||
|
if (!this.countedAt) return;
|
||||||
|
this.notify.confirm(
|
||||||
|
"Mark everything counted here as read? Every list starts over from " +
|
||||||
|
this.formatTime(this.countedAt.toISOString()) +
|
||||||
|
", and so do your notifications.",
|
||||||
|
() => this.markAllRead(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark everything up to the end of the counts on screen as read, recount
|
||||||
|
* from there (catching what arrived since), and re-authorize the
|
||||||
|
* notifications from there.
|
||||||
|
*/
|
||||||
|
async markAllRead(): Promise<void> {
|
||||||
|
const end = this.counts && countsEndPair(this.counts);
|
||||||
|
if (!this.context || !end) {
|
||||||
|
this.notify.error("Could not check every server, so nothing changed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.markingRead = true;
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const state = markedReadState(
|
||||||
|
parseNewActivityCursorState(settings.newActivityCursors),
|
||||||
|
end,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
await this.saveCursorState(state);
|
||||||
|
this.notificationNote = "";
|
||||||
|
this.countedSince = now.toISOString();
|
||||||
|
this.counts = await countSummaryActivity(this.context, state, now);
|
||||||
|
this.countedAt = now;
|
||||||
|
await this.refreshNotifications(settings, state, end, end);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.error("[NewActivityView] Error marking all read:", err);
|
||||||
|
this.notify.error(
|
||||||
|
(err as Error)?.message || "There was an error marking all read.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.markingRead = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveCursorState(state: NewActivityCursorState): Promise<void> {
|
async saveCursorState(state: NewActivityCursorState): Promise<void> {
|
||||||
await this.$saveUserSettings(this.activeDid, {
|
await this.$saveUserSettings(this.activeDid, {
|
||||||
newActivityCursors: serializeNewActivityCursorState(state),
|
newActivityCursors: serializeNewActivityCursorState(state),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<TopMessage />
|
<TopMessage />
|
||||||
|
<NextStepBanner />
|
||||||
|
|
||||||
<!-- Main View Heading -->
|
<!-- Main View Heading -->
|
||||||
<div class="flex gap-4 items-center mb-4">
|
<div class="flex gap-4 items-center mb-4">
|
||||||
@@ -280,12 +281,14 @@ import InfiniteScroll from "../components/InfiniteScroll.vue";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
|
import NextStepBanner from "../components/NextStepBanner.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
|
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
|
||||||
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
|
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
|
||||||
import { OnboardPage, iconForUnitCode } from "../libs/util";
|
import { OnboardPage, iconForUnitCode } from "../libs/util";
|
||||||
|
import { shouldOpenWelcome } from "../libs/onboarding";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
@@ -323,6 +326,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
|
|||||||
EntityIcon,
|
EntityIcon,
|
||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
|
NextStepBanner,
|
||||||
OnboardingDialog,
|
OnboardingDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
@@ -432,7 +436,7 @@ export default class ProjectsView extends Vue {
|
|||||||
*/
|
*/
|
||||||
private async checkOnboardingStatus() {
|
private async checkOnboardingStatus() {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
if (!settings.finishedOnboarding) {
|
if (shouldOpenWelcome(settings)) {
|
||||||
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
||||||
OnboardPage.Create,
|
OnboardPage.Create,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<!-- Sub View Heading -->
|
<!-- Sub View Heading -->
|
||||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
{{ startupError ? "Time Safari Cannot Start" : "Generate an Identity" }}
|
{{ startupError ? "TimeSafari Cannot Start" : "Generate an Identity" }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Back and Help lead nowhere while the database is down -->
|
<!-- Back and Help lead nowhere while the database is down -->
|
||||||
@@ -319,7 +319,7 @@ export default class StartView extends Vue {
|
|||||||
const body = this.errorReport.slice(0, 1800);
|
const body = this.errorReport.slice(0, 1800);
|
||||||
return (
|
return (
|
||||||
`mailto:${SUPPORT_EMAIL}` +
|
`mailto:${SUPPORT_EMAIL}` +
|
||||||
`?subject=${encodeURIComponent("Time Safari startup error")}` +
|
`?subject=${encodeURIComponent("TimeSafari startup error")}` +
|
||||||
`&body=${encodeURIComponent(body)}`
|
`&body=${encodeURIComponent(body)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-1
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-bold mb-4">SQL Operations</h2>
|
<h2 class="text-xl font-bold mb-4">App SQL</h2>
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<button :class="sqlLinkClasses" @click="setAllTablesQuery">
|
<button :class="sqlLinkClasses" @click="setAllTablesQuery">
|
||||||
All Tables
|
All Tables
|
||||||
@@ -95,6 +95,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">
|
||||||
|
Notification Plugin SQL (read-only)
|
||||||
|
</h2>
|
||||||
|
<div v-if="!notificationPluginDatabaseFile">
|
||||||
|
The daily-notification-plugin keeps a database only on Android and iOS.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-sm mb-2">
|
||||||
|
Separate from the app database: {{ notificationPluginDatabaseFile }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
:class="sqlLinkClasses"
|
||||||
|
@click="
|
||||||
|
setNotificationPluginQuery(
|
||||||
|
`SELECT * FROM sqlite_master WHERE type='table';`,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
All Tables
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="table in notificationPluginTables"
|
||||||
|
:key="table"
|
||||||
|
:class="sqlLinkClasses"
|
||||||
|
@click="setNotificationPluginQuery(`SELECT * FROM ${table};`)"
|
||||||
|
>
|
||||||
|
{{ table }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
v-model="notificationPluginSqlQuery"
|
||||||
|
class="w-full h-32 p-2 border border-gray-300 rounded-md font-mono"
|
||||||
|
placeholder="Enter a SELECT or PRAGMA query here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
:class="primaryButtonClasses"
|
||||||
|
@click="executeNotificationPluginSql"
|
||||||
|
>
|
||||||
|
Execute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="notificationPluginSqlResult" class="mt-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Result:</h3>
|
||||||
|
<pre class="bg-gray-100 p-4 rounded-md overflow-x-auto">{{
|
||||||
|
JSON.stringify(notificationPluginSqlResult, null, 2)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
|
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
|
||||||
Populates the "shared-photo" view as if they used "share_target".
|
Populates the "shared-photo" view as if they used "share_target".
|
||||||
@@ -349,6 +404,12 @@ import { testBecomeUser0, testServerRegisterUser } from "@/test";
|
|||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { Account } from "../db/tables/accounts";
|
import { Account } from "../db/tables/accounts";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import {
|
||||||
|
NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM,
|
||||||
|
notificationPluginDatabaseFile,
|
||||||
|
queryNotificationPluginDatabase,
|
||||||
|
} from "@/services/notifications/notificationPluginDatabase";
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
import EntityGridFunctionPropTest from "../test/EntityGridFunctionPropTest.vue";
|
import EntityGridFunctionPropTest from "../test/EntityGridFunctionPropTest.vue";
|
||||||
import PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue";
|
import PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue";
|
||||||
|
|
||||||
@@ -421,6 +482,13 @@ export default class Help extends Vue {
|
|||||||
sqlResult: unknown = null;
|
sqlResult: unknown = null;
|
||||||
returnRawResults = false;
|
returnRawResults = false;
|
||||||
|
|
||||||
|
// for SQL operations on the daily-notification-plugin database
|
||||||
|
notificationPluginDatabaseFile = notificationPluginDatabaseFile();
|
||||||
|
notificationPluginTables =
|
||||||
|
NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM[Capacitor.getPlatform()] ?? [];
|
||||||
|
notificationPluginSqlQuery = "";
|
||||||
|
notificationPluginSqlResult: unknown = null;
|
||||||
|
|
||||||
cryptoLib = cryptoLib;
|
cryptoLib = cryptoLib;
|
||||||
|
|
||||||
// for component tests
|
// for component tests
|
||||||
@@ -547,6 +615,11 @@ export default class Help extends Vue {
|
|||||||
this.executeSql();
|
this.executeSql();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setNotificationPluginQuery(query: string) {
|
||||||
|
this.notificationPluginSqlQuery = query;
|
||||||
|
this.executeNotificationPluginSql();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for notification test buttons
|
* Configuration for notification test buttons
|
||||||
* Eliminates repetitive notification button definitions in template
|
* Eliminates repetitive notification button definitions in template
|
||||||
@@ -1022,6 +1095,34 @@ export default class Help extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a read-only query against the daily-notification-plugin's
|
||||||
|
* separate database (see notificationPluginDatabase.ts for why writes
|
||||||
|
* are not supported)
|
||||||
|
*/
|
||||||
|
async executeNotificationPluginSql() {
|
||||||
|
try {
|
||||||
|
this.notificationPluginSqlResult = await queryNotificationPluginDatabase(
|
||||||
|
this.notificationPluginSqlQuery,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
"Test Notification Plugin SQL Result:",
|
||||||
|
this.notificationPluginSqlResult,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Test Notification Plugin SQL Error:", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: NOTIFY_SQL_ERROR.title,
|
||||||
|
text: createSqlErrorMessage(error),
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the URL flow from initialization to change propagation.
|
* Tests the URL flow from initialization to change propagation.
|
||||||
* This simulates the flow where a user's DID is set, and then the
|
* This simulates the flow where a user's DID is set, and then the
|
||||||
|
|||||||
@@ -62,21 +62,21 @@
|
|||||||
* @example Checking activity feed
|
* @example Checking activity feed
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* await page.goto('./');
|
* await page.goto('./');
|
||||||
* await page.getByTestId('closeOnboardingAndFinish').click();
|
* await closeOnboardingIfShown(page);
|
||||||
* await expect(page.locator('ul#listLatestActivity li:nth-child(10)'))
|
* await expect(page.locator('ul#listLatestActivity li:nth-child(10)'))
|
||||||
* .toBeVisible();
|
* .toBeVisible();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { generateNewEthrUser, importUser, deleteContact, switchToUser } from './testUtils';
|
import { generateNewEthrUser, importUser, deleteContact, switchToUser, closeOnboardingIfShown } from './testUtils';
|
||||||
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
|
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
|
||||||
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
||||||
|
|
||||||
test('Check activity feed - check that server is running', async ({ page }) => {
|
test('Check activity feed - check that server is running', async ({ page }) => {
|
||||||
// Load app homepage
|
// Load app homepage
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
|
|
||||||
// Check that initial 10 activities have been loaded
|
// Check that initial 10 activities have been loaded
|
||||||
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
|
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
|
||||||
@@ -136,7 +136,7 @@ test('Check ID generation', async ({ page }) => {
|
|||||||
test('Check setting name & sharing info', async ({ page }) => {
|
test('Check setting name & sharing info', async ({ page }) => {
|
||||||
// Load homepage to trigger ID generation (?)
|
// Load homepage to trigger ID generation (?)
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
// Wait for dialog to be hidden or removed - try multiple approaches
|
// Wait for dialog to be hidden or removed - try multiple approaches
|
||||||
try {
|
try {
|
||||||
// First try: wait for overlay to disappear
|
// First try: wait for overlay to disappear
|
||||||
@@ -243,7 +243,7 @@ test('Check User 0 can register a random person', async ({ page }) => {
|
|||||||
await switchToUser(page, 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
|
await switchToUser(page, 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
|
||||||
|
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
// Wait for dialog to be hidden or removed - try multiple approaches
|
// Wait for dialog to be hidden or removed - try multiple approaches
|
||||||
try {
|
try {
|
||||||
// First try: wait for overlay to disappear
|
// First try: wait for overlay to disappear
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
||||||
import { importUser, retryWaitForLoadState, retryWaitForSelector, retryClick, getNetworkIdleTimeout, getElementWaitTimeout } from './testUtils';
|
import { importUser, retryWaitForLoadState, retryWaitForSelector, retryClick, getNetworkIdleTimeout, getElementWaitTimeout, closeOnboardingIfShown } from './testUtils';
|
||||||
|
|
||||||
test('Record something given', async ({ page }) => {
|
test('Record something given', async ({ page }) => {
|
||||||
// Generate a random string of a few characters
|
// Generate a random string of a few characters
|
||||||
@@ -100,7 +100,7 @@ test('Record something given', async ({ page }) => {
|
|||||||
|
|
||||||
// Record something given
|
// Record something given
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
|
|
||||||
// Simple dialog handling - just wait for it to be gone
|
// Simple dialog handling - just wait for it to be gone
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
||||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
import { importUser, createUniqueStringsArray, createRandomNumbersArray, closeOnboardingIfShown } from './testUtils';
|
||||||
|
|
||||||
test('Record 9 new gifts', async ({ page }) => {
|
test('Record 9 new gifts', async ({ page }) => {
|
||||||
test.slow(); // Set timeout longer
|
test.slow(); // Set timeout longer
|
||||||
@@ -114,7 +114,7 @@ test('Record 9 new gifts', async ({ page }) => {
|
|||||||
// Record gift
|
// Record gift
|
||||||
await page.goto('./', { waitUntil: 'networkidle' });
|
await page.goto('./', { waitUntil: 'networkidle' });
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
}
|
}
|
||||||
await page.getByRole('button', { name: 'Thank' }).click();
|
await page.getByRole('button', { name: 'Thank' }).click();
|
||||||
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
|
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ async function testProjectGive(page: Page, isToProject: boolean) {
|
|||||||
// find a project and enter a give to it and see that it shows
|
// find a project and enter a give to it and see that it shows
|
||||||
await importUser(page, '00');
|
await importUser(page, '00');
|
||||||
await page.goto('./discover');
|
await page.goto('./discover');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
|
||||||
|
|
||||||
await page.locator('ul#listDiscoverResults li:first-child a').click()
|
await page.locator('ul#listDiscoverResults li:first-child a').click()
|
||||||
// wait for the project page to load
|
// wait for the project page to load
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ async function findProjectIds(
|
|||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ fullId: string; shortId: string }> {
|
): Promise<{ fullId: string; shortId: string }> {
|
||||||
await page.goto('./discover');
|
await page.goto('./discover');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
|
||||||
await page.locator('ul#listDiscoverResults li:first-child a').click();
|
await page.locator('ul#listDiscoverResults li:first-child a').click();
|
||||||
await page.waitForURL(/\/project\//);
|
await page.waitForURL(/\/project\//);
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page } from '@playwright/test';
|
||||||
import { importUser, getOSSpecificTimeout } from './testUtils';
|
import { importUser, getOSSpecificTimeout, closeOnboardingIfShown } from './testUtils';
|
||||||
|
|
||||||
test('Add contact, record gift, confirm gift', async ({ page }) => {
|
test('Add contact, record gift, confirm gift', async ({ page }) => {
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
|||||||
|
|
||||||
// Confirm that home shows contact in "Record Something…"
|
// Confirm that home shows contact in "Record Something…"
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
await page.getByRole('button', { name: 'Thank' }).click();
|
await page.getByRole('button', { name: 'Thank' }).click();
|
||||||
await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName }).first()).toBeVisible();
|
await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName }).first()).toBeVisible();
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
|||||||
|
|
||||||
// Go to home view and look for gift
|
// Go to home view and look for gift
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
const giftLink = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
const giftLink = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
||||||
await expect(giftLink).toBeVisible();
|
await expect(giftLink).toBeVisible();
|
||||||
await giftLink.click();
|
await giftLink.click();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page } from '@playwright/test';
|
||||||
import { importUser, importUserFromAccount } from './testUtils';
|
import { importUser, importUserFromAccount, switchToUser, waitForNewActivityLoaded, closeOnboardingIfShown } from './testUtils';
|
||||||
|
|
||||||
test('Record an offer', async ({ page }) => {
|
test('Record an offer', async ({ page }) => {
|
||||||
test.setTimeout(60000);
|
test.setTimeout(60000);
|
||||||
@@ -14,17 +14,26 @@ test('Record an offer', async ({ page }) => {
|
|||||||
// Switch to user 0
|
// Switch to user 0
|
||||||
// await importUser(page);
|
// await importUser(page);
|
||||||
// Become User Zero
|
// Become User Zero
|
||||||
await importUserFromAccount(page, "00");
|
const userZeroDid = await importUserFromAccount(page, "00");
|
||||||
// Select a project
|
// this Home visit records where User Zero's New Activity starts
|
||||||
await page.goto('./discover');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
|
await waitForNewActivityLoaded(page);
|
||||||
// Record an offer
|
// Select a project User Zero owns, since New Activity leaves out a person's
|
||||||
// The line that used to sit here waited for a button with the text 'Edit'. It
|
// own offers to their projects
|
||||||
// never waited (isVisible() returns at once) and never matched (that button
|
await page.goto('./projects');
|
||||||
// carries title="Edit", no text, and renders only for the project's issuer).
|
await page.locator('ul#listProjects li').first().click();
|
||||||
// The project id is read from the URL, so wait for the navigation to land.
|
// The project id is read from the URL, so wait for the navigation to land.
|
||||||
await page.waitForURL(/\/project\//);
|
await page.waitForURL(/\/project\//);
|
||||||
|
const projectUrl = page.url();
|
||||||
|
|
||||||
|
// Become User One, who records the offer to User Zero's project
|
||||||
|
await importUserFromAccount(page, "01");
|
||||||
|
// onboarding is per identity, and its dialog would cover the Projects page
|
||||||
|
await page.goto('./');
|
||||||
|
await closeOnboardingIfShown(page);
|
||||||
|
await expect(page.locator('.dialog-overlay')).toBeHidden();
|
||||||
|
await page.goto(projectUrl);
|
||||||
await expect(page.getByTestId('offerButton')).toBeVisible();
|
await expect(page.getByTestId('offerButton')).toBeVisible();
|
||||||
await page.getByTestId('offerButton').click();
|
await page.getByTestId('offerButton').click();
|
||||||
await page.getByTestId('inputDescription').fill(description);
|
await page.getByTestId('inputDescription').fill(description);
|
||||||
@@ -74,7 +83,8 @@ test('Record an offer', async ({ page }) => {
|
|||||||
await page.getByTestId('editClaimButton').click();
|
await page.getByTestId('editClaimButton').click();
|
||||||
const newAmount = page.getByTestId('inputOfferAmount');
|
const newAmount = page.getByTestId('inputOfferAmount');
|
||||||
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
|
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
|
||||||
// go to the home page and check that the offer is shown as new
|
// as the project owner, go to the home page and check that the offer is shown as new
|
||||||
|
await switchToUser(page, userZeroDid);
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
const offerNumElem = page.getByTestId('newActivityNumber-personalPlanContributions');
|
const offerNumElem = page.getByTestId('newActivityNumber-personalPlanContributions');
|
||||||
// extract the number and check that it's greater than 0 or "50+"
|
// extract the number and check that it's greater than 0 or "50+"
|
||||||
@@ -100,7 +110,7 @@ test('Affirm delivery of an offer', async ({ page }) => {
|
|||||||
|
|
||||||
await importUserFromAccount(page, "00");
|
await importUserFromAccount(page, "00");
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
// Wait for dialog to be hidden or removed - try multiple approaches
|
// Wait for dialog to be hidden or removed - try multiple approaches
|
||||||
try {
|
try {
|
||||||
// First try: wait for overlay to disappear
|
// First try: wait for overlay to disappear
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { switchToUser, getTestUserData, importUserFromAccount, waitForNewActivityLoaded } from './testUtils';
|
import { switchToUser, getTestUserData, importUserFromAccount, waitForNewActivityLoaded, closeOnboardingIfShown } from './testUtils';
|
||||||
|
|
||||||
test('New offers for another user', async ({ page }) => {
|
test('New offers for another user', async ({ page }) => {
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
@@ -14,7 +14,7 @@ test('New offers for another user', async ({ page }) => {
|
|||||||
throw new Error('Auto-created DID not found in HomeView');
|
throw new Error('Auto-created DID not found in HomeView');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await closeOnboardingIfShown(page);
|
||||||
// Wait for onboarding dialog to fully close and settings to persist
|
// Wait for onboarding dialog to fully close and settings to persist
|
||||||
await expect(page.locator('.dialog-overlay')).toBeHidden({ timeout: 5000 });
|
await expect(page.locator('.dialog-overlay')).toBeHidden({ timeout: 5000 });
|
||||||
await expect(page.getByTestId('newActivityNumber-claims')).toBeHidden();
|
await expect(page.getByTestId('newActivityNumber-claims')).toBeHidden();
|
||||||
|
|||||||
@@ -88,13 +88,26 @@ export async function importUser(page: Page, id?: string): Promise<string> {
|
|||||||
return did;
|
return did;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On the feed, wait until onboarding has had its chance to open, and close it
|
||||||
|
* if it did. The interests prompt opens only for an identity with no profile
|
||||||
|
* text on the server, so a test user who has one never sees it.
|
||||||
|
*/
|
||||||
|
export async function closeOnboardingIfShown(page: Page): Promise<void> {
|
||||||
|
await page.locator('#Content[data-onboarding-checked="true"]').waitFor();
|
||||||
|
const close = page.getByTestId("closeOnboardingAndFinish");
|
||||||
|
if (await close.isVisible()) {
|
||||||
|
await close.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function importUserAndCloseOnboarding(
|
export async function importUserAndCloseOnboarding(
|
||||||
page: Page,
|
page: Page,
|
||||||
id?: string
|
id?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const did = await importUser(page, id);
|
const did = await importUser(page, id);
|
||||||
await page.goto("./");
|
await page.goto("./");
|
||||||
await page.getByTestId("closeOnboardingAndFinish").click();
|
await closeOnboardingIfShown(page);
|
||||||
return did;
|
return did;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user