interests during onboarding: show messages for further action, to store profile & get notified

This commit is contained in:
2026-11-01 15:32:00 -07:00
parent 753bd00b6b
commit c35a16d1f0
9 changed files with 376 additions and 44 deletions
+1 -1
View File
@@ -1154,7 +1154,7 @@ export GEM_PATH=$shortened_path
###### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 76 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.10;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 77 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.11;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
+11 -2
View File
@@ -248,8 +248,17 @@ projects/people by anywhere/nearby/mapped and would gain a fourth axis.
it (±0.45° latitude; longitude scaled by `cos(lat)`). The box, not the point,
is what leaves the device.
- Shows two sections, People and Projects, best match first, each row with
the description excerpt, the similarity as a short label ("strong", "good",
"some"), and a tap-through to `user-profile` or `project`. When the box
the description excerpt, the similarity as a short label ("strong" at 0.85
and up, "weak" at 0.81 and up, none below), and a tap-through to
`user-profile` or `project`. Only strong matches show by default; the rest
sit in a collapsed "Show N weaker matches" section, and with no strong
matches the view says so above that section, then says how they would
hear about one (`noStrongFollowUp` in `src/libs/onboarding.ts`): a
registration notice when unregistered; a link to the follow-up card
(`/account?nextStep=1`, shown regardless of stage) when the server profile
has no text or, on the phone apps, no daily alert is on; otherwise a note
that the alert is on, or on the web that alerts are in the phone apps.
When the box
returns fewer than five rows, a second request without the box fills a
"Farther away" section; the server's query cache serves the second
request without another inference.
+11 -3
View File
@@ -24,9 +24,17 @@ covers the web dismissal path (`closeOnboardingIfShown`).
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.
4. Tap "Find matches". The results view shows People and Projects with only
"strong" matches (similarity 0.85 and up). Below them, "Show N weaker
matches" is collapsed; tapping it reveals the rest, "weak" rows labeled and
lower rows unlabeled. With no strong matches, "No strong matches yet"
shows above the collapsed row, followed by how they'd hear about one:
unregistered, a notice to find someone to register them; registered
without a saved profile, or on a phone without a daily alert, a link to
the Profile page's follow-up card (`/account?nextStep=1`); with both, a
note that the daily alert is on; on the web with a saved profile, a note
that alerts are in the phone apps. With fewer than five nearby, "Farther
away" appears.
5. Go to the feed. The banner slides down once, the profile tab's red dot
pulses, and the banner collapses. Visit Discover and Projects: no banner;
the dot stays.
+41 -8
View File
@@ -252,14 +252,47 @@ export function locBoxToBoundingBox(box: LocBox): BoundingBox {
};
}
/** The /similar score at and above which a match is shown by default. */
export const STRONG_SIMILARITY = 0.85;
/**
* A short word for a /similar score. The cuts follow the server's current
* embedding spec: 0.78 is the search minimum and 0.85 the alert minimum.
* A short word for a /similar score, or "" for a weak match, which shows no
* label. The cuts follow the server's embedding spec: 0.78 is the search
* minimum and 0.85 the alert minimum.
*/
export function similarityLabel(
similarity: number,
): "strong" | "good" | "some" {
if (similarity >= 0.85) return "strong";
if (similarity >= 0.81) return "good";
return "some";
export function similarityLabel(similarity: number): "strong" | "weak" | "" {
if (similarity >= STRONG_SIMILARITY) return "strong";
if (similarity >= 0.81) return "weak";
return "";
}
/**
* What the matches view says under "No strong matches yet":
* - `register`: not registered, so no saved profile and no alerts
* - `alertsOn`: a saved profile and a daily alert, so strong matches arrive
* - `setUp`: registered, missing the profile save or (on the phone apps) an
* alert; the Profile page's follow-up card does both
* - `profileOnly`: a saved profile where the app has no alerts (the web)
*/
export type NoStrongFollowUp =
| "register"
| "alertsOn"
| "setUp"
| "profileOnly";
export function noStrongFollowUp(input: {
isRegistered: boolean;
profileSaved: boolean;
alertsOn: boolean;
offersAlerts: boolean;
}): NoStrongFollowUp {
if (!input.isRegistered) return "register";
if (!input.profileSaved) return "setUp";
if (input.alertsOn) return "alertsOn";
return input.offersAlerts ? "setUp" : "profileOnly";
}
/** Whether a match is strong enough to show without expanding. */
export function isStrongMatch(similarity: number): boolean {
return similarity >= STRONG_SIMILARITY;
}
+23
View File
@@ -13,6 +13,29 @@ export interface UserProfile {
rowId?: string; // set on profile retrieved from server
}
/**
* Whether the person has a public profile with text on the server, which
* alerts about matches use. A 404 (no profile) or any failure counts as none.
*/
export async function hasServerProfileText(
partnerApiServer: string,
headers: Record<string, string>,
did: string,
fetchFn: typeof fetch = fetch,
): Promise<boolean> {
try {
const response = await fetchFn(
`${partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(did)}`,
{ method: "GET", headers },
);
if (response.status !== 200) return false;
const body = await response.json();
return !!body?.data?.description?.trim();
} catch {
return false;
}
}
/** One /api/partner/similar result, best first, with DIDs hidden. */
export type SimilarMatch =
| {
+70 -2
View File
@@ -1,6 +1,8 @@
import {
boundingBoxAround,
isNudgeOn,
isStrongMatch,
noStrongFollowUp,
locBoxToBoundingBox,
offersAlerts,
onboardingSettingsUpdate,
@@ -21,6 +23,7 @@ import {
} from "@/libs/onboarding";
import {
fetchSimilar,
hasServerProfileText,
SIMILAR_BUSY_MESSAGE,
SIMILAR_NETWORK_MESSAGE,
SIMILAR_RATE_LIMITED_MESSAGE,
@@ -224,8 +227,73 @@ describe("location helpers", () => {
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");
expect(similarityLabel(0.82)).toBe("weak");
expect(similarityLabel(0.79)).toBe("");
});
it("treats only strong matches as shown by default", () => {
expect(isStrongMatch(0.85)).toBe(true);
expect(isStrongMatch(0.849)).toBe(false);
});
});
describe("noStrongFollowUp", () => {
const base = {
isRegistered: true,
profileSaved: true,
alertsOn: true,
offersAlerts: true,
};
it("asks the unregistered to get registered, whatever else is set", () => {
expect(noStrongFollowUp({ ...base, isRegistered: false })).toBe("register");
});
it("only reassures with both a saved profile and an alert", () => {
expect(noStrongFollowUp(base)).toBe("alertsOn");
});
it("sends a missing profile to the setup, even with an alert on", () => {
expect(noStrongFollowUp({ ...base, profileSaved: false })).toBe("setUp");
});
it("sends a missing alert to the setup where the app has alerts", () => {
expect(noStrongFollowUp({ ...base, alertsOn: false })).toBe("setUp");
});
it("has nothing to set up on the web once the profile is saved", () => {
expect(
noStrongFollowUp({ ...base, alertsOn: false, offersAlerts: false }),
).toBe("profileOnly");
});
});
describe("hasServerProfileText", () => {
function respond(status: number, body: unknown) {
return jest.fn().mockResolvedValue({
status,
json: () => Promise.resolve(body),
} as unknown as Response);
}
it("is true for a profile with text, and encodes the DID", async () => {
const fetchFn = respond(200, { data: { description: "gardening" } });
expect(
await hasServerProfileText("https://p.example", {}, "did:x:1", fetchFn),
).toBe(true);
expect(fetchFn.mock.calls[0][0]).toBe(
"https://p.example/api/partner/userProfileForIssuer/did%3Ax%3A1",
);
});
it("is false for blank text, a 404, or a failed request", async () => {
const blank = respond(200, { data: { description: " " } });
expect(await hasServerProfileText("s", {}, "d", blank)).toBe(false);
expect(await hasServerProfileText("s", {}, "d", respond(404, {}))).toBe(
false,
);
const down = jest.fn().mockRejectedValue(new Error("offline"));
expect(await hasServerProfileText("s", {}, "d", down)).toBe(false);
});
});
+11 -2
View File
@@ -1337,6 +1337,8 @@ export default class AccountViewView extends Vue {
// the profile draft the phone kept
onboardingState: OnboardingState = { stage: "interests" };
nextStepProfileSaved: boolean = false;
/** `?nextStep=1`, from the matches view: show the card outside the nudge */
nextStepRequested: boolean = false;
nextStepSmsDisabledReason: string = "";
// the profile editor holds the kept interests, not a saved profile
profileDraftFromPhone: boolean = false;
@@ -1430,7 +1432,9 @@ export default class AccountViewView extends Vue {
} else {
// Profile not created yet; leave defaults
}
if (!profile?.description) {
if (profile?.description) {
this.nextStepProfileSaved = true;
} else {
this.fillProfileFromKeptInterests();
}
} catch (error) {
@@ -3187,7 +3191,10 @@ export default class AccountViewView extends Vue {
}
get showNextStepCard(): boolean {
return isNudgeOn(this.onboardingState, this.isRegistered);
return (
isNudgeOn(this.onboardingState, this.isRegistered) ||
(this.nextStepRequested && this.isRegistered)
);
}
/** The phone apps; elsewhere the card asks only for the profile. */
@@ -3209,6 +3216,7 @@ export default class AccountViewView extends Vue {
private async loadOnboardingState(): Promise<void> {
const settings = await this.$accountSettings();
this.onboardingState = parseOnboardingState(settings.onboardingState);
this.nextStepRequested = this.$route.query["nextStep"] === "1";
setNudgeOn(isNudgeOn(this.onboardingState, this.isRegistered));
this.nextStepSmsDisabledReason = (await this.activeIdentityIsPasskey())
? "Not available for passkey identities."
@@ -3310,6 +3318,7 @@ export default class AccountViewView extends Vue {
}
async onNextStepDone(): Promise<void> {
this.nextStepRequested = false;
await this.saveOnboardingState(withDone(this.onboardingState));
}
+7 -14
View File
@@ -296,6 +296,7 @@ import {
parseNewActivityCursorState,
serializeNewActivityCursorState,
} from "../libs/newActivity";
import { hasServerProfileText } from "../libs/partnerServer";
import {
onboardingSettingsUpdate,
parseOnboardingState,
@@ -778,20 +779,12 @@ export default class HomeView extends Vue {
}
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;
}
const settings = await this.$accountSettings();
return hasServerProfileText(
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER,
await getHeaders(this.activeDid),
this.activeDid,
);
}
/**
+201 -12
View File
@@ -80,14 +80,79 @@
back later as more people join.
</p>
<p
v-else-if="strongGroups.length === 0"
class="text-center text-slate-500 mt-8"
data-testId="interestMatchesNoStrong"
>
No strong matches yet. Try describing your interests another way, or
check back later as more people join.
</p>
<!-- With no strong match, how they'd hear about one later -->
<template v-if="strongGroups.length === 0 && followUp">
<RegistrationNotice
v-if="followUp === 'register'"
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
:message="REGISTER_MESSAGE"
/>
<p
v-else-if="followUp === 'alertsOn'"
class="text-center text-slate-600 mt-4"
data-testId="interestMatchesAlertsOn"
>
Your daily alert is on, so we'll let you know when a strong match
joins or posts.
</p>
<div
v-else-if="followUp === 'setUp'"
class="text-center mt-4"
data-testId="interestMatchesSetUp"
>
<p class="text-slate-600 mb-2">
{{ setUpMessage }}
</p>
<router-link
:to="{ name: 'account', query: { nextStep: '1' } }"
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
data-testId="interestMatchesSetUpLink"
>
{{ setUpLinkText }}
</router-link>
</div>
<p
v-else-if="followUp === 'profileOnly'"
class="text-center text-slate-600 mt-4"
data-testId="interestMatchesProfileOnly"
>
Your saved profile lets people with interests like yours find you. To
be alerted about new matches, use the Android or iPhone app.
</p>
</template>
<div
v-for="group in groups"
v-for="group in visibleGroups"
: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">
<button
v-if="group.key === WEAK_TOGGLE_KEY"
class="w-full text-left text-slate-600 py-2"
data-testId="interestMatchesWeakToggle"
:aria-expanded="showWeak"
@click="showWeak = !showWeak"
>
<font-awesome
:icon="showWeak ? 'chevron-down' : 'chevron-right'"
class="fa-fw"
/>
{{ showWeak ? "Hide" : "Show" }} {{ weakCount }} weaker
{{ weakCount === 1 ? "match" : "matches" }}
</button>
<h2 v-else class="text-lg font-semibold mb-1">{{ group.title }}</h2>
<ul v-if="group.matches.length > 0" class="border-t border-slate-300">
<li
v-for="match in group.matches"
:key="match.subjectType + ':' + match.subjectId"
@@ -114,6 +179,7 @@
{{ titleFor(match) }}
</h3>
<span
v-if="similarityLabel(match.similarity)"
class="text-xs uppercase px-1.5 py-0.5 rounded"
:class="labelClasses(match.similarity)"
>{{ similarityLabel(match.similarity) }}</span
@@ -144,14 +210,19 @@ import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
import NextStepBanner from "../components/NextStepBanner.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import QuickNav from "../components/QuickNav.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import TopMessage from "../components/TopMessage.vue";
import { DEFAULT_PARTNER_API_SERVER } from "../constants/app";
import { DEFAULT_PARTNER_API_SERVER, PASSKEYS_ENABLED } from "../constants/app";
import { UNNAMED_PROJECT } from "../constants/entities";
import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders } from "../libs/endorserServer";
import {
boundingBoxAround,
hasPoint,
isStrongMatch,
noStrongFollowUp,
NoStrongFollowUp,
offersAlerts,
onboardingSettingsUpdate,
parseOnboardingState,
SEARCH_RADIUS_KM,
@@ -160,6 +231,7 @@ import {
} from "../libs/onboarding";
import {
fetchSimilar,
hasServerProfileText,
SimilarMatch,
SimilarResult,
} from "../libs/partnerServer";
@@ -171,6 +243,40 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
/** Fewer nearby results than this, and a second search fills "Farther away". */
const FEW_NEARBY = 5;
const REGISTER_MESSAGE =
"Find someone to register you, and then you can be notified when " +
"someone with interests like yours joins.";
/** The group key of the row that expands the weaker matches. */
const WEAK_TOGGLE_KEY = "weakToggle";
interface MatchGroup {
key: string;
title: string;
matches: SimilarMatch[];
}
/** People and projects nearby, then everything farther, minus empty groups. */
function groupMatches(
nearby: SimilarMatch[],
farther: SimilarMatch[],
keyPrefix: string,
): MatchGroup[] {
return [
{
key: keyPrefix + "people",
title: "People",
matches: nearby.filter((m) => m.subjectType === "profile"),
},
{
key: keyPrefix + "projects",
title: "Projects",
matches: nearby.filter((m) => m.subjectType === "plan"),
},
{ key: keyPrefix + "farther", title: "Farther away", matches: farther },
].filter((group) => group.matches.length > 0);
}
@Component({
name: "InterestMatchesView",
components: {
@@ -178,6 +284,7 @@ const FEW_NEARBY = 5;
NextStepBanner,
ProjectIcon,
QuickNav,
RegistrationNotice,
TopMessage,
},
mixins: [PlatformServiceMixin],
@@ -186,7 +293,10 @@ export default class InterestMatchesView extends Vue {
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
REGISTER_MESSAGE = REGISTER_MESSAGE;
SEARCH_RADIUS_KM = SEARCH_RADIUS_KM;
WEAK_TOGGLE_KEY = WEAK_TOGGLE_KEY;
similarityLabel = similarityLabel;
activeDid = "";
@@ -194,21 +304,70 @@ export default class InterestMatchesView extends Vue {
allMyDids: string[] = [];
errorMessage = "";
farther: SimilarMatch[] = [];
/** Set after a search with no strong match; empty until then */
followUp: NoStrongFollowUp | "" = "";
givenName = "";
isLoading = false;
/** Known only once followUp is set */
needsAlert = false;
needsProfile = false;
nearby: SimilarMatch[] = [];
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
point: { lat: number; lon: number } | null = null;
searched = false;
showWeak = false;
text = "";
get 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");
get strongGroups(): MatchGroup[] {
const strong = (m: SimilarMatch) => isStrongMatch(m.similarity);
return groupMatches(
this.nearby.filter(strong),
this.farther.filter(strong),
"",
);
}
get weakGroups(): MatchGroup[] {
const weak = (m: SimilarMatch) => !isStrongMatch(m.similarity);
return groupMatches(
this.nearby.filter(weak),
this.farther.filter(weak),
"weak-",
);
}
get setUpMessage(): string {
if (this.needsProfile && this.needsAlert) {
return "Save your interests to your public profile and turn on a daily alert, and we'll let you know when a strong match joins or posts.";
}
if (this.needsProfile) {
return "Alerts about matches use your public profile. Save your interests there, and we'll let you know when a strong match joins or posts.";
}
return "Turn on a daily alert, and we'll let you know when a strong match joins or posts.";
}
get setUpLinkText(): string {
if (this.needsProfile && this.needsAlert)
return "Save profile and set up alerts";
return this.needsProfile ? "Save to your profile" : "Set up alerts";
}
get weakCount(): number {
return this.weakGroups.reduce((n, g) => n + g.matches.length, 0);
}
/**
* Strong matches, then a row that expands the weaker ones, which follow
* only while expanded.
*/
get visibleGroups(): MatchGroup[] {
if (this.weakCount === 0) return this.strongGroups;
const toggle = { key: WEAK_TOGGLE_KEY, title: "", matches: [] };
return [
{ 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);
...this.strongGroups,
toggle,
...(this.showWeak ? this.weakGroups : []),
];
}
async mounted(): Promise<void> {
@@ -216,6 +375,7 @@ export default class InterestMatchesView extends Vue {
const activeIdentity = await this.$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
this.givenName = settings.firstName || "";
this.allContacts = await this.$getAllContacts();
this.allMyDids = await retrieveAccountDids();
@@ -267,6 +427,7 @@ export default class InterestMatchesView extends Vue {
}
this.nearby = first.matches;
this.farther = [];
this.showWeak = false;
if (box && first.matches.length < FEW_NEARBY) {
// the server caches the query's vector, so this costs no inference
const wide = await fetchSimilar(this.partnerApiServer, headers, {
@@ -285,6 +446,7 @@ export default class InterestMatchesView extends Vue {
}
this.searched = true;
await this.recordMatchesShown();
await this.loadFollowUp(headers);
} catch (error) {
logger.error("[InterestMatchesView] Search failed:", error);
this.errorMessage =
@@ -294,6 +456,33 @@ export default class InterestMatchesView extends Vue {
}
}
/** With no strong match, work out how they'd hear about one later. */
private async loadFollowUp(headers: Record<string, string>): Promise<void> {
this.followUp = "";
if (this.strongGroups.length > 0) return;
const settings = await this.$accountSettings();
const isRegistered = !!settings.isRegistered;
const alertsOn =
!!settings.notifyingNewActivityTime ||
!!settings.notifyingNewActivitySmsTime;
const appOffersAlerts = offersAlerts(this.capabilities);
const profileSaved =
isRegistered &&
(await hasServerProfileText(
this.partnerApiServer,
headers,
this.activeDid,
));
this.needsProfile = !profileSaved;
this.needsAlert = appOffersAlerts && !alertsOn;
this.followUp = noStrongFollowUp({
isRegistered,
profileSaved,
alertsOn,
offersAlerts: appOffersAlerts,
});
}
private showError(result: Exclude<SimilarResult, { kind: "ok" }>): void {
logger.warn("[InterestMatchesView] Search not answered:", result);
this.errorMessage = result.message;
@@ -327,7 +516,7 @@ export default class InterestMatchesView extends Vue {
switch (similarityLabel(similarity)) {
case "strong":
return "bg-green-100 text-green-800";
case "good":
case "weak":
return "bg-blue-100 text-blue-800";
default:
return "bg-slate-100 text-slate-600";