Merge branch 'master' into api-notify-deep-link

This commit is contained in:
2026-11-03 10:49:35 +00:00
84 changed files with 7047 additions and 837 deletions
+1 -2
View File
@@ -5,8 +5,7 @@ VITE_LOG_LEVEL=info
# iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
VITE_APP_SERVER=https://test.`
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
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 production).
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
+4
View File
@@ -20,3 +20,7 @@ Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
## 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.
## 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
View File
@@ -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:
```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.
#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:
```bash
perl -p -i -e 's/versionCode .*/versionCode 76/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/versionCode .*/versionCode 77/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.11"/g' android/app/build.gradle
```
##### 2. Build
+8
View File
@@ -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).
## [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
### Changed
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
+3 -3
View File
@@ -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.
## 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
* [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)
* 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
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
+9 -4
View File
@@ -39,8 +39,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 76
versionName "1.4.10"
versionCode 77
versionName "1.4.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -64,8 +64,13 @@ android {
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// R8 shrinking/optimization: Play Vitals "code optimization" (DEX) score.
// 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
if (signingConfigs.release.storeFile != null) {
signingConfig signingConfigs.release
+1
View File
@@ -15,6 +15,7 @@ dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
+2 -2
View File
@@ -14,8 +14,8 @@
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-renamesourcefileattribute SourceFile
+2
View File
@@ -99,6 +99,8 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<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" />
<!-- Daily Notification Plugin Permissions -->
@@ -23,6 +23,10 @@
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/geolocation",
"classpath": "com.capacitorjs.plugins.geolocation.GeolocationPlugin"
},
{
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
+3
View File
@@ -20,6 +20,9 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
include ':capacitor-filesystem'
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'
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.
+557
View File
@@ -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.
+10
View File
@@ -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.
@@ -1,5 +1,12 @@
# 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
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
@@ -16,7 +23,7 @@ list's length.
| Bucket | Server | What it holds | Needs |
|---|---|---|---|
| `claims` | endorser `/api/v2/report/alertSearch` | Claims by others with you as `toEntity` or `fromEntity` (offers, gives, every other claim type) | — |
| `personalPlanContributions` | endorser | Gives and offers to or from projects you issued or are agent of (your own claims included) | — |
| `personalPlanContributions` | endorser | Gives and offers by others to or from projects you issued or are agent of | — |
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
| `trackedPlanClaims` | endorser | Gives and offers to or from starred projects | `planHandleIds` |
| `plansNearby` | endorser | Projects created or edited in the search area | `location` |
@@ -69,16 +76,22 @@ Stored as JSON in one settings column, `newActivityCursors`
- **Visit window**, one cursor per server. The summary counts every bucket from
its start, the same window the notification service counts from once the
visit is uploaded to it.
visit is uploaded to it, except a bucket whose list was opened after that
start was recorded: it counts from the end of that list (`summaryStart` in
`src/libs/newActivity.ts`, `countSummaryActivity` in
`src/services/newActivity.ts`).
- **Bucket window**, one per bucket, on that bucket's server. A detail page
lists everything 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.
- **Uploaded**, the cursors last sent to each notification channel.
A window records where a load counted from (`from`), where it ended (`to`) and
when the visit began (`at`). A load less than 30 minutes after `at` is the same
visit and counts from the same `from`, so moving between the summary and a
detail page keeps the counts; a later load starts a new visit from the last
A window records where a load counted from (`from`) and when that cursor was
recorded (`fromSeenAt`), where it ended (`to`) and when (`seenAt`), and when
the visit began (`at`). A summary load less than 30 minutes after `at` is the
same visit and counts from the same `from`, so reloading the summary keeps the
counts of unopened lists; a later load starts a new visit from the last
`to`. "Mark unread from this item" does not exist, since no cursor points at an
item.
@@ -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
summary's visit or that bucket's own list (`unseenStart` in
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
uses a visit's start, so a list just opened shows no tile, even inside the
30-minute visit in which the summary keeps showing its count.
uses a visit's start, so a summary just loaded shows no tiles, even inside
the 30-minute visit in which the summary keeps showing its counts.
- Buckets on one server with the same start share one request, so with no
list opened since the summary, Home makes one request per server.
- Home writes the cursor state only to seed it on the first load ever; it
@@ -275,15 +288,14 @@ App (`crowd-funder-for-time-pwa`):
where New Activity starts in parallel with the feed, Affirm Delivery waits
for the offer's project, a WebAssembly explanation on the start-up error
screen, test warm-up, retry and reporter).
- [ ] Release the app after endorser-ch is deployed, since detail rows read
`claim` from `alertSearch` — next: release (Trent)
- [x] Release the app after endorser-ch is deployed, since detail rows read
`claim` from `alertSearch`.
Notification service (`notification-wakeup-service`):
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
CHANGELOG.
- [ ] Deploy to test and production — next: deploy, or tick this if done
(Trent)
- [x] Deploy to test and production.
endorser-ch:
@@ -292,27 +304,25 @@ endorser-ch:
sends) gets `toEntity`, so it reaches the owner's
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
existing offers without moving `changeSeq`; test, CHANGELOG.
- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
- [x] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
next: commit (Trent)
- [ ] Deploy to test, then production, running Flyway on the main DB so V23
applies — next: deploy (Trent)
- [ ] Leave a person's own gives and offers out of their
`personalPlanContributions` (the query includes them when a person
contributes to their own project) — next: implement after the deploys above (agent)
- endorser-ch: add `AND j.issuer != ?`, bound to the requesting DID, to
both halves of the `UNION` in `jwtsForUserPlanContributions`
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js`.
- [x] Deploy to test, then production, running Flyway on the main DB so V23
applies.
- [x] Leave a person's own gives and offers out of their
`personalPlanContributions`:
- endorser-ch: `AND j.issuer != ?`, bound to the requesting DID, on both
halves of the `UNION` in `jwtsForUserPlanContributions`
(`endorser.db.service.js`), as `jwtsWithDidAfterId` does.
- endorser-ch test in `controller-endorser-6-plans-totals.js`: user 1's own
offer to user 1's plan is absent from user 1's
`personalPlanContributions`, and user 2's offer is present.
- App spec 50 "Record an offer": record the offer as User 1
(`importUserFromAccount(page, "01")`) on a project User 0 owns, then
switch to User 0 to check the `personalPlanContributions` tile and list.
"Affirm delivery" then gets User 1 as giver and the project as recipient.
- endorser-ch CHANGELOG entry; the notification service needs no change,
since its counts come from the same query.
- App spec 50 "Record an offer": User 0 seeds New Activity on Home, User 1
records the offer on a project User 0 owns, then User 0 checks the
`personalPlanContributions` tile and list. "Affirm delivery" gets User 1
as giver and the project as recipient.
- endorser-ch CHANGELOG entry (Unreleased); the notification service needs
no change, since its counts come from the same query.
## Out of scope
@@ -345,3 +355,7 @@ Closed decisions:
- 2026-10-16: detail pages query `alertSearch` themselves and do not reuse
Home's rows; an in-memory hand-off would be a second path that every
entry other than a fresh, uncapped Home tile skips.
- 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.
@@ -1,5 +1,17 @@
# 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
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
to `StartView` through `sessionStorage`.
- Three places catch it and redirect to `/start`: the router `errorHandler`
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
global error handler (`src/main.common.ts`).
and `beforeEach` guard (`src/router/index.ts`), and the Vue global error
handler (`src/main.common.ts`).
- The guard calls `retrieveAccountDids()` for every route except the
identity-setup routes, so with the database down every route except
`/account`, `/test` and `/logs` lands on `/start`.
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
(`resetDatabase()`, which copies the file aside before deleting it).
- `StartView` shows the recovery screen: Try Again, Email Details, Copy
Details, "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
(`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()`
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
(`src/libs/endorserServer.ts`) omits the Authorization header when there
is no DID.
- Every path that creates, saves, or signs with an identity reads the `secret`
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
the database is down.
## Part A: One-tap diagnostics report
## Part A: One-tap diagnostics report (not built)
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.
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.
## Part B: Degraded mode
## Part B: Degraded mode (not built)
- 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
@@ -77,13 +91,14 @@ Android and iOS.
confirm, invite, contacts) with that same reason, instead of the
"someone must register you" text an unregistered user sees.
- Default `apiServer` in every settings fallback. `$accountSettings()`
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
the caller's `defaults` (often `{}`) on its early-return and error paths, so
`apiServer` can be undefined; `HomeView` works around this on its own.
(`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. (Moved to PLAN.md)
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
Copy → Start Over.
## Part C (optional): An identity that does not need SQLite
## Part C (optional): An identity that does not need SQLite (not built)
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
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
These stay open for whoever picks up Parts A–C.
- Which server and endpoint receive diagnostics, and how long reports are kept.
- Whether to quarantine a corrupt database automatically ("file is not a
database", "malformed") and start fresh. Automatic quarantine trades a
@@ -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
keys together with the secret that decrypts them. The backup rules
(`android/app/src/main/res/xml/backup_rules.xml`,
`data_extraction_rules.xml`) include it.
`data_extraction_rules.xml`) include it and exclude only the plugin's
encrypted preference files.
## Rejected
@@ -119,6 +137,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Checklist
Parts A–C are deferred, not scheduled.
- [ ] A1. Decide the diagnostics endpoint and payload retention.
- [ ] A2. Capture "database file existed before first connection" during startup.
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
@@ -127,3 +147,17 @@ encryption setting (`androidIsEncryption: false`) in effect.
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
- [ ] B4. Reorder the recovery screen.
- [ ] 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.
@@ -10,8 +10,7 @@ Error" on every launch:
```
Reinstalling did not help. The failure never appeared on developer devices
installed through adb or Android Studio. Full symptom report:
`startup-db-error-symptoms.md`.
installed through adb or Android Studio.
## Causes
@@ -107,7 +106,8 @@ the app on both platforms.
## Follow-up
The one-tap diagnostics report and a degraded mode for users without a working
database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
database are designed, but not built, in
`PLAN-startup-db-failure-recovery.md` in this directory.
## History
+8
View File
@@ -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.
## 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
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.
+121
View File
@@ -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.
+7
View File
@@ -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
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
# 2 - Software Developers
+2 -2
View File
@@ -8,10 +8,10 @@
<!-- CORS headers removed to allow images from any domain -->
<title>Time Safari</title>
<title>Giftopia</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>
+29 -42
View File
@@ -70,7 +70,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -80,18 +80,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup 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";
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 */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
enableUserScriptSandboxing = 0;
files = (
);
inputFileListPaths = (
@@ -365,7 +372,6 @@
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
enableUserScriptSandboxing = 0;
files = (
);
inputFileListPaths = (
@@ -382,25 +388,6 @@
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
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 */
/* Begin PBXSourcesBuildPhase section */
@@ -581,7 +568,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -593,7 +580,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.10;
MARKETING_VERSION = 1.4.11;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -612,7 +599,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -624,7 +611,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.10;
MARKETING_VERSION = 1.4.11;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -642,7 +629,7 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FIREBASE_ENABLED = YES;
@@ -658,7 +645,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.10;
MARKETING_VERSION = 1.4.11;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -682,7 +669,7 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FIREBASE_ENABLED = YES;
@@ -698,7 +685,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.10;
MARKETING_VERSION = 1.4.11;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
+5 -1
View File
@@ -41,7 +41,11 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<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>
<string>Time Safari allows you to upload photos.</string>
<key>NSUserNotificationAlertStyle</key>
+1
View File
@@ -17,6 +17,7 @@ def capacitor_pods
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
+11 -1
View File
@@ -15,6 +15,9 @@ PODS:
- CapacitorFilesystem (7.1.8):
- Capacitor
- IONFilesystemLib (~> 1.1.1)
- CapacitorGeolocation (7.1.8):
- Capacitor
- IONGeolocationLib (= 1.0.1)
- CapacitorMlkitBarcodeScanning (7.5.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 7.0.0)
@@ -52,6 +55,7 @@ PODS:
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- IONFilesystemLib (1.1.2)
- IONGeolocationLib (1.0.1)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
@@ -92,6 +96,7 @@ DEPENDENCIES:
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorGeolocation (from `../../node_modules/@capacitor/geolocation`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
@@ -108,6 +113,7 @@ SPEC REPOS:
- GoogleUtilities
- GTMSessionFetcher
- IONFilesystemLib
- IONGeolocationLib
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@@ -132,6 +138,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorFilesystem:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorGeolocation:
:path: "../../node_modules/@capacitor/geolocation"
CapacitorMlkitBarcodeScanning:
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
CapacitorPreferences:
@@ -155,6 +163,7 @@ SPEC CHECKSUMS:
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319
@@ -167,6 +176,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
@@ -177,6 +187,6 @@ SPEC CHECKSUMS:
TimesafariDailyNotificationPlugin: b60e726f8776b5d22e026caef906f1c3b0e963dd
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
PODFILE CHECKSUM: 49f988dae1b57531e4bf9078f55c522b44b56ee7
COCOAPODS: 1.16.2
+2030 -516
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "giftopia",
"version": "1.4.11-beta",
"version": "1.4.12-beta",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
@@ -160,6 +160,7 @@
"@capacitor/clipboard": "^7.0.4",
"@capacitor/core": "^7.6.4",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/geolocation": "^7.1.8",
"@capacitor/ios": "^7.6.4",
"@capacitor/preferences": "^7.0.4",
"@capacitor/push-notifications": "^7.0.7",
+1 -1
View File
@@ -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.
* 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.
*/
webServer: {
+269
View File
@@ -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&hellip;
</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>
+117
View File
@@ -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>
+178
View File
@@ -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>
+25 -9
View File
@@ -83,7 +83,9 @@
<p class="mt-4 flex items-center">
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>
</div>
@@ -200,6 +202,11 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import {
onboardingSettingsUpdate,
parseOnboardingState,
withWelcomeTour,
} from "../libs/onboarding";
import { OnboardPage } from "../libs/util";
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() {
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;
if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages
await this.$updateSettings({
finishedOnboarding: true,
});
await this.finishTour();
}
}
/**
* 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
*
@@ -309,9 +327,7 @@ export default class OnboardingDialog extends Vue {
async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false;
if (done) {
await this.$updateSettings({
finishedOnboarding: true,
});
await this.finishTour();
if (goHome) {
this.$router.push({ name: "home" });
}
@@ -185,9 +185,14 @@ export default class PushNotificationPermission extends Vue {
skipSchedule?: boolean;
rolloverIntervalMinutes?: number;
timeOnly?: boolean;
/** "HH:mm" (24-hour) to start the clock at, which the person can change */
defaultTime?: string;
},
) {
this.callback = callback || this.callback;
if (options?.defaultTime) {
this.timeValue = options.defaultTime;
}
this.isVisible = true;
this.pushType = pushType;
this.skipScheduleForOpen = options?.skipSchedule ?? false;
+40 -2
View File
@@ -92,7 +92,17 @@
class="block text-center py-2 px-1"
>
<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,
but it isn't accurate because we don't hold anything for them.
@@ -111,8 +121,36 @@
<script lang="ts">
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 {
@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>
<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>
+16
View File
@@ -306,6 +306,22 @@ const MIGRATIONS = [
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;
`,
},
];
/**
+4
View File
@@ -47,6 +47,10 @@ export type Settings = {
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
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
lastNotifiedClaimId?: string;
+1
View File
@@ -30,6 +30,7 @@ export interface AccountSettings {
name: string;
bbox: BoundingBox;
}>;
newActivityDelivery?: string;
notifyingNewActivityTime?: string;
notifyingNewActivitySmsPhone?: string;
notifyingNewActivitySmsTime?: string;
+8
View File
@@ -69,6 +69,7 @@ export const deepLinkPathSchemas = {
"help-notifications": z.object({}),
"help-onboarding": z.object({}),
"help-terms": z.object({}),
"interest-matches": z.object({}),
"invite-one": 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
@@ -103,6 +104,13 @@ export const deepLinkQuerySchemas = {
searchPeople: 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 "."
"new-activity": z.object({
c: z.string().optional(),
+7
View File
@@ -192,6 +192,13 @@ export interface AlertAuthorizationRequestBody {
timezone?: string;
/** Exactly 100 entries. */
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;
}
/**
+33
View File
@@ -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;
}
}
+4 -20
View File
@@ -22,6 +22,8 @@ import { sha256 } from "ethereum-cryptography/sha256";
import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { addLastClaimOrHandleAsIdIfMissing } from "./confirmationTarget";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { NOTIFICATION_TIMEOUTS } from "../utils/notificationUtils";
import { createNotifyHelpers } from "../utils/notify";
@@ -112,6 +114,8 @@ export {
addEndorserPrefix,
stripEndorserPrefix,
} from "./endorserHandleIds";
// Same reason as above.
export { addLastClaimOrHandleAsIdIfMissing } from "./confirmationTarget";
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
@@ -233,26 +237,6 @@ export function removeSchemaContext(obj: any) {
: 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
// similar code is also contained in endorser-mobile
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+79 -7
View File
@@ -7,12 +7,13 @@
* - The visit window, one cursor per server. The summary counts every bucket
* from its start, the same point the notification service counts from once
* the visit is uploaded to it.
* - A window per bucket, on that bucket's server. A detail page counts from its
* start, so its count is exact for that bucket.
* - A window per bucket, on that bucket's server. A detail page counts from the
* end of its last load, so opening it marks the bucket read.
*
* A window 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
* summary and a detail page does not zero the counts.
* A visit started less than {@link SAME_VISIT_MS} ago is the same visit: the
* next summary load counts from the same start, so reloading the summary does
* not zero the counts. A bucket opened since that start counts from the end of
* its own list instead ({@link summaryStart}).
*/
import type { AlertSearchCursor } from "@/interfaces/alertSearch";
@@ -164,13 +165,14 @@ export interface CursorPair {
/**
* The span one load counted: from `from` (absent before any cursor was known)
* up to `to`, the cursor the server returned. `at` is when the visit began;
* `seenAt` is when `to` was recorded.
* `seenAt` is when `to` was recorded, and `fromSeenAt` when `from` was.
*/
export interface ActivityWindow<T> {
from?: T;
to: T;
at: string;
seenAt?: string;
fromSeenAt?: string;
}
export type NotificationChannel = "fcm" | "sms";
@@ -232,13 +234,16 @@ export function nextWindow<T>(
if (window && isSameVisit(window, now)) {
return {
...(window.from !== undefined ? { from: window.from } : {}),
...(window.fromSeenAt ? { fromSeenAt: window.fromSeenAt } : {}),
to,
at: window.at,
seenAt: now.toISOString(),
};
}
return {
...(window?.to !== undefined ? { from: window.to } : {}),
...(window?.to !== undefined
? { from: window.to, fromSeenAt: window.seenAt ?? window.at }
: {}),
to,
at: now.toISOString(),
seenAt: now.toISOString(),
@@ -250,6 +255,73 @@ function seenTime<T>(window: ActivityWindow<T> | undefined): number {
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
* person saw last, the summary's or this bucket's own list. Unlike
+298
View File
@@ -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;
}
+140
View File
@@ -1,3 +1,6 @@
import type { PlanData } from "@/interfaces/records";
import type { LocBox } from "./onboarding";
export interface UserProfile {
description: string;
generateEmbedding?: boolean;
@@ -9,3 +12,140 @@ export interface UserProfile {
issuerDid: string;
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
View File
@@ -50,6 +50,10 @@ import {
onNotificationAuthMayBeReady,
runLegacyApiNotificationsCleanupOnce,
} 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] Platform:", process.env.VITE_PLATFORM);
@@ -364,8 +368,27 @@ setTimeout(async () => {
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
// One-time: remove persisted legacy api_* schedules from pre-retirement installs
await runLegacyApiNotificationsCleanupOnce();
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
await initializeNativePushAndFirebaseMessaging();
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token,
// 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)
await configureNativeFetcherIfReady();
} catch (error) {
+1 -1
View File
@@ -17,7 +17,7 @@ function showInsecureContextError(): void {
}
container.innerHTML = `
<section style="max-width: 40rem; margin: 3rem auto; padding: 0 1rem; font-family: sans-serif; line-height: 1.5;">
<h1 style="font-size: 1.25rem; font-weight: 600;">Time Safari Cannot Start Here</h1>
<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>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>
+5
View File
@@ -170,6 +170,11 @@ const routes: Array<RouteRecordRaw> = [
name: "import-derive",
component: () => import("../views/ImportDerivedAccountView.vue"),
},
{
path: "/interest-matches",
name: "interest-matches",
component: () => import("../views/InterestMatchesView.vue"),
},
{
path: "/invite-one",
name: "invite-one",
+87 -19
View File
@@ -26,7 +26,9 @@ import {
type NewActivityCursorState,
seededState,
startParams,
summaryStart,
unseenStart,
windowStart,
} from "@/libs/newActivity";
/** 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>>;
/** A count is a lower bound: the read stopped with more to go. */
hitLimit: boolean;
/** Per bucket, when this server's buckets were read from different starts. */
bucketHitLimit?: Partial<Record<NewActivityBucket, boolean>>;
/** Where this read ended: the start of the next visit. */
to?: AlertSearchCursor;
userMessage?: string;
@@ -179,47 +183,111 @@ export interface UnseenCounts {
errors: string[];
}
interface StartGroup {
server: AlertSearchServer;
from?: AlertSearchCursor;
buckets: NewActivityBucket[];
result: ServerCounts;
}
/**
* Counts for the Home screen: each bucket from {@link unseenStart}. Buckets on
* the same server with the same start share one read, so a person who has
* opened no list since the summary costs one read per server.
* Counts for each available bucket from its own start. Buckets on the same
* server with the same start share one read.
*/
export async function countUnseenActivity(
async function countFromStarts(
ctx: NewActivityContext,
state: NewActivityCursorState,
): Promise<UnseenCounts> {
const groups = new Map<
string,
{
server: AlertSearchServer;
from?: AlertSearchCursor;
buckets: NewActivityBucket[];
}
>();
startOf: (bucket: NewActivityBucket) => AlertSearchCursor | undefined,
): Promise<StartGroup[]> {
const groups = new Map<string, Omit<StartGroup, "result">>();
for (const bucket of NEW_ACTIVITY_BUCKETS) {
if (!bucketIsAvailable(ctx, bucket)) continue;
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
const from = unseenStart(state, bucket);
const from = startOf(bucket);
const key = server + " " + (from ?? "");
const group = groups.get(key) ?? { server, from, buckets: [] };
group.buckets.push(bucket);
groups.set(key, group);
}
const counted = await Promise.all(
return Promise.all(
[...groups.values()].map(async (group) => ({
group,
...group,
result: await countServer(ctx, group.server, group.from),
})),
);
}
/**
* Counts for the summary: each bucket from {@link summaryStart}. Each server's
* `to`, the end of the visit, comes from the read that started at the visit's
* start, so a capped read leaves the rest for the next visit; when every
* bucket on the server has its own start, from a read that was not capped.
* A server with any failed read reports the error and no `to`.
*/
export async function countSummaryActivity(
ctx: NewActivityContext,
state: NewActivityCursorState,
now: Date,
fallback?: CursorPair,
): Promise<NewActivityCounts> {
const groups = await countFromStarts(ctx, (bucket) =>
summaryStart(state, bucket, now, fallback),
);
const visitStart = windowStart(state.visit, now) ?? fallback;
const serverCounts = (server: AlertSearchServer): ServerCounts => {
const own = groups.filter((group) => group.server === server);
const counts: ServerCounts = {
counts: {},
hitLimit: false,
bucketHitLimit: {},
};
for (const group of own) {
const result = group.result;
if (result.error) {
counts.error ??= result.error;
continue;
}
counts.userMessage ??= result.userMessage;
counts.hitLimit ||= result.hitLimit;
for (const bucket of group.buckets) {
counts.counts[bucket] = result.counts[bucket];
counts.bucketHitLimit![bucket] = result.hitLimit;
}
}
if (!counts.error) {
const ending =
own.find((group) => group.from === visitStart?.[server]) ??
own.find((group) => !group.result.hitLimit) ??
own[0];
counts.to = ending?.result.to;
}
return counts;
};
return {
endorser: serverCounts("endorser"),
partner: serverCounts("partner"),
};
}
/**
* Counts for the Home screen: each bucket from {@link unseenStart}. A person
* who has opened no list since the summary costs one read per server.
*/
export async function countUnseenActivity(
ctx: NewActivityContext,
state: NewActivityCursorState,
): Promise<UnseenCounts> {
const counted = await countFromStarts(ctx, (bucket) =>
unseenStart(state, bucket),
);
const unseen: UnseenCounts = { buckets: {}, errors: [] };
for (const { group, result } of counted) {
for (const { buckets, result } of counted) {
if (result.error) {
if (!unseen.errors.includes(result.error)) {
unseen.errors.push(result.error);
}
continue;
}
for (const bucket of group.buckets) {
for (const bucket of buckets) {
unseen.buckets[bucket] = {
count: result.counts[bucket] ?? 0,
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
*/
@@ -16,6 +16,8 @@ jest.mock("@/libs/crypto/vc", () => ({
jest.mock("./alertAuthorizationBatch", () => ({
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
mintAlertAuthorizationBatch: jest.fn(),
localTimeTextToUtc: (text: string) =>
text === "8:30 PM" ? { notifyHourUtc: 2, notifyMinuteUtc: 30 } : null,
}));
jest.mock("./NotificationDebugConfig", () => ({
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 () => {
const logSpy = jest.spyOn(console, "log").mockImplementation();
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
@@ -2,10 +2,12 @@
* Push-channel AlertSearch authorization against notification-wakeup-service.
*
* Upload mints a delegated JWT batch for the active identity and
* `POST /notifications/alert-authorization`, with the send time fixed at
* 09:00 in the device's zone. Revoke sends `DELETE` on that same route and
* does not unregister the FCM device. The debug panel uploads manually; the
* in-app New Activity toggle uploads on enable and revokes on disable.
* `POST /notifications/alert-authorization`, with the send time the person
* picked for in-app New Activity notifications, converted to UTC. Without a
* time (the debug panel) it sends 09:00 in the device's zone. Revoke sends
* `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
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
@@ -20,6 +22,7 @@ import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import {
ALERT_AUTHORIZATION_BATCH_DAYS,
type AlertAuthorizationBatch,
localTimeTextToUtc,
mintAlertAuthorizationBatch,
} from "./alertAuthorizationBatch";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
@@ -70,7 +73,7 @@ export interface AlertAuthorizationDependencies {
getActiveDid: () => Promise<string | null>;
isAuthBypassEnabled: () => boolean;
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;
getAuthHeaders: typeof getNotificationApiHeaders;
getBaseUrl: () => string;
@@ -87,10 +90,25 @@ const defaultDependencies: AlertAuthorizationDependencies = {
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(
batch: AlertAuthorizationBatch,
timeZone: string,
searchState: AlertSearchUploadState = {},
options: AlertAuthorizationUploadOptions = {},
): AlertAuthorizationRequestBody & AlertSearchUploadState {
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
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 {
batchId: batch.batchId,
@@ -107,6 +131,7 @@ function buildRequestBody(
timezone: timeZone,
jwts: batch.jwts,
...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
* area the daily search uses; see {@link AlertSearchUploadState}
* @param options the send time and whether this is a reset
*/
export async function uploadAlertSearchAuthorization(
dependencies: AlertAuthorizationDependencies = defaultDependencies,
searchState?: AlertSearchUploadState,
options?: AlertAuthorizationUploadOptions,
): Promise<AlertAuthorizationUploadResult> {
try {
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
@@ -224,7 +251,7 @@ export async function uploadAlertSearchAuthorization(
const batch = await dependencies.mintBatch(access.did);
const timeZone = dependencies.getTimeZone();
const body = buildRequestBody(batch, timeZone, searchState);
const body = buildRequestBody(batch, timeZone, searchState, options);
const response = await dependencies.fetch(
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
@@ -75,7 +75,7 @@ async function registerRetrievedToken(
* config, and register() there throws natively, crashing the app before any
* JS catch can run.
*/
async function canRegisterNativePush(): Promise<boolean> {
export async function canRegisterNativePush(): Promise<boolean> {
if (Capacitor.getPlatform() !== "android") {
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). */
export function getLastKnownFcmToken(): string | null {
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 {
type AlertAuthorizationDependencies,
type AlertAuthorizationRevokeResult,
type AlertAuthorizationUploadOptions,
type AlertAuthorizationUploadResult,
revokeAlertSearchAuthorization,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
/**
* Upload the AlertSearch batch after the in-app setting has been enabled.
* A failure is reported and returned; it does not throw.
* Upload the AlertSearch batch when the in-app setting is enabled or given a
* new time. A failure is reported and returned; it does not throw.
*/
export async function authorizeInAppNewActivity(
reportError: (message: string) => void,
dependencies?: AlertAuthorizationDependencies,
searchState?: AlertSearchUploadState,
options?: AlertAuthorizationUploadOptions,
): Promise<AlertAuthorizationUploadResult> {
const result = await uploadAlertSearchAuthorization(
dependencies,
searchState,
options,
);
if (!result.ok) {
reportError(result.errorMessage);
@@ -14,9 +14,11 @@ import { logger } from "@/utils/logger";
import { localTimeTextToUtc } from "./alertAuthorizationBatch";
import { uploadAlertSearchAuthorization } from "./alertAuthorization";
import { authorizeSmsAlertSearch } from "./smsNotificationApi";
import { storedNewActivityDelivery } from "./newActivityDelivery";
/** The settings that say which channels are on. */
export interface NotificationChannelSettings {
newActivityDelivery?: string;
notifyingNewActivityTime?: string;
notifyingNewActivitySmsTime?: string;
smsNotificationsEnabled?: boolean;
@@ -26,7 +28,13 @@ export function enabledNotificationChannels(
settings: NotificationChannelSettings,
): 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 (
settings.smsNotificationsEnabled &&
settings.notifyingNewActivitySmsTime
@@ -37,7 +45,10 @@ export function enabledNotificationChannels(
}
export interface NotificationRefreshDependencies {
uploadFcm: (state: AlertSearchUploadState) => Promise<boolean>;
uploadFcm: (
state: AlertSearchUploadState,
timeText: string,
) => Promise<boolean>;
uploadSms: (
did: string,
notifyHourUtc: number,
@@ -47,8 +58,10 @@ export interface NotificationRefreshDependencies {
}
const defaultDependencies: NotificationRefreshDependencies = {
uploadFcm: async (state) => {
const result = await uploadAlertSearchAuthorization(undefined, state);
uploadFcm: async (state, timeText) => {
const result = await uploadAlertSearchAuthorization(undefined, state, {
timeText,
});
if (!result.ok) {
logger.warn(
"[NewActivityRefresh] Push re-authorization failed:",
@@ -82,7 +95,15 @@ export async function refreshNotificationAuthorizations(
try {
let ok = false;
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 {
const utc = localTimeTextToUtc(
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 searchState cursors to count from, and the starred handle IDs and
* 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(
did: string,
notifyHourUtc: number,
notifyMinuteUtc: number,
searchState?: AlertSearchUploadState,
rerunToday = false,
): Promise<AlertAuthorizationResponse> {
const body = {
...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)),
...searchState,
...(rerunToday ? { rerunToday: true } : {}),
};
return smsRequest<AlertAuthorizationResponse>(did, {
method: "POST",
@@ -493,7 +493,7 @@ export class CapacitorPlatformService
toDirectory: Directory.Cache,
});
await Share.share({
title: "Time Safari database",
title: "TimeSafari database",
url: uri,
dialogTitle: "Save or send your database file",
});
+47
View 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,
);
});
});
+181 -4
View File
@@ -21,19 +21,23 @@ import {
} from "@/libs/alertSearch";
import {
joinCursorPair,
markedReadState,
nextWindow,
parseCursorParam,
parseNewActivityCursorState,
SAME_VISIT_MS,
seededState,
startParams,
summaryStart,
ulidJustBefore,
unseenStart,
windowStart,
windowStartSeenAt,
} from "@/libs/newActivity";
import {
alertSearchUploadState,
countNewActivity,
countSummaryActivity,
countUnseenActivity,
latestCursorPair,
loadBucket,
@@ -69,6 +73,7 @@ describe("visit windows", () => {
expect(windowStart(window, NOW)).toEqual("20");
expect(nextWindow(window, "25", NOW)).toEqual({
from: "20",
fromSeenAt: window.at,
to: "25",
at: NOW.toISOString(),
seenAt: NOW.toISOString(),
@@ -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", () => {
it("round-trips the endorser and partner cursors through 'c'", () => {
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", () => {
it("keeps the cursor of the server that answered", async () => {
const { axios } = fakeAxios((url) => {
@@ -483,6 +628,15 @@ describe("notification refresh", () => {
).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 () => {
const state = alertSearchUploadState(
{ planHandleIds: ["h1"], location: undefined },
@@ -498,21 +652,44 @@ describe("notification refresh", () => {
const uploadSms = jest.fn().mockResolvedValue(true);
const uploaded = await refreshNotificationAuthorizations(
"did:ethr:0xme",
{ notifyingNewActivitySmsTime: "9:00 AM" },
{
notifyingNewActivityTime: "9:00 AM",
notifyingNewActivitySmsTime: "9:00 AM",
},
["fcm", "sms"],
cursors,
state,
{ 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(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(
"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"],
cursors,
{},
+54
View File
@@ -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");
});
});
+383
View File
@@ -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");
});
});
+45
View File
@@ -1,10 +1,12 @@
import {
authorizeSmsAlertSearch,
listSmsPhones,
registerSmsPhone,
SmsApiError,
smsErrorMessage,
} from "@/services/notifications/smsNotificationApi";
import { createEndorserJwtForDid } from "@/libs/endorserServer";
import { buildAlertAuthorizationBody } from "@/services/notifications/alertAuthorizationBatch";
jest.mock("@/libs/endorserServer", () => ({
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");
});
});
+56
View File
@@ -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
View File
@@ -45,6 +45,25 @@
</router-link>
</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 -->
<IdentitySection
:given-name="givenName"
@@ -144,6 +163,11 @@
</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.
Both let the user pick a time; they differ only in how the alert is
@@ -168,11 +192,8 @@
</p>
</div>
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
<div
v-if="showGeneralAdvanced"
class="flex items-center justify-between mt-3 mb-2 pl-1"
>
<!-- Channel 1: In-App notification -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
<font-awesome
@@ -205,15 +226,59 @@
></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">
Troubleshoot your in-app notifications&hellip;
</router-link>
</div>
<div
v-if="notifyingNewActivity && showGeneralAdvanced"
class="w-full pl-1"
>
<div v-if="notifyingNewActivity" class="w-full pl-1">
<div
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>
</button>
</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
v-model="userProfileDesc"
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 * as L from "leaflet";
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 { copyToClipboard } from "../services/ClipboardService";
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 EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import NextStepCard from "../components/NextStepCard.vue";
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
import QuickNav from "../components/QuickNav.vue";
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
@@ -1077,6 +1155,7 @@ import {
configureNativeFetcherIfReady,
deleteSmsPhone,
getNotificationApiBaseUrl,
initializeNativePushAndFirebaseMessaging,
listSmsPhones,
localTimeTextToUtc,
revokeInAppNewActivity,
@@ -1086,6 +1165,12 @@ import {
UnsupportedIdentityError,
} from "@/services/notifications";
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 CursorPair,
@@ -1099,6 +1184,22 @@ import {
latestCursorPair,
} from "@/services/newActivity";
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)
interface ProfileData {
description: string;
@@ -1119,6 +1220,7 @@ interface PushNotificationPermissionRef {
skipSchedule?: boolean;
rolloverIntervalMinutes?: number;
timeOnly?: boolean;
defaultTime?: string;
},
) => void;
hourInput?: string;
@@ -1135,6 +1237,7 @@ interface PushNotificationPermissionRef {
LMap,
LMarker,
LTileLayer,
NextStepCard,
PushNotificationPermission,
QuickNav,
SmsVerificationDialog,
@@ -1198,6 +1301,12 @@ export default class AccountViewView extends Vue {
// Notification properties
notifyingNewActivity: boolean = false;
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.
// 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
@@ -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
loadingLimits: boolean = false;
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;
showB64Copy: boolean = false;
showContactGives: boolean = false;
@@ -1287,6 +1406,7 @@ export default class AccountViewView extends Vue {
try {
await this.initializeState();
await this.processIdentity();
await this.loadOnboardingState();
// Profile service logic now inlined - no need for external service
logger.debug(
@@ -1312,6 +1432,11 @@ export default class AccountViewView extends Vue {
} else {
// Profile not created yet; leave defaults
}
if (profile?.description) {
this.nextStepProfileSaved = true;
} else {
this.fillProfileFromKeptInterests();
}
} catch (error) {
logger.error("Error loading profile:", error);
this.notify.error(
@@ -1396,6 +1521,11 @@ export default class AccountViewView extends Vue {
!!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.serverPushAvailable = await serverPushSupported();
this.newActivityDelivery = effectiveNewActivityDelivery(
settings,
this.serverPushAvailable,
);
this.notifyingNewActivitySmsPhone =
settings.notifyingNewActivitySmsPhone || "";
this.notifyingNewActivitySmsTime =
@@ -1517,6 +1647,11 @@ export default class AccountViewView extends Vue {
await this.$saveSettings({
showGeneralAdvanced: this.showGeneralAdvanced,
});
// the server delivery is offered only with these on
this.newActivityDelivery = effectiveNewActivityDelivery(
await this.$accountSettings(),
this.serverPushAvailable,
);
}
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.newActivityDelivery === "server" &&
!this.warnIfUnregistered("Server notifications")
) {
return;
}
(
this.$refs.pushNotificationPermission as PushNotificationPermission
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
if (success) {
await this.$saveSettings({
notifyingNewActivityTime: timeText,
});
this.notifyingNewActivity = true;
this.notifyingNewActivityTime = timeText;
if (Capacitor.isNativePlatform()) {
await this.scheduleNewActivityDualNotification(timeText);
).open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (success) {
await this.$saveSettings({
notifyingNewActivityTime: timeText,
// saved, so startup and refresh don't treat it as "server"
newActivityDelivery: this.newActivityDelivery,
});
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(
(message) => {
this.reportAlertSearchAuthorizationFailure("upload", message);
},
undefined,
searchState,
);
}
});
},
defaultTime ? { defaultTime } : undefined,
);
} else {
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
if (!success) {
return;
}
// Revoke before clearing the local setting. A failed delete leaves
// the toggle on so the dialog does not claim the subscription is gone.
const revoked = await revokeInAppNewActivity((message) => {
this.reportAlertSearchAuthorizationFailure("revoke", message);
});
if (!revoked) {
if (!(await this.turnOffInAppNewActivity())) {
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
* failure; callers must not follow it with a success announcement.
@@ -1689,6 +1967,10 @@ export default class AccountViewView extends Vue {
return;
}
if (!this.warnIfUnregistered("Text messages")) {
return;
}
if (this.notifyingNewActivitySmsPhone) {
// Number already verified: just switch the channel back on. Nothing is
// authorized yet -- the per-notification toggle is what asks for texts.
@@ -1708,23 +1990,33 @@ export default class AccountViewView extends Vue {
});
},
onOption2: () => {
(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;
}
},
);
this.openSmsVerification();
},
});
}
/**
* 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,
* the registration is deleted from the notify-api, and every SMS
@@ -1798,6 +2090,9 @@ export default class AccountViewView extends Vue {
this.notifyingNewActivitySmsTime = "";
return;
}
if (!this.warnIfUnregistered("Text messages")) {
return;
}
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
* device, since the texts come from the server.
*/
async promptSmsTimeAndEnable(): Promise<void> {
async promptSmsTimeAndEnable(defaultTime?: string): Promise<void> {
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
@@ -1852,7 +2147,7 @@ export default class AccountViewView extends Vue {
this.notifyingNewActivitySms = true;
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.
*/
async editNewActivitySmsNotification(): Promise<void> {
if (!this.warnIfUnregistered("Text messages")) {
return;
}
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
@@ -1964,6 +2262,9 @@ export default class AccountViewView extends Vue {
* @param startFromNow true when turning texts on: the first count starts
* now. False for a new time, which keeps the service's cursors so nothing
* waiting for the next text is dropped.
*
* 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
*/
private async authorizeSmsAlerts(
@@ -1999,6 +2300,7 @@ export default class AccountViewView extends Vue {
),
undefined,
),
true,
);
return true;
} catch (error) {
@@ -2315,6 +2617,12 @@ export default class AccountViewView extends Vue {
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
*/
async editNewActivityNotification(): Promise<void> {
if (
this.newActivityDelivery === "server" &&
!this.warnIfUnregistered("Server notifications")
) {
return;
}
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
@@ -2322,7 +2630,30 @@ export default class AccountViewView extends Vue {
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
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 config = buildDualScheduleConfig({ notifyTime: time24h });
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;
try {
const profileData: ProfileData = {
@@ -2830,6 +3162,14 @@ export default class AccountViewView extends Vue {
if (success) {
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
try {
const settings = await this.$accountSettings();
@@ -2840,14 +3180,169 @@ export default class AccountViewView extends Vue {
} else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
}
return success;
} catch (error) {
logger.error("Error saving profile:", error);
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
return false;
} finally {
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 {
try {
const updated = this.toggleProfileLocation({
+15 -1
View File
@@ -4,6 +4,7 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage />
<NextStepBanner />
<!-- Main View Heading -->
<div class="flex gap-4 items-center mb-4">
@@ -167,6 +168,16 @@
</ul>
</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&hellip;
</router-link>
</div>
<!-- Quick Search -->
<div
id="QuickSearch"
@@ -372,6 +383,7 @@ import QuickNav from "../components/QuickNav.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import NextStepBanner from "../components/NextStepBanner.vue";
import TopMessage from "../components/TopMessage.vue";
import {
NotificationIface,
@@ -382,6 +394,7 @@ import { BoundingBox } from "../db/tables/settings";
import { PlanData } from "../interfaces";
import { didInfo, errorStringForLog, getHeaders } from "../libs/endorserServer";
import { OnboardPage, retrieveAccountDids } from "../libs/util";
import { shouldOpenWelcome } from "../libs/onboarding";
import { logger } from "../utils/logger";
import { UserProfile } from "@/libs/partnerServer";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -408,6 +421,7 @@ interface Tile {
InfiniteScroll,
LMap,
LTileLayer,
NextStepBanner,
OnboardingDialog,
ProjectIcon,
QuickNav,
@@ -485,7 +499,7 @@ export default class DiscoverView extends Vue {
this.allMyDids = await retrieveAccountDids();
if (!settings.finishedOnboarding && !hideOnboarding) {
if (shouldOpenWelcome(settings) && !hideOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);
+10
View File
@@ -45,6 +45,16 @@
</p>
</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>
<div>
<p>
+37 -9
View File
@@ -31,7 +31,16 @@
</p>
<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
class="text-blue-500 cursor-pointer"
@click="unsetFinishedOnboarding()"
@@ -548,6 +557,7 @@
</div>
</div>
<!-- eslint enable -->
<InterestsPromptDialog ref="interestsPromptDialog" />
</section>
</template>
@@ -558,8 +568,14 @@ import { copyToClipboard } from "../services/ClipboardService";
// Capacitor import removed - using QRNavigationService instead
import * as Package from "../../package.json";
import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface, SUPPORT_EMAIL } from "../constants/app";
import {
onboardingSettingsUpdate,
parseOnboardingState,
withWelcomeTour,
} from "../libs/onboarding";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { QRNavigationService } from "@/services/QRNavigationService";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
@@ -592,7 +608,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
* @vue-facing-decorator
*/
@Component({
components: { QuickNav },
components: { InterestsPromptDialog, QuickNav },
mixins: [PlatformServiceMixin],
})
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
*
* This method allows users to restart the onboarding process by setting
* the finishedOnboarding flag to false for their active DID. This is useful
* when users want to see the page-by-page help guide again.
* This starts the page-by-page help as a tour (`welcomeTour` in
* `onboardingState`): Home, Discover, and Projects each open their page
* once, until the last page or a close ends it.
*
* @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();
if (activeIdentity.activeDid) {
await this.$updateSettings({
...settings,
finishedOnboarding: false,
});
// the pages are a tour; the interests prompt is the onboarding
await this.$saveSettings(
onboardingSettingsUpdate(
withWelcomeTour(
parseOnboardingState(settings.onboardingState),
true,
),
),
);
this.$log(
"[HelpView] Onboarding reset successfully for DID: " +
+63 -2
View File
@@ -13,8 +13,10 @@ Raymer * @version 1.0.0 */
class="p-6 pb-24 max-w-3xl mx-auto"
:data-active-did="activeDid"
:data-new-activity-loaded="newActivityLoaded"
:data-onboarding-checked="onboardingChecked"
>
<TopMessage />
<NextStepBanner ref="nextStepBanner" deferred />
<!-- Main View Heading -->
<div class="flex gap-4 items-center mb-4">
@@ -35,6 +37,7 @@ Raymer * @version 1.0.0 */
</div>
<OnboardingDialog ref="onboardingDialog" />
<InterestsPromptDialog ref="interestsPromptDialog" />
<!--
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 FeedFilters from "../components/FeedFilters.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 QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
@@ -270,6 +275,7 @@ import ActivityListItem from "../components/ActivityListItem.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import {
AppString,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
@@ -290,6 +296,17 @@ import {
parseNewActivityCursorState,
serializeNewActivityCursorState,
} from "../libs/newActivity";
import { hasServerProfileText } from "../libs/partnerServer";
import {
onboardingSettingsUpdate,
parseOnboardingState,
shouldOpenWelcome,
withDone,
} from "../libs/onboarding";
import {
refreshNudge,
wasInterestsPromptShown,
} from "../utils/onboardingNudge";
import {
countUnseenActivity,
seedIfNeeded,
@@ -387,6 +404,8 @@ interface FeedError {
GiftedDialog,
GiftedPrompts,
InfiniteScroll,
InterestsPromptDialog,
NextStepBanner,
OnboardingDialog,
QuickNav,
TopMessage,
@@ -424,6 +443,8 @@ export default class HomeView extends Vue {
newActivityCounts: UnseenCounts | null = null;
// the New Activity load finished (and stored any first start), for tests
newActivityLoaded = false;
// the welcome or interests prompt has opened, or was not due, for tests
onboardingChecked = false;
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
@@ -713,11 +734,24 @@ export default class HomeView extends Vue {
}
}
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
if (shouldOpenWelcome(settings)) {
await (this.$refs.onboardingDialog as OnboardingDialog).open(
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) {
logger.error("[HomeView Settings Trace] ❌ initializeIdentity() failed", {
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
*
+555
View File
@@ -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&hellip;
</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>
+14 -11
View File
@@ -140,7 +140,6 @@ import {
NEW_ACTIVITY_BUCKET_INFO,
NewActivityBucket,
NewActivityBucketInfo,
nextWindow,
parseNewActivityCursorState,
serializeNewActivityCursorState,
ulidJustBefore,
@@ -237,9 +236,10 @@ export default class NewActivityBucketView extends Vue {
});
}
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
const from = bucketWindow
? windowStart(bucketWindow, now)
? bucketWindow.to
: windowStart(state.visit, now)?.[server];
const page = await loadBucket(context, bucket, from);
@@ -254,14 +254,13 @@ export default class NewActivityBucketView extends Vue {
this.loading = false;
if (page.nextAfterCursor) {
const window = bucketWindow
? nextWindow(bucketWindow, page.nextAfterCursor, now)
: {
...(from ? { from } : {}),
to: page.nextAfterCursor,
at: now.toISOString(),
seenAt: now.toISOString(),
};
const window = {
...(from ? { from } : {}),
...(bucketWindow?.seenAt ? { fromSeenAt: bucketWindow.seenAt } : {}),
to: page.nextAfterCursor,
at: now.toISOString(),
seenAt: now.toISOString(),
};
// re-read: the summary may have saved the visit meanwhile
const latest = parseNewActivityCursorState(
(await this.$accountSettings()).newActivityCursors,
@@ -402,7 +401,11 @@ export default class NewActivityBucketView extends Vue {
...latest,
buckets: {
...latest.buckets,
[this.bucket]: { ...window, to: page.nextAfterCursor },
[this.bucket]: {
...window,
to: page.nextAfterCursor,
seenAt: new Date().toISOString(),
},
},
}),
});
+90 -8
View File
@@ -31,12 +31,28 @@
</div>
<template v-else>
<p class="mb-2" data-testId="newActivityTotal">
<span class="text-lg font-semibold"
>{{ total }}{{ anyHitLimit ? "+" : "" }}</span
<div class="flex items-baseline gap-4 mb-2">
<p class="grow" data-testId="newActivityTotal">
<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.
</p>
Mark all read
</button>
</div>
<p
v-if="notificationNote"
@@ -121,18 +137,21 @@ import {
NewActivityBucket,
NewActivityCursorState,
NewActivityPrerequisite,
markedReadState,
nextWindow,
parseCursorParam,
parseNewActivityCursorState,
sameCursorPair,
serializeNewActivityCursorState,
windowStart,
windowStartSeenAt,
} from "../libs/newActivity";
import {
alertSearchUploadState,
bucketIsAvailable,
countNewActivity,
countsEndPair,
countSummaryActivity,
NewActivityContext,
NewActivityCounts,
totalNewActivity,
@@ -149,7 +168,8 @@ import { createNotifyHelpers } from "@/utils/notify";
/**
* New Activity summary: one count per alertSearch bucket since the last visit,
* 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({
components: { QuickNav },
@@ -167,6 +187,18 @@ export default class NewActivityView extends Vue {
counts: NewActivityCounts | null = null;
loading = true;
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 {
return this.counts ? totalNewActivity(this.counts) : 0;
@@ -201,7 +233,8 @@ export default class NewActivityView extends Vue {
bucketHitLimit(bucket: NewActivityBucket): boolean {
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 {
@@ -243,8 +276,11 @@ export default class NewActivityView extends Vue {
// becomes the start of the next visit
const from: CursorPair | undefined =
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;
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> {
await this.$saveUserSettings(this.activeDid, {
newActivityCursors: serializeNewActivityCursorState(state),
+5 -1
View File
@@ -3,6 +3,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage />
<NextStepBanner />
<!-- Main View Heading -->
<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 OnboardingDialog from "../components/OnboardingDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import NextStepBanner from "../components/NextStepBanner.vue";
import TopMessage from "../components/TopMessage.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
import { OnboardPage, iconForUnitCode } from "../libs/util";
import { shouldOpenWelcome } from "../libs/onboarding";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
@@ -323,6 +326,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
EntityIcon,
InfiniteScroll,
QuickNav,
NextStepBanner,
OnboardingDialog,
ProjectIcon,
TopMessage,
@@ -432,7 +436,7 @@ export default class ProjectsView extends Vue {
*/
private async checkOnboardingStatus() {
const settings = await this.$accountSettings();
if (!settings.finishedOnboarding) {
if (shouldOpenWelcome(settings)) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Create,
);
+2 -2
View File
@@ -6,7 +6,7 @@
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<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>
<!-- 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);
return (
`mailto:${SUPPORT_EMAIL}` +
`?subject=${encodeURIComponent("Time Safari startup error")}` +
`?subject=${encodeURIComponent("TimeSafari startup error")}` +
`&body=${encodeURIComponent(body)}`
);
}
+102 -1
View File
@@ -49,7 +49,7 @@
</div>
<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">
<button :class="sqlLinkClasses" @click="setAllTablesQuery">
All Tables
@@ -95,6 +95,61 @@
</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">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
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 { Account } from "../db/tables/accounts";
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 PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue";
@@ -421,6 +482,13 @@ export default class Help extends Vue {
sqlResult: unknown = null;
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;
// for component tests
@@ -547,6 +615,11 @@ export default class Help extends Vue {
this.executeSql();
}
setNotificationPluginQuery(query: string) {
this.notificationPluginSqlQuery = query;
this.executeNotificationPluginSql();
}
/**
* Configuration for notification test buttons
* 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.
* This simulates the flow where a user's DID is set, and then the
+5 -5
View File
@@ -62,21 +62,21 @@
* @example Checking activity feed
* ```typescript
* await page.goto('./');
* await page.getByTestId('closeOnboardingAndFinish').click();
* await closeOnboardingIfShown(page);
* await expect(page.locator('ul#listLatestActivity li:nth-child(10)'))
* .toBeVisible();
* ```
*/
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 { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
test('Check activity feed - check that server is running', async ({ page }) => {
// Load app homepage
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Check that initial 10 activities have been loaded
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 }) => {
// Load homepage to trigger ID generation (?)
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Wait for dialog to be hidden or removed - try multiple approaches
try {
// 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 page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Wait for dialog to be hidden or removed - try multiple approaches
try {
// First try: wait for overlay to disappear
+2 -2
View File
@@ -80,7 +80,7 @@
*/
import { test, expect } from '@playwright/test';
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 }) => {
// Generate a random string of a few characters
@@ -100,7 +100,7 @@ test('Record something given', async ({ page }) => {
// Record something given
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Simple dialog handling - just wait for it to be gone
await page.waitForFunction(() => {
+2 -2
View File
@@ -86,7 +86,7 @@
import { test, expect } from '@playwright/test';
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.slow(); // Set timeout longer
@@ -114,7 +114,7 @@ test('Record 9 new gifts', async ({ page }) => {
// Record gift
await page.goto('./', { waitUntil: 'networkidle' });
if (i === 0) {
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
}
await page.getByRole('button', { name: 'Thank' }).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
await importUser(page, '00');
await page.goto('./discover');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.locator('ul#listDiscoverResults li:first-child a').click()
// wait for the project page to load
@@ -27,7 +27,6 @@ async function findProjectIds(
page: Page,
): Promise<{ fullId: string; shortId: string }> {
await page.goto('./discover');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.locator('ul#listDiscoverResults li:first-child a').click();
await page.waitForURL(/\/project\//);
+3 -3
View File
@@ -60,7 +60,7 @@
*/
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 }) => {
@@ -117,7 +117,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
await page.getByRole('button', { name: 'Thank' }).click();
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
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"]');
await expect(giftLink).toBeVisible();
await giftLink.click();
+22 -12
View File
@@ -1,5 +1,5 @@
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.setTimeout(60000);
@@ -14,17 +14,26 @@ test('Record an offer', async ({ page }) => {
// Switch to user 0
// await importUser(page);
// Become User Zero
await importUserFromAccount(page, "00");
// Select a project
await page.goto('./discover');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
// Record an offer
// The line that used to sit here waited for a button with the text 'Edit'. It
// never waited (isVisible() returns at once) and never matched (that button
// carries title="Edit", no text, and renders only for the project's issuer).
const userZeroDid = await importUserFromAccount(page, "00");
// this Home visit records where User Zero's New Activity starts
await page.goto('./');
await closeOnboardingIfShown(page);
await waitForNewActivityLoaded(page);
// Select a project User Zero owns, since New Activity leaves out a person's
// own offers to their projects
await page.goto('./projects');
await page.locator('ul#listProjects li').first().click();
// The project id is read from the URL, so wait for the navigation to land.
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 page.getByTestId('offerButton').click();
await page.getByTestId('inputDescription').fill(description);
@@ -74,7 +83,8 @@ test('Record an offer', async ({ page }) => {
await page.getByTestId('editClaimButton').click();
const newAmount = page.getByTestId('inputOfferAmount');
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('./');
const offerNumElem = page.getByTestId('newActivityNumber-personalPlanContributions');
// 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 page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Wait for dialog to be hidden or removed - try multiple approaches
try {
// First try: wait for overlay to disappear
+2 -2
View File
@@ -1,5 +1,5 @@
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 }) => {
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');
}
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
// Wait for onboarding dialog to fully close and settings to persist
await expect(page.locator('.dialog-overlay')).toBeHidden({ timeout: 5000 });
await expect(page.getByTestId('newActivityNumber-claims')).toBeHidden();
+14 -1
View File
@@ -88,13 +88,26 @@ export async function importUser(page: Page, id?: string): Promise<string> {
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(
page: Page,
id?: string
): Promise<string> {
const did = await importUser(page, id);
await page.goto("./");
await page.getByTestId("closeOnboardingAndFinish").click();
await closeOnboardingIfShown(page);
return did;
}