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. +

+ + + +
-

{{ 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";