diff --git a/BUILDING.md b/BUILDING.md
index 8ca52add..9f1fee24 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -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
```
diff --git a/doc/CHANGES/PLAN-onboarding-by-interests.md b/doc/CHANGES/PLAN-onboarding-by-interests.md
index 0a3337b4..91211b28 100644
--- a/doc/CHANGES/PLAN-onboarding-by-interests.md
+++ b/doc/CHANGES/PLAN-onboarding-by-interests.md
@@ -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.
diff --git a/doc/onboarding-by-interests-manual-test.md b/doc/onboarding-by-interests-manual-test.md
index 784c6503..ab6b0e04 100644
--- a/doc/onboarding-by-interests-manual-test.md
+++ b/doc/onboarding-by-interests-manual-test.md
@@ -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 , " 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.
diff --git a/src/libs/onboarding.ts b/src/libs/onboarding.ts
index 1d5c305c..97f40797 100644
--- a/src/libs/onboarding.ts
+++ b/src/libs/onboarding.ts
@@ -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;
}
diff --git a/src/libs/partnerServer.ts b/src/libs/partnerServer.ts
index 1a225913..aca7d366 100644
--- a/src/libs/partnerServer.ts
+++ b/src/libs/partnerServer.ts
@@ -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,
+ did: string,
+ fetchFn: typeof fetch = fetch,
+): Promise {
+ 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 =
| {
diff --git a/src/test/onboarding.test.ts b/src/test/onboarding.test.ts
index 1385dda2..1779ef5a 100644
--- a/src/test/onboarding.test.ts
+++ b/src/test/onboarding.test.ts
@@ -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);
});
});
diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue
index 8eaede6a..f10f8e31 100644
--- a/src/views/AccountViewView.vue
+++ b/src/views/AccountViewView.vue
@@ -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 {
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 {
+ this.nextStepRequested = false;
await this.saveOnboardingState(withDone(this.onboardingState));
}
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index e07d749e..ad04def1 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -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 {
- 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,
+ );
}
/**
diff --git a/src/views/InterestMatchesView.vue b/src/views/InterestMatchesView.vue
index d0259db2..dd65d303 100644
--- a/src/views/InterestMatchesView.vue
+++ b/src/views/InterestMatchesView.vue
@@ -80,14 +80,79 @@
back later as more people join.
+
+ No strong matches yet. Try describing your interests another way, or
+ check back later as more people join.
+
+
+
+
+
+
+ Your daily alert is on, so we'll let you know when a strong match
+ joins or posts.
+
+
+
+ {{ setUpMessage }}
+
+
+ {{ setUpLinkText }}
+
+
+
+ Your saved profile lets people with interests like yours find you. To
+ be alerted about new matches, use the Android or iPhone app.
+
+
+
-
{{ group.title }}
-
+
+
{{ group.title }}
+
{{ similarityLabel(match.similarity) }} 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 {
@@ -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): Promise {
+ 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): 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";