large onboarding: ask for interests and go to matching, then prompt to save
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<!--
|
||||
InterestsPromptDialog.vue - the first question on every platform
|
||||
|
||||
Asks for interests in the person's own words and an optional point, keeps
|
||||
both on the device (settings.onboardingState), and opens the results view.
|
||||
Opened from HomeView on a first visit and from Help to search again.
|
||||
See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||
-->
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog" data-testId="interestsPrompt">
|
||||
<h1 class="text-xl font-bold text-center mb-2 relative">
|
||||
What would you like to find?
|
||||
<div
|
||||
class="text-lg text-center leading-none absolute right-0 -top-1 cursor-pointer"
|
||||
aria-label="Close"
|
||||
@click="onClickClose()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]" />
|
||||
</div>
|
||||
</h1>
|
||||
<p class="text-center text-slate-600 mb-4">
|
||||
Tell us your interests, and we'll look for people and projects nearby
|
||||
that match.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
v-model="interests"
|
||||
class="w-full h-28 p-2 border border-slate-300 rounded-md mb-2"
|
||||
:placeholder="placeholder"
|
||||
aria-label="Your interests"
|
||||
data-testId="interestsInput"
|
||||
></textarea>
|
||||
<p v-if="capabilities.isMobile" class="text-xs text-slate-500 mb-4">
|
||||
Tip: tap the microphone on your keyboard to speak.
|
||||
</p>
|
||||
|
||||
<!-- Location: optional -->
|
||||
<div class="mb-4">
|
||||
<div v-if="point" class="text-sm text-slate-700">
|
||||
<font-awesome icon="location-dot" class="fa-fw text-slate-400" />
|
||||
Near {{ pointText }}
|
||||
<span class="text-blue-500 cursor-pointer ml-2" @click="removePoint()"
|
||||
>remove</span
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="isLocating" class="text-sm text-slate-500">
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
Finding your location…
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm bg-slate-200 text-slate-700 px-3 py-2 rounded-md"
|
||||
@click="useMyLocation()"
|
||||
>
|
||||
<font-awesome icon="location-dot" class="fa-fw" />
|
||||
Use my location
|
||||
</button>
|
||||
<button
|
||||
v-if="locationFailed || showMap"
|
||||
type="button"
|
||||
class="text-sm bg-slate-200 text-slate-700 px-3 py-2 rounded-md"
|
||||
@click="showMap = !showMap"
|
||||
>
|
||||
<font-awesome icon="location-dot" class="fa-fw" />
|
||||
Pick on map
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="locationFailed && !point" class="text-xs text-slate-500 mt-1">
|
||||
Your location isn't available. You can pick a spot on the map, or
|
||||
search without one.
|
||||
</p>
|
||||
<div v-if="showMap && !point" class="mt-2 aspect-video">
|
||||
<l-map
|
||||
class="!z-40 rounded-md"
|
||||
:zoom="2"
|
||||
:center="[20, 0]"
|
||||
@click="onMapClick"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500 mb-4">
|
||||
Your interests stay on this device. Searching sends them to the server
|
||||
to find matches, but the server does not store them. You can choose to
|
||||
add them to your public profile afterward.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50"
|
||||
:disabled="!interests.trim()"
|
||||
data-testId="findMatches"
|
||||
@click="onClickFindMatches()"
|
||||
>
|
||||
Find matches
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
data-testId="closeOnboardingAndFinish"
|
||||
@click="onClickNotNow()"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Geolocation } from "@capacitor/geolocation";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
|
||||
import {
|
||||
hasPoint,
|
||||
onboardingSettingsUpdate,
|
||||
parseOnboardingState,
|
||||
roundCoordinate,
|
||||
withDeclined,
|
||||
withInterests,
|
||||
} from "../libs/onboarding";
|
||||
import { markInterestsPromptShown } from "../utils/onboardingNudge";
|
||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
components: { LMap, LTileLayer },
|
||||
emits: ["find-matches"],
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class InterestsPromptDialog extends Vue {
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
$router!: Router;
|
||||
|
||||
interests = "";
|
||||
isLocating = false;
|
||||
locationFailed = false;
|
||||
// from Help, "Not now" only closes: the flow's stage is left as it was
|
||||
openedFromHelp = false;
|
||||
point: { lat: number; lon: number } | null = null;
|
||||
showMap = false;
|
||||
visible = false;
|
||||
|
||||
placeholder =
|
||||
"For example: vegetable gardening, teaching kids to code, fixing bikes," +
|
||||
" a weekly hiking group...";
|
||||
|
||||
get pointText(): string {
|
||||
if (!this.point) return "";
|
||||
return `${this.point.lat.toFixed(2)}, ${this.point.lon.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Opens pre-filled with whatever interests and point the phone kept. */
|
||||
async open(options?: { fromHelp?: boolean }): Promise<void> {
|
||||
this.openedFromHelp = !!options?.fromHelp;
|
||||
const settings = await this.$accountSettings();
|
||||
const state = parseOnboardingState(settings.onboardingState);
|
||||
this.interests = state.interests || "";
|
||||
this.point = hasPoint(state) ? { lat: state.lat, lon: state.lon } : null;
|
||||
this.isLocating = false;
|
||||
this.locationFailed = false;
|
||||
this.showMap = false;
|
||||
this.visible = true;
|
||||
markInterestsPromptShown();
|
||||
}
|
||||
|
||||
async useMyLocation(): Promise<void> {
|
||||
this.isLocating = true;
|
||||
try {
|
||||
// browsers ask for permission on the read itself, and the plugin's
|
||||
// web version has no separate request
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const permission = await Geolocation.requestPermissions({
|
||||
permissions: ["coarseLocation"],
|
||||
});
|
||||
if (
|
||||
permission.coarseLocation !== "granted" &&
|
||||
permission.location !== "granted"
|
||||
) {
|
||||
throw new Error("Location permission: " + permission.coarseLocation);
|
||||
}
|
||||
}
|
||||
const position = await Geolocation.getCurrentPosition({
|
||||
enableHighAccuracy: false,
|
||||
timeout: 15000,
|
||||
maximumAge: 10 * 60 * 1000,
|
||||
});
|
||||
// only the rounded point is ever kept
|
||||
this.point = {
|
||||
lat: roundCoordinate(position.coords.latitude),
|
||||
lon: roundCoordinate(position.coords.longitude),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("[InterestsPromptDialog] No location:", error);
|
||||
this.locationFailed = true;
|
||||
this.showMap = true;
|
||||
} finally {
|
||||
this.isLocating = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMapClick(event: LeafletMouseEvent): void {
|
||||
if (event?.latlng) {
|
||||
this.point = {
|
||||
lat: roundCoordinate(event.latlng.lat),
|
||||
lon: roundCoordinate(event.latlng.lng),
|
||||
};
|
||||
this.showMap = false;
|
||||
}
|
||||
}
|
||||
|
||||
removePoint(): void {
|
||||
this.point = null;
|
||||
}
|
||||
|
||||
/** Keep the text and point first, so nothing after this can lose them. */
|
||||
async onClickFindMatches(): Promise<void> {
|
||||
if (!this.interests.trim()) return;
|
||||
const settings = await this.$accountSettings();
|
||||
const state = withInterests(
|
||||
parseOnboardingState(settings.onboardingState),
|
||||
this.interests,
|
||||
this.point,
|
||||
);
|
||||
await this.$saveSettings(onboardingSettingsUpdate(state));
|
||||
this.visible = false;
|
||||
if (this.$route.name === "interest-matches") {
|
||||
// the results view is underneath and searches again on this event
|
||||
this.$emit("find-matches");
|
||||
} else {
|
||||
await this.$router.push({ name: "interest-matches" });
|
||||
}
|
||||
}
|
||||
|
||||
async onClickNotNow(): Promise<void> {
|
||||
if (this.openedFromHelp) {
|
||||
this.visible = false;
|
||||
return;
|
||||
}
|
||||
const settings = await this.$accountSettings();
|
||||
const state = withDeclined(
|
||||
parseOnboardingState(settings.onboardingState),
|
||||
new Date(),
|
||||
{ interests: this.interests, point: this.point },
|
||||
);
|
||||
await this.$saveSettings(onboardingSettingsUpdate(state));
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
/** The "x": the stage stays, and Home asks again on the next cold start. */
|
||||
onClickClose(): void {
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!--
|
||||
NextStepBanner.vue - the interests onboarding's one-time pointer to the
|
||||
Profile page's next-step card. It slides down once for a registered person
|
||||
who has seen their matches, pulses the profile tab's dot, then collapses;
|
||||
the dot stays. Where the app has no alerts (web, desktop), it asks only for
|
||||
the profile. See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||
-->
|
||||
<template>
|
||||
<transition name="next-step-banner">
|
||||
<div
|
||||
v-if="showing"
|
||||
class="mb-4 rounded-md bg-blue-600 text-white px-4 py-3 shadow cursor-pointer"
|
||||
role="status"
|
||||
data-testId="nextStepBanner"
|
||||
@click="onClick()"
|
||||
>
|
||||
<template v-if="alertsOffered">
|
||||
Want to hear when new matches appear? Save your interests and pick an
|
||||
alert
|
||||
</template>
|
||||
<template v-else>
|
||||
Let people with interests like yours find you: save your interests to
|
||||
your profile
|
||||
</template>
|
||||
<font-awesome icon="arrow-right" class="fa-fw" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import {
|
||||
offersAlerts,
|
||||
onboardingSettingsUpdate,
|
||||
parseOnboardingState,
|
||||
shouldAnimateBanner,
|
||||
withBannerShown,
|
||||
} from "../libs/onboarding";
|
||||
import { pulseNudge, refreshNudge } from "../utils/onboardingNudge";
|
||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
|
||||
/** How long the banner holds before collapsing toward the tab. */
|
||||
const HOLD_MS = 5000;
|
||||
|
||||
@Component({ mixins: [PlatformServiceMixin] })
|
||||
export default class NextStepBanner extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
/** When set, the page calls {@link check} itself, e.g. after it re-checks registration. */
|
||||
@Prop({ default: false }) deferred!: boolean;
|
||||
|
||||
showing = false;
|
||||
hideTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
get alertsOffered(): boolean {
|
||||
return offersAlerts(this.capabilities);
|
||||
}
|
||||
|
||||
async mounted(): Promise<void> {
|
||||
if (!this.deferred) {
|
||||
await this.check();
|
||||
}
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
if (this.showing) return;
|
||||
const settings = await this.$accountSettings();
|
||||
refreshNudge(settings);
|
||||
const state = parseOnboardingState(settings.onboardingState);
|
||||
if (!shouldAnimateBanner(state, !!settings.isRegistered)) {
|
||||
return;
|
||||
}
|
||||
// spent as soon as it shows, so it never animates twice
|
||||
await this.$saveSettings(
|
||||
onboardingSettingsUpdate(withBannerShown(state, new Date())),
|
||||
);
|
||||
this.showing = true;
|
||||
pulseNudge(HOLD_MS);
|
||||
this.hideTimer = setTimeout(() => {
|
||||
this.showing = false;
|
||||
}, HOLD_MS);
|
||||
}
|
||||
|
||||
beforeUnmount(): void {
|
||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
this.showing = false;
|
||||
this.$router.push({ name: "account" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.next-step-banner-enter-active {
|
||||
transition:
|
||||
transform 0.4s ease-out,
|
||||
opacity 0.4s ease-out;
|
||||
}
|
||||
.next-step-banner-leave-active {
|
||||
transition:
|
||||
transform 0.6s ease-in,
|
||||
opacity 0.6s ease-in;
|
||||
}
|
||||
.next-step-banner-enter-from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
/* collapses down and right, toward the profile tab */
|
||||
.next-step-banner-leave-to {
|
||||
transform: translate(40%, 60vh) scale(0.1);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<!--
|
||||
NextStepCard.vue - the interests onboarding's follow-up, at the top of the
|
||||
Profile page: save the interests to the public profile, then, where the
|
||||
app has alerts (the phone apps), pick a daily alert. A thin layer: the
|
||||
page runs the actions with the methods its own profile and notification
|
||||
sections use, and this only shows them.
|
||||
See doc/CHANGES/PLAN-onboarding-by-interests.md.
|
||||
-->
|
||||
<template>
|
||||
<section
|
||||
class="bg-blue-50 border border-blue-200 rounded-md px-4 py-4 mb-8"
|
||||
aria-labelledby="nextStepHeading"
|
||||
data-testId="nextStepCard"
|
||||
>
|
||||
<h2 id="nextStepHeading" class="font-bold mb-1">
|
||||
{{
|
||||
offerAlerts
|
||||
? "Hear about new matches"
|
||||
: "Let people with interests like yours find you"
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="offerAlerts" class="text-sm text-slate-600 mb-4">
|
||||
Two steps, and we'll let you know when someone with interests like yours
|
||||
joins or posts.
|
||||
</p>
|
||||
<p v-else class="text-sm text-slate-600 mb-4">
|
||||
Save your interests to your public profile, and people searching for
|
||||
interests like yours can find you.
|
||||
</p>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<h3 class="font-semibold mb-1">
|
||||
<font-awesome
|
||||
v-if="profileSaved"
|
||||
icon="check"
|
||||
class="fa-fw text-green-600"
|
||||
/>
|
||||
<template v-if="offerAlerts">1.</template> Save to your public profile
|
||||
</h3>
|
||||
<p v-if="profileSaved" class="text-sm text-slate-600 mb-4">
|
||||
Saved. You can change it any time under "Public Profile" below.
|
||||
</p>
|
||||
<template v-else>
|
||||
<textarea
|
||||
v-model="text"
|
||||
class="w-full h-24 p-2 border border-slate-300 rounded-md"
|
||||
aria-label="Interests for your public profile"
|
||||
data-testId="nextStepInterests"
|
||||
></textarea>
|
||||
<label v-if="pointText" class="flex items-center text-sm mb-2">
|
||||
<input v-model="includeLocation" type="checkbox" class="mr-2" />
|
||||
Include location (rounded to about 1 km): {{ pointText }}
|
||||
</label>
|
||||
<p v-if="offerAlerts" class="text-xs text-slate-500 mb-2">
|
||||
Alerts about matches use your saved profile.
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-4 disabled:opacity-50"
|
||||
:disabled="saving || !text.trim()"
|
||||
data-testId="nextStepSave"
|
||||
@click="$emit('save', { text: text.trim(), includeLocation })"
|
||||
>
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2, only where the app has alerts -->
|
||||
<template v-if="offerAlerts">
|
||||
<h3 class="font-semibold mb-1">
|
||||
<font-awesome
|
||||
v-if="smsOn || inAppOn"
|
||||
icon="check"
|
||||
class="fa-fw text-green-600"
|
||||
/>
|
||||
2. Get alerted
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-2 mb-2">
|
||||
<button
|
||||
class="text-left px-4 py-2 rounded-md border"
|
||||
:class="
|
||||
smsDisabledReason
|
||||
? 'border-slate-200 text-slate-400 cursor-not-allowed'
|
||||
: 'border-blue-300 bg-white'
|
||||
"
|
||||
:disabled="!!smsDisabledReason || smsOn"
|
||||
data-testId="nextStepSms"
|
||||
@click="$emit('sms')"
|
||||
>
|
||||
<font-awesome icon="message" class="fa-fw" />
|
||||
Text message
|
||||
<span class="text-xs uppercase text-green-700 ml-1"
|
||||
>most reliable</span
|
||||
>
|
||||
<span v-if="smsOn" class="block text-sm text-green-700">
|
||||
On, at {{ smsTime }}
|
||||
</span>
|
||||
<span v-else-if="smsDisabledReason" class="block text-xs">
|
||||
{{ smsDisabledReason }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="text-left px-4 py-2 rounded-md border border-blue-300 bg-white"
|
||||
:disabled="inAppOn"
|
||||
data-testId="nextStepInApp"
|
||||
@click="$emit('in-app')"
|
||||
>
|
||||
<font-awesome icon="comment" class="fa-fw" />
|
||||
In-app notification
|
||||
<span v-if="inAppOn" class="block text-sm text-green-700">
|
||||
On, at {{ inAppTime }}
|
||||
</span>
|
||||
<span v-else class="block text-xs text-slate-500">
|
||||
May arrive late on iPhone.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'help-notification-types' }"
|
||||
class="text-sm text-blue-500"
|
||||
>
|
||||
Which should I choose?
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
class="text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
data-testId="nextStepDone"
|
||||
@click="$emit('done')"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
data-testId="nextStepNoThanks"
|
||||
@click="$emit('done')"
|
||||
>
|
||||
No thanks
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from "vue-facing-decorator";
|
||||
|
||||
@Component({
|
||||
name: "NextStepCard",
|
||||
emits: ["save", "sms", "in-app", "done"],
|
||||
})
|
||||
export default class NextStepCard extends Vue {
|
||||
/** Show step 2, the daily alert: false where the app has no alerts */
|
||||
@Prop({ default: true }) offerAlerts!: boolean;
|
||||
@Prop({ default: "" }) interests!: string;
|
||||
/** "lat, lon" of the kept point, or empty when there is none */
|
||||
@Prop({ default: "" }) pointText!: string;
|
||||
@Prop({ default: false }) profileSaved!: boolean;
|
||||
@Prop({ default: false }) saving!: boolean;
|
||||
@Prop({ default: false }) smsOn!: boolean;
|
||||
@Prop({ default: "" }) smsTime!: string;
|
||||
/** Why text messages can't be chosen here, or empty when they can */
|
||||
@Prop({ default: "" }) smsDisabledReason!: string;
|
||||
@Prop({ default: false }) inAppOn!: boolean;
|
||||
@Prop({ default: "" }) inAppTime!: string;
|
||||
|
||||
text = "";
|
||||
includeLocation = true;
|
||||
|
||||
created(): void {
|
||||
this.text = this.interests;
|
||||
}
|
||||
|
||||
@Watch("interests")
|
||||
onInterestsChange(value: string): void {
|
||||
if (!this.text) this.text = value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -83,7 +83,9 @@
|
||||
|
||||
<p class="mt-4 flex items-center">
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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[] };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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…
|
||||
</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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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…
|
||||
</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>
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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\//);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user