29 KiB
Plan: Onboarding by Interests
Goal
A person who opens the app to the Home feed for the first time (phone, web, or desktop) is asked one thing before anything else: what they are interested in, in their own words, with an optional "use my location". The app then shows them the people and projects whose descriptions are closest in meaning to what they typed, and closest in distance when they gave a location. After they have seen those results, and only once they are registered, a red dot on the profile tab (and a one-time banner) points them to the follow-up: save the interests and location to their public profile, and, on the phone apps, turn on a daily alert, text message first, so they hear when someone with matching interests joins or posts. Web and desktop have no New Activity alerts, so there the follow-up is the profile save alone.
In scope:
- 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.
- 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.
- A results screen for interest matches, reachable again later from Discover.
- A "next step" nudge that does not block the screen: a red dot on the profile tab, a banner that animates in once, and a card at the top of the Profile page that holds the follow-up actions: the profile save, and on the phone apps the alert. It appears only for registered identities, since every action needs registration.
- The follow-up actions reuse what the Profile page already does: save a profile with description and location, verify a phone and authorize SMS New Activity alerts, or authorize in-app New Activity alerts.
Out of scope: alerts on web and desktop; on-device embedding; changes to what the alerts contain;
registration changes beyond text search on /similar (profile save and both
alert channels keep requiring registration).
Current state
src/components/OnboardingDialog.vueis the welcome. It opens fromHomeView.vue(initializeIdentity, whensettings.finishedOnboardingis false), again fromDiscoverView.vueandProjectsView.vue, and re-opens on every visit to those pages until a button setsfinishedOnboarding. Playwright web tests dismiss it through thecloseOnboardingAndFinishtest ID (test-playwright/testUtils.ts,importUserAndCloseOnboarding).HelpView.vuehas a "see the page-by-page help again" link (unsetFinishedOnboarding) that setsfinishedOnboardingfalse and goes to Home, where the welcome dialog opens again.src/views/HelpOnboardingView.vue(routehelp-onboarding) is a standalone instructions page for meeting onboarding;HelpView.vueholds the general help.- The router guard (
src/router/index.ts) creates an identity on the first navigation, so Home never loads without anactiveDid. Deep links land on their own routes (invite-one-accept,claim,confirm-gift,shared-photo,contact-import, …); the invite flow ends oncontacts. - Matching lives on the Partner server (
endorser-ch,src/api/controllers/partner-router.js; design in that repo'sCHANGES/PLAN-interest-matching-architecture.md):GET /api/partner/similar?from=text:<query>&to=profile|plan|bothwith an optional bounding box (minLocLat,minLocLon,maxLocLat,maxLocLon),minSimilarity, andlimit(default 25, max 50). It returns{ subjectType, subjectId, similarity, record }best first, with DIDs hidden the way the profile and plan searches hide them. Atext:source needs a JWT from a registered DID (mainService.isMember) and is limited toEMBEDDING_QUERY_MAX_PER_MINUTEqueries per DID (default 20). The query text is embedded in memory and not stored.GET /api/partner/userProfile(the people list Discover uses) andGET /api/v2/report/plans/plansByLocation(the project lists) need a JWT but not registration. Both take aclaimContentstext filter and a bounding box, which is how Discover searches.POST /api/partner/userProfile(description,locLat,locLon, optional second point) needs registration. Every saved profile is embedded, and the embedding is refreshed when the text changes.- Query text is embedded in-process through one serial inference queue
(
embedding.service.js,enqueue) shared with profile saves and the plan sweep. The queue has no length limit. The per-DID text limit is an in-memory map inpartner-router.js(allowTextQuery). The server reads no client IP anywhere and does not set Expresstrust proxy. - Neither alertSearch handler (
/api/partner/alertSearch,/api/v2/report/alertSearch) checks registration; a JWT is enough. alertSearchreturnsprofilesSimilarandplansSimilar: subjects whose text was embedded inside the window and whose similarity to the requester's saved profile is at least the spec'salertMinimumSimilarity. They are empty when the requester has no profile text. An optionalsimilarLocationbox restricts them.
- This app's New Activity feature already counts those two lists
(
src/libs/newActivity.ts: "Profiles Like Yours", "Projects Matching Your Profile"), and the notification service (notification-wakeup-service,src/alertSearch/digest.ts) counts them into the "You have N new updates." push and text. So a saved profile with text is the only precondition for match alerts; no server or service change is needed for the alert itself. - Profile editing is on
AccountViewView.vue("Public Profile", a section shown only whenisRegistered: a textarea, an "Include Location" checkbox, and a Leaflet map the person taps to place a point;saveProfilepostsdescription,locLat,locLon). The app has no geolocation plugin and never callsnavigator.geolocation; location is always chosen on a map (SearchAreaView.vuefor the search box,settings.searchBoxes[0]). - Notification settings are also on
AccountViewView.vue, under "New Activity Notification", and only for registered identities (the notify API refuses unregistered DIDs). Two channels:- In-App (
notifyingNewActivityTime), shown only whenshowGeneralAdvancedis on because of iOS delivery problems (doc/sms-notifications.md), delivered by server push or on-device schedule (newActivityDelivery). - SMS (
smsNotificationsEnabled,notifyingNewActivitySmsPhone,notifyingNewActivitySmsTime):toggleSmsEnabled→ChoiceButtonDialog("See More Info" / "Enter Phone Number") →SmsVerificationDialog(US numbers, verification code) →promptSmsTimeAndEnable(PushNotificationPermissionwithtimeOnly) →authorizeSmsAlertSearch. Passkey identities cannot use it.
- In-App (
- No speech input exists anywhere in the app.
- Settings are one row per account (
src/db/tables/settings.ts); booleans and JSON columns are listed inPlatformServiceMixin._mapColumnsToValues, and a column is added with a migration insrc/db-sql/migration.ts. - Platform detection:
this.capabilities.isNativeAppandisMobilethroughPlatformServiceMixin(a computed property); the Capacitor service reports both true.
Design
State
One JSON settings column, onboardingState, following the newActivityCursors
precedent:
{
"stage": "interests",
"interests": "vegetable gardening, teaching kids to code",
"lat": 40.76,
"lon": -111.89,
"matchesShownAt": "2026-10-25T16:02:11Z",
"bannerShownAt": null,
"declinedAt": null,
"welcomeTour": false
}
Stages, in order: interests (prompt pending) → matches (interests stored,
results not shown, or shown without a reachable server) → save (results seen;
the nudge is on) → done. A person who taps "Not now" on the prompt goes to
done with declinedAt set. finishedOnboarding is set true whenever the
stage becomes done, so everything that reads it keeps working.
The three welcome pages are not part of onboarding at any stage.
HomeView, DiscoverView, and ProjectsView open OnboardingDialog only
while welcomeTour is true. The Help page's "page-by-page help" link sets
welcomeTour, and finishing or closing the dialog clears it. A
finishedOnboarding of false, which a fresh install has until the stage
reaches done, opens nothing by itself.
The Help page's "enter your interests again" link (above the welcome link)
opens InterestsPromptDialog from Help, pre-filled with the kept interests and
location. It does not change the stage unless the stage is done, in which
case "Find matches" moves it to matches and the flow runs again.
The stage is per account, like every other setting. An identity that already
has a profile with text on the server starts at done (a reinstall or a second
device): the Home load checks GET /api/partner/userProfileForIssuer/:did once
when the stage is interests.
Kept interests and location
interests, lat, and lon are written to onboardingState when the person
taps "Find matches", before any network call, so neither the registration
gates nor a server error can lose them. They are the draft of a public
profile, and they outlive the onboarding stages:
- They stay when the stage reaches
done, including after "No thanks" on the Profile card, and after "Not now" on the prompt when the person had typed something before declining. - They stay for as long as the person is unregistered. The server refuses an unregistered profile save, so the phone copy is the only copy until someone registers them.
- When a registered person has no saved profile, the Profile page's "Public Profile" editor (shown only to registered identities) pre-fills its textarea and map point from them and shows "Saved only on this device. Not public until you save your profile." with a "Forget" link. This is the path for a person who was unregistered during onboarding, and for anyone who declined the card.
- Saving the profile clears them, since the server then holds the text. "Forget" clears them. Running the interest search again with edited text replaces them.
latandlonhold the reading rounded to two decimals (about 1 km). The raw reading is never written.
Where and when the prompt opens
Every platform. HomeView sets data-onboarding-checked on #Content once
the prompt has opened or was not due, and the Playwright helper
closeOnboardingIfShown waits for it and closes the prompt only when it
opened: test users with saved profile text never see it.
The prompt opens from HomeView only, in the same place OnboardingDialog
opens (end of initializeIdentity, so the feed is loading underneath), when
the stage is interests. Nothing else opens it, so an invite, a claim, a
shared photo, or a contact import runs to its end without interruption; the
person meets the prompt the first time they come to Home afterward.
No timer and no wait for a gesture. A timer fires while the person is reading or scrolling; waiting for a gesture interrupts the thing they chose to do. Opening with the feed, as the welcome dialog does, is the moment they expect to be greeted, and the feed stays visible behind the overlay as context.
It opens once. If dismissed with the close "x" (not "Not now"), the stage stays
interests and the prompt returns on the next cold start of the app, not on
the next visit to Home. "Not now" ends it.
The prompt (InterestsPromptDialog.vue)
One screen, replacing the three. Content:
- Title: "What would you like to find?" Sub-line: "Tell us your interests, and we'll look for people and projects nearby that match."
- A textarea with a placeholder of examples. A small hint under it: "Tip: tap the microphone on your keyboard to speak." (See Decisions for a dedicated mic button.)
- A "Use my location" button. On tap, ask the OS for permission and read one position. On success, show "Near " with a "remove" link. On denial or failure, show "Pick on map" which opens a small map (the Leaflet setup from the Profile page) to tap a point. Location is optional; the search runs without it.
- The privacy line, in full: "Your interests stay on this device. Searching sends them to the server to find matches, but the server does not store them. You can choose to add them to your public profile afterward." (See Decisions: the request's "does not leave the device" wording is not true of a server-side text search, and the line must not say it.)
- Buttons: "Find matches" (primary; disabled until there is text) and "Not
now" (secondary,
data-testId="closeOnboardingAndFinish"so the existing test helper works if the flow is ever enabled on web).
"Find matches" stores interests and the point in onboardingState, sets the
stage to matches, and navigates to the results view.
The results view (InterestMatchesView.vue, route interest-matches)
A dedicated view rather than a mode of DiscoverView, which already multiplies
projects/people by anywhere/nearby/mapped and would gain a fourth axis.
- Reads interests and the point from
onboardingState, or from route query (text,lat,lon) so Discover can link to it later with a "Search by interests" entry. - Calls
GET /api/partner/similarwithfrom=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 bycos(lat)). The box, not the point, is what leaves the device. - Shows two sections, People and Projects, best match first, each row with
the description excerpt, the similarity as a short label ("strong", "good",
"some"), and a tap-through to
user-profileorproject. When the box returns fewer than five rows, a second request without the box fills a "Farther away" section; the server's query cache serves the second request without another inference. - Registered or not, the person gets the same matches by meaning, from the same request (see Registration gates).
- Error handling:
- 429: "Too many searches right now; try again in a minute." with a retry button. The interests are already kept, so nothing is lost.
- 503 (inference queue full): "Matching is busy; try again shortly." with a retry button.
- Network failure: a retry button; stage stays
matches.
- On the first successful render, set
matchesShownAtand the stage tosave.
The nudge
Three pieces, all driven by onboardingState.stage === "save" together with
settings.isRegistered. An unregistered person is never prompted to fill in a
profile or turn on alerts: after the results the stage moves to save as
usual, but the dot, banner, and card stay hidden. The first page load that
finds isRegistered true (HomeView re-checks registration on each load)
makes them appear, with the banner's one-time animation still unspent. The
kept interests wait on the phone for that moment.
- Red dot on the profile tab.
QuickNav.vuereads 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 thecircle-usericon. Every page shows QuickNav, so the pointer is everywhere and in nobody's way. - One-time banner. A
NextStepBanner.vuecomponent, placed underTopMessageinHomeView,DiscoverView,ProjectsView, andInterestMatchesView. The first time any of those pages renders while the stage issaveandbannerShownAtis null, it slides down from the top: "Want to hear when new matches appear? Save your interests and pick an alert →" on the phone apps, and "Let people with interests like yours find you: save your interests to your profile →" elsewhere. It holds for a few seconds while the profile tab's dot pulses (a CSS keyframe on the dot, triggered by a shared reactive flag), then collapses toward the tab. Tapping it goes toaccount. It setsbannerShownAtand never animates again; the dot stays. - Next-step card on the Profile page.
NextStepCard.vueat the top ofAccountViewView.vue, above the identity section, shown while the stage issave. It carries the messaging for the follow-up and both actions:- Step 1, "Save to your public profile." Shows the interests text
(editable) and, when a point exists, "Include location (rounded to about
1 km)" checked by default. "Save" calls the page's
saveProfilepath with the text and the rounded point, and also setssettings.searchBoxes[0]to the same box the search used when no search box exists yet, so Nearby works. Match alerts depend on this step; the card says so: "Alerts about matches use your saved profile." - Step 2, "Get alerted." On the phone apps only
(
offersAlerts(capabilities): native and mobile), since web and desktop have no Notifications section. Two choices, in this order:- Text message (labelled "most reliable"): runs the existing
toggleSmsEnabled→SmsVerificationDialog→ time prompt →authorizeSmsAlertSearchsequence. Shown disabled with the reason when the identity uses a passkey or SMS is otherwise unavailable. - In-app notification: runs the existing in-app enable path, with the
note "may arrive late on iPhone".
A link to
help-notification-typessits under both.
- Text message (labelled "most reliable"): runs the existing
- "Done" and "No thanks" at the bottom; both set the stage to
done, which clears the dot and the card. Completing every step shown (the save, plus an alert on the phone apps) also setsdone. The card is a thin layer over methodsAccountViewViewalready has; the notification section below it is unchanged, so a person who ignores the card finds the same toggles where they always were.
- Step 1, "Save to your public profile." Shows the interests text
(editable) and, when a point exists, "Include location (rounded to about
1 km)" checked by default. "Save" calls the page's
Welcome text
OnboardingDialog.vue keeps its three pages unchanged. It opens only from the
Help page link, as described under State, and walks the Home → Discover →
Projects sequence while welcomeTour is set.
Location
@capacitor/geolocation is added for "Use my location". Its Android
implementation depends on play-services-location, so this is a Play Services
dependency on top of the MLKit one (see AGENTS.md); on a device without
Google services the call fails and the prompt falls back to "Pick on map". The
point is used three ways: a ~50 km box for the search, a point rounded to two
decimals (about 1 km) for the saved profile, and the same box for
searchBoxes[0]. The raw reading is never stored.
Registration gates
Text search on GET /api/partner/similar opens to any JWT. Profile save and
both alert channels keep requiring registration, so an unregistered person
sees the same matches as a registered one, and their interests stay on the
phone until someone registers them and they choose to save.
An unregistered DID costs nothing to mint, so the per-DID limit alone stops nobody. The server change adds these protections, in the order a request meets them:
- Per-IP limit on text queries, keyed by the client address: the IPv4
address, or the /64 prefix of an IPv6 address. Mobile carriers put many
phones behind one IPv4 address (carrier-grade NAT), so the limit is a
burst ceiling, not a per-person budget: default 60 per minute
(
EMBEDDING_QUERY_MAX_PER_MINUTE_PER_IP). The per-DID limit stays at 20 for registered DIDs and is 5 for unregistered ones. - Correct client address.
req.ipis the proxy's address unless Expresstrust proxynames the proxy. The setting is the proxy's address or hop count, fromTRUST_PROXY, nevertrue: withtrue, any client can setX-Forwarded-Forand pick its own limit key. Unset, the per-IP limit keys on the connecting address, which is right when nothing fronts the API. - Query cache. An LRU of recent query vectors keyed by the hash of the preprocessed text (default 1,000 entries). Repeated text, from one spammer or from many people typing "gardening", costs no inference.
- Bounded queue with priority. Text queries wait in their own queue
capped at a few entries (
EMBEDDING_QUERY_MAX_QUEUED, default 10); past that the request gets 503 at once instead of waiting. Profile saves and the sweep go ahead of queued text queries, so a flood delays only other searchers.
What this exposes: nothing an unregistered JWT cannot read already. The profile list is open to any JWT with DIDs hidden the same way, and plans are public claims. Search by meaning makes the same records easier to find, which is the point of the feature. Spam does not get in through this route: it is read-only, and publishing anything (profile, claim, SMS number) keeps its registration gate.
Decisions made
- Privacy wording. Use the 'privacy line' above.
- Registration gates. Open text search on
/similarto unregistered identities, with the protections under Registration gates. Keep the gates on profile save and on both alert channels. Interests and location stay on the phone until the person is registered and chooses to save them. - Speech input. Keyboard dictation with a hint (for the first
release: zero dependencies, works on both platforms as they are). Not a dedicated
mic button through
@capacitor-community/speech-recognition, which needs microphone and speech permissions and an on-device recognizer service (Google's on most Android phones; absent on some FOSS devices). - Geolocation dependency. Accept
@capacitor/geolocationand itsplay-services-locationdependency (with the map fallback). - In-app choice visibility. The card shows the in-app option even when
showGeneralAdvancedis off. SMS is listed first. The card appears only for registered identities, so neither choice needs a registration message. - Default alert time. Pre-fill the time prompt with 9:00 local and show "change".
- Re-prompt rule. After the "x" close, return on the next cold start.
Work
endorser-ch
/similar: drop the registration check fortext:; per-DID limit 20 registered / 5 unregistered.- Per-IP text-query limit (IPv4 address or IPv6 /64), with
TRUST_PROXYfeeding Expresstrust 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 when full.
- Tests in
test/controller-partner-9-similar.js: unregistered text query succeeds; per-IP limit with a spoofedX-Forwarded-Forignored 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 TEXTtosettings, and the column in theSettingstype as a JSON string read withparseOnboardingState, likenewActivityCursors(not in_mapColumnsToValues). src/libs/onboarding.ts: the state type, stage transitions, the bounding-box helper (point + radius →minLocLat… ), the rounding helper, andoffersAlerts(capabilities). Unit tests insrc/test/.HomeView.initializeIdentity: with stageinterests, check for an existing server profile, then openInterestsPromptDialog; openOnboardingDialoginstead during a welcome tour.HomeView,DiscoverView,ProjectsView: openOnboardingDialogonly whilewelcomeTouris set; clear it when the dialog finishes or closes.HelpView.vue: an "enter your interests again" link above the welcome link, openingInterestsPromptDialogpre-filled; the welcome link setswelcomeTour.- 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 flow and map fallback, privacy line, "Find matches", "Not now".@capacitor/geolocation: install, Android permissions (ACCESS_COARSE_LOCATION,ACCESS_FINE_LOCATION), iOSNSLocationWhenInUseUsageDescription, Podfile entry; note the Play Services dependency indoc/android-firebase-gms.md.- iOS
pod installforCapacitorGeolocation(updatesPodfile.lock;scripts/build-ios.shruns it throughcap sync) — next: run on a machine with CocoaPods (Trent) 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+ routeinterest-matches(+ deep-link schema entry with optionaltext,lat,lon): two sections, "Farther away" fill, error states, stage →saveon first render.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 issaveand the identity is registered; pulse class driven by a shared reactive flag.NextStepBanner.vue: one-time animated banner, registered identities only; placed in Home, Discover, Projects, and the results view; setsbannerShownAt.NextStepCard.vueinAccountViewView.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 fromonboardingStateonly 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 device" note and "Forget".- Default time pre-fill in
PushNotificationPermissionwhen opened from the card (per Decision 6).
This app: text, docs, tests
doc/usage-guide.mdandHelpNotificationTypesView.vue: mention that match alerts come from the saved profile text.- Unit tests: stage transitions, kept-interests lifetime (kept on
done, "Not now", and while unregistered; cleared on save and "Forget"), bbox and rounding helpers,fetchSimilarerror mapping. - Playwright web:
closeOnboardingIfShown(waits fordata-onboarding-checked, closes the prompt only if it opened) in place of directcloseOnboardingAndFinishclicks; no close on Discover, where nothing opens. - 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.
Rejected
A timer before the prompt
A delay of a few seconds lands while the person is reading the feed or mid-scroll, and the length is arbitrary. The welcome dialog's timing (with the feed load) is the one moment the person expects to be addressed.
Waiting for the first gesture
The first tap or scroll is an action the person chose; a modal at that moment cancels it. It also makes the prompt's appearance feel caused by whatever they touched.
Extending DiscoverView with a "matches" mode
Discover already crosses projects/people with anywhere/nearby/mapped; a similarity mode would add a fourth axis and a different result shape (similarity-ranked, two types at once). A separate view is smaller and can be linked from Discover.
Running the search on the device
Requires the vector export and an on-device copy of the embedding model (a few hundred megabytes), which the server plan schedules as a later phase. It is the only way to make "never leaves the device" literally true, and it is not available for this release.
Blocking the next step with another modal after the results
The request rules this out: the person is in a search flow and the follow-up must wait at the edge of the screen until they choose it.
Keeping the welcome pages as the web and desktop onboarding
The first release of this plan limited the interests prompt to the phone apps and left web and Electron on the welcome pages. Closed 2026-10-31: every platform gets the interests prompt, and only the alert step stays phone-only, because web and desktop have no Notifications section.
Dropping the three welcome pages entirely
Their content is the only in-app explanation of the feed, Discover, and Projects tabs, so they stay, reachable from Help.
Keyword search for unregistered identities
The open people and project lists filter on claimContents, which needs
the person's words to appear in a record. A sentence of interests rarely
does, so the results would be thin exactly when the first impression
matters.
Opening profile save or SMS to unregistered identities
Profile save would let anyone publish to the list every user reads, which is where spam would land. SMS verification for an unregistered DID would let anyone make the notify API text arbitrary numbers at the project's expense (SMS pumping). Match alerts for unregistered people would need both, so they wait for registration.