large onboarding: ask for interests and go to matching, then prompt to save

This commit is contained in:
2026-10-31 21:44:12 -06:00
parent 03c1a7f513
commit 753bd00b6b
45 changed files with 2480 additions and 159 deletions
+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`.
+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
### 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.
+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
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')
+97 -79
View File
@@ -1,41 +1,41 @@
# Plan: Onboarding by Interests (mobile)
# Plan: Onboarding by Interests
## Goal
A person who installs the phone app and opens it to the Home feed is asked one
thing before anything else: what they are interested in, in their own words,
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
turn on a daily alert, text message first, so they hear when someone with
matching interests joins or posts.
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 native
Android and iOS builds.
- The Help page should now show a link to enter their interests again,
which triggers this interest prompt again.
This link goes above the current onboarding-welcome dialog link.
- The current onboarding explanations can be triggered by the Help page,
but that's the only way. On mobile they are not part of the onboarding
process.
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 two follow-up actions. It appears only for
registered identities, since both actions need registration.
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: the web and Electron builds keep the welcome dialog as their
onboarding; on-device embedding; changes to what the alerts contain;
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).
@@ -65,7 +65,7 @@ alert channels keep requiring registration).
`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 (`ClaimService.getRateLimits`) and is
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
@@ -117,8 +117,8 @@ alert channels keep requiring registration).
- 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`; the Capacitor service reports both true.
- Platform detection: `this.capabilities.isNativeApp` and `isMobile` through
`PlatformServiceMixin` (a computed property); the Capacitor service reports both true.
## Design
@@ -146,11 +146,10 @@ 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.
On mobile the three welcome pages are not part of onboarding at any stage.
`HomeView`, `DiscoverView`, and `ProjectsView` open `OnboardingDialog` on
mobile only while `welcomeTour` is true. The Help page's "page-by-page help"
link sets `welcomeTour` on mobile (on web it keeps setting `finishedOnboarding`
false), and finishing or closing the dialog clears it. On mobile, a
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.
@@ -179,7 +178,7 @@ profile, and they outlive the onboarding stages:
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 phone. Not public until you save your profile." with a "Forget"
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.
@@ -190,9 +189,10 @@ profile, and they outlive the onboarding stages:
### Where and when the prompt opens
Mobile only: `capabilities().isNativeApp && capabilities().isMobile`. Web and
Electron keep `OnboardingDialog` unchanged, so the Playwright web suite
is untouched.
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
@@ -223,7 +223,7 @@ One screen, replacing the three. Content:
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 phone. Searching
- 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
@@ -246,7 +246,7 @@ projects/people by anywhere/nearby/mapped and would gain a fourth axis.
- 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 phone.
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", "good",
"some"), and a tap-through to `user-profile` or `project`. When the box
@@ -274,8 +274,9 @@ 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 on mount (the
settings read is cached) and renders a small red circle at the top-right of
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
@@ -283,7 +284,8 @@ kept interests wait on the phone for that moment.
`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 →", holds for a few seconds while the profile tab's dot pulses (a CSS
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.
@@ -297,7 +299,9 @@ kept interests wait on the phone for that moment.
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."** Two choices, in this order:
- **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
@@ -306,17 +310,17 @@ kept interests wait on the phone for that moment.
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 both steps also sets `done`.
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. On web and Electron it
remains the onboarding. On mobile it opens only from the Help page link, as
described under State, and walks the same Home → Discover → Projects sequence
while `welcomeTour` is set.
`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
@@ -392,89 +396,96 @@ its registration gate.
### endorser-ch
- [ ] `/similar`: drop the registration check for `text:`; per-DID limit 20
- [x] `/similar`: drop the registration check for `text:`; per-DID limit 20
registered / 5 unregistered.
- [ ] Per-IP text-query limit (IPv4 address or IPv6 /64), with `TRUST_PROXY`
- [x] Per-IP text-query limit (IPv4 address or IPv6 /64), with `TRUST_PROXY`
feeding Express `trust proxy`; document the setting for each deployment.
- [ ] Query-vector LRU cache keyed by preprocessed-text hash.
- [ ] Separate, capped text-query queue behind profile and sweep work; 503
- [x] Query-vector LRU cache keyed by preprocessed-text hash.
- [x] Separate, capped text-query queue behind profile and sweep work; 503
when full.
- [ ] Tests in `test/controller-partner-9-similar.js`: unregistered text query
- [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
- [ ] Migration adding `onboardingState TEXT` to `settings`; add it to the JSON
list in `PlatformServiceMixin._mapColumnsToValues` and to the `Settings`
type.
- [ ] `src/libs/onboarding.ts`: the state type, stage transitions, the
- [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 `isMobileOnboarding(capabilities)`. Unit tests in `src/test/`.
- [ ] `HomeView.initializeIdentity`: on mobile with stage `interests`, check
for an existing server profile, then open `InterestsPromptDialog`; otherwise
keep the `OnboardingDialog` call for web.
- [ ] `HomeView`, `DiscoverView`, `ProjectsView`: on mobile, open
`OnboardingDialog` only while `welcomeTour` is set; clear it when the dialog
finishes or closes.
- [ ] `HelpView.vue`: an "enter your interests again" link above the welcome
link, opening `InterestsPromptDialog` pre-filled; on mobile, the welcome
link sets `welcomeTour` instead of only unsetting `finishedOnboarding`.
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
- [ ] `InterestsPromptDialog.vue`: textarea, location button with permission
- [x] `InterestsPromptDialog.vue`: textarea, location button with permission
flow and map fallback, privacy line, "Find matches", "Not now".
- [ ] `@capacitor/geolocation`: install, `npx cap sync`, Android permissions
- [x] `@capacitor/geolocation`: install, Android permissions
(`ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION`), iOS
`NSLocationWhenInUseUsageDescription`; note the Play Services dependency in
`doc/android-firebase-gms.md`.
- [ ] `src/libs/partnerServer.ts`: `fetchSimilar(params)` typed client for
`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.
- [ ] `InterestMatchesView.vue` + route `interest-matches` (+ deep-link schema
- [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.
- [ ] `DiscoverView.vue`: a "Search by interests" link to the view, so the
- [x] `DiscoverView.vue`: a "Search by interests" link to the view, so the
feature is reachable after onboarding.
### This app: nudge
- [ ] `QuickNav.vue`: red dot on the profile tab while the stage is `save`
- [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.
- [ ] `NextStepBanner.vue`: one-time animated banner, registered identities
- [x] `NextStepBanner.vue`: one-time animated banner, registered identities
only; placed in Home,
Discover, Projects, and the results view; sets `bannerShownAt`.
- [ ] `NextStepCard.vue` in `AccountViewView.vue`: Step 1 save (text +
- [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.
- [ ] `AccountViewView.vue` "Public Profile": with no saved profile, pre-fill
from the kept interests and point, with the "Saved only on this phone" note
- [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".
- [ ] Default time pre-fill in `PushNotificationPermission` when opened from
- [x] Default time pre-fill in `PushNotificationPermission` when opened from
the card (per Decision 6).
### This app: text, docs, tests
- [ ] `doc/usage-guide.md` and `HelpNotificationTypesView.vue`: mention that
- [x] `doc/usage-guide.md` and `HelpNotificationTypesView.vue`: mention that
match alerts come from the saved profile text.
- [ ] Unit tests: stage transitions, kept-interests lifetime (kept on `done`,
- [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.
- [ ] Playwright web: confirm `importUserAndCloseOnboarding` passes
(web is unchanged).
- [ ] Mobile manual script in `doc/`: fresh install → prompt → deny location →
- [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.
- [ ] CHANGELOG entry; bump with `-beta`.
- [x] CHANGELOG entry; bump with `-beta`.
## Rejected
@@ -509,6 +520,13 @@ available for this release.
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
+1 -1
View File
@@ -1,7 +1,7 @@
## Tasks
- [ ] Onboarding by interests on mobile — next: state and gating work (`onboardingState` migration, `src/libs/onboarding.ts`); see [PLAN-onboarding-by-interests.md](PLAN-onboarding-by-interests.md) (Trent)
- [ ] 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
+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.
+113
View File
@@ -0,0 +1,113 @@
# 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, each row
with a "strong", "good", or "some" label. 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
View File
@@ -44,6 +44,8 @@
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Time Safari allows you to upload photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Time Safari uses your approximate location to find people and projects near you. Only a rounded area leaves your phone.</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>UIBackgroundModes</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'
+13
View File
@@ -18,6 +18,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",
@@ -1935,6 +1936,18 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/geolocation": {
"version": "7.1.8",
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-7.1.8.tgz",
"integrity": "sha512-x/rJieD2N2zlGvXFRD3ru4ArJWTwXAIYmyhfo5zg4F1DKGos7mCCOvIOSVMdfQmt14SCsmJeO9Qe8EtDGNGCrw==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.3"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.6.4.tgz",
+1
View File
@@ -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",
+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>
+8
View File
@@ -314,6 +314,14 @@ const MIGRATIONS = [
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;
`,
},
];
/**
+2
View File
@@ -47,6 +47,8 @@ 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;
+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(),
+265
View File
@@ -0,0 +1,265 @@
/**
* 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,
};
}
/**
* A short word for a /similar score. The cuts follow the server's current
* embedding spec: 0.78 is the search minimum and 0.85 the alert minimum.
*/
export function similarityLabel(
similarity: number,
): "strong" | "good" | "some" {
if (similarity >= 0.85) return "strong";
if (similarity >= 0.81) return "good";
return "some";
}
+117
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,117 @@ export interface UserProfile {
issuerDid: string;
rowId?: string; // set on profile retrieved from server
}
/** 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[] };
}
+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",
+315
View File
@@ -0,0 +1,315 @@
import {
boundingBoxAround,
isNudgeOn,
locBoxToBoundingBox,
offersAlerts,
onboardingSettingsUpdate,
type OnboardingState,
parseOnboardingState,
roundCoordinate,
serializeOnboardingState,
shouldAnimateBanner,
shouldOpenWelcome,
similarityLabel,
withBannerShown,
withDeclined,
withDone,
withInterests,
withMatchesShown,
withoutInterests,
withWelcomeTour,
} from "@/libs/onboarding";
import {
fetchSimilar,
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("good");
expect(similarityLabel(0.79)).toBe("some");
});
});
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");
});
});
+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);
}
+270 -34
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"
@@ -411,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"
@@ -1062,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";
@@ -1071,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";
@@ -1152,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;
@@ -1172,6 +1220,7 @@ interface PushNotificationPermissionRef {
skipSchedule?: boolean;
rolloverIntervalMinutes?: number;
timeOnly?: boolean;
defaultTime?: string;
},
) => void;
hourInput?: string;
@@ -1188,6 +1237,7 @@ interface PushNotificationPermissionRef {
LMap,
LMarker,
LTileLayer,
NextStepCard,
PushNotificationPermission,
QuickNav,
SmsVerificationDialog,
@@ -1282,6 +1332,14 @@ 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;
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;
@@ -1346,6 +1404,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(
@@ -1371,6 +1430,9 @@ export default class AccountViewView extends Vue {
} else {
// Profile not created yet; leave defaults
}
if (!profile?.description) {
this.fillProfileFromKeptInterests();
}
} catch (error) {
logger.error("Error loading profile:", error);
this.notify.error(
@@ -1644,7 +1706,8 @@ 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" &&
@@ -1654,24 +1717,28 @@ export default class AccountViewView extends Vue {
}
(
this.$refs.pushNotificationPermission as PushNotificationPermission
).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);
).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);
}
} else {
await this.uploadServerNewActivity(timeText);
}
}
});
},
defaultTime ? { defaultTime } : undefined,
);
} else {
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
if (!success) {
@@ -1919,23 +1986,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
@@ -2051,7 +2128,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) => {
@@ -2066,7 +2143,7 @@ export default class AccountViewView extends Vue {
this.notifyingNewActivitySms = true;
this.notifyingNewActivitySmsTime = timeText;
},
{ timeOnly: true },
{ timeOnly: true, defaultTime },
);
}
@@ -3061,7 +3138,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 = {
@@ -3080,6 +3158,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();
@@ -3090,14 +3176,164 @@ 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);
}
/** 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);
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> {
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: " +
+70 -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,16 @@ import {
parseNewActivityCursorState,
serializeNewActivityCursorState,
} from "../libs/newActivity";
import {
onboardingSettingsUpdate,
parseOnboardingState,
shouldOpenWelcome,
withDone,
} from "../libs/onboarding";
import {
refreshNudge,
wasInterestsPromptShown,
} from "../utils/onboardingNudge";
import {
countUnseenActivity,
seedIfNeeded,
@@ -387,6 +403,8 @@ interface FeedError {
GiftedDialog,
GiftedPrompts,
InfiniteScroll,
InterestsPromptDialog,
NextStepBanner,
OnboardingDialog,
QuickNav,
TopMessage,
@@ -424,6 +442,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 +733,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 +759,41 @@ 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> {
try {
const settings = await this.$accountSettings();
const partnerApiServer =
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER;
const response = await this.axios.get(
`${partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(this.activeDid)}`,
{ headers: await getHeaders(this.activeDid) },
);
return !!response.data?.data?.description?.trim();
} catch (error) {
// 404 when there is no profile; anything else, ask anyway
logger.debug("[HomeView] No server profile found:", error);
return false;
}
}
/**
* Ensures correct API server configuration
*
+366
View File
@@ -0,0 +1,366 @@
<!--
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>
<div
v-for="group in groups"
:key="group.key"
class="mt-6"
:data-testId="'interestMatches-' + group.key"
>
<h2 class="text-lg font-semibold mb-1">{{ group.title }}</h2>
<ul 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
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 TopMessage from "../components/TopMessage.vue";
import { DEFAULT_PARTNER_API_SERVER } from "../constants/app";
import { UNNAMED_PROJECT } from "../constants/entities";
import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders } from "../libs/endorserServer";
import {
boundingBoxAround,
hasPoint,
onboardingSettingsUpdate,
parseOnboardingState,
SEARCH_RADIUS_KM,
similarityLabel,
withMatchesShown,
} from "../libs/onboarding";
import {
fetchSimilar,
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;
@Component({
name: "InterestMatchesView",
components: {
InterestsPromptDialog,
NextStepBanner,
ProjectIcon,
QuickNav,
TopMessage,
},
mixins: [PlatformServiceMixin],
})
export default class InterestMatchesView extends Vue {
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
SEARCH_RADIUS_KM = SEARCH_RADIUS_KM;
similarityLabel = similarityLabel;
activeDid = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
errorMessage = "";
farther: SimilarMatch[] = [];
isLoading = false;
nearby: SimilarMatch[] = [];
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
point: { lat: number; lon: number } | null = null;
searched = false;
text = "";
get groups(): Array<{ key: string; title: string; matches: SimilarMatch[] }> {
const people = this.nearby.filter((m) => m.subjectType === "profile");
const projects = this.nearby.filter((m) => m.subjectType === "plan");
return [
{ key: "people", title: "People", matches: people },
{ key: "projects", title: "Projects", matches: projects },
{ key: "farther", title: "Farther away", matches: this.farther },
].filter((group) => group.matches.length > 0);
}
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.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 = [];
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();
} catch (error) {
logger.error("[InterestMatchesView] Search failed:", error);
this.errorMessage =
"Something went wrong while searching. Please try again.";
} finally {
this.isLoading = false;
}
}
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 "good":
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>
+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,
);
+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();
+4 -4
View File
@@ -1,5 +1,5 @@
import { test, expect, Page } from '@playwright/test';
import { importUser, importUserFromAccount, switchToUser, waitForNewActivityLoaded } from './testUtils';
import { importUser, importUserFromAccount, switchToUser, waitForNewActivityLoaded, closeOnboardingIfShown } from './testUtils';
test('Record an offer', async ({ page }) => {
test.setTimeout(60000);
@@ -17,7 +17,7 @@ test('Record an offer', async ({ page }) => {
const userZeroDid = await importUserFromAccount(page, "00");
// this Home visit records where User Zero's New Activity starts
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
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
@@ -31,7 +31,7 @@ test('Record an offer', async ({ page }) => {
await importUserFromAccount(page, "01");
// onboarding is per identity, and its dialog would cover the Projects page
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await closeOnboardingIfShown(page);
await expect(page.locator('.dialog-overlay')).toBeHidden();
await page.goto(projectUrl);
await expect(page.getByTestId('offerButton')).toBeVisible();
@@ -110,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;
}