interests during onboarding: show messages for further action, to store profile & get notified
This commit is contained in:
+1
-1
@@ -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:
|
###### 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
|
```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.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,
|
it (±0.45° latitude; longitude scaled by `cos(lat)`). The box, not the point,
|
||||||
is what leaves the device.
|
is what leaves the device.
|
||||||
- Shows two sections, People and Projects, best match first, each row with
|
- Shows two sections, People and Projects, best match first, each row with
|
||||||
the description excerpt, the similarity as a short label ("strong", "good",
|
the description excerpt, the similarity as a short label ("strong" at 0.85
|
||||||
"some"), and a tap-through to `user-profile` or `project`. When the box
|
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
|
returns fewer than five rows, a second request without the box fills a
|
||||||
"Farther away" section; the server's query cache serves the second
|
"Farther away" section; the server's query cache serves the second
|
||||||
request without another inference.
|
request without another inference.
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ covers the web dismissal path (`closeOnboardingIfShown`).
|
|||||||
welcome pages do not.
|
welcome pages do not.
|
||||||
3. Type interests. Tap "Use my location" and deny permission. "Pick on map"
|
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.
|
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
|
4. Tap "Find matches". The results view shows People and Projects with only
|
||||||
with a "strong", "good", or "some" label. With fewer than five nearby,
|
"strong" matches (similarity 0.85 and up). Below them, "Show N weaker
|
||||||
"Farther away" appears.
|
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
|
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;
|
pulses, and the banner collapses. Visit Discover and Projects: no banner;
|
||||||
the dot stays.
|
the dot stays.
|
||||||
|
|||||||
+41
-8
@@ -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
|
* A short word for a /similar score, or "" for a weak match, which shows no
|
||||||
* embedding spec: 0.78 is the search minimum and 0.85 the alert minimum.
|
* label. The cuts follow the server's embedding spec: 0.78 is the search
|
||||||
|
* minimum and 0.85 the alert minimum.
|
||||||
*/
|
*/
|
||||||
export function similarityLabel(
|
export function similarityLabel(similarity: number): "strong" | "weak" | "" {
|
||||||
similarity: number,
|
if (similarity >= STRONG_SIMILARITY) return "strong";
|
||||||
): "strong" | "good" | "some" {
|
if (similarity >= 0.81) return "weak";
|
||||||
if (similarity >= 0.85) return "strong";
|
return "";
|
||||||
if (similarity >= 0.81) return "good";
|
}
|
||||||
return "some";
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ export interface UserProfile {
|
|||||||
rowId?: string; // set on profile retrieved from server
|
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. */
|
/** One /api/partner/similar result, best first, with DIDs hidden. */
|
||||||
export type SimilarMatch =
|
export type SimilarMatch =
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
boundingBoxAround,
|
boundingBoxAround,
|
||||||
isNudgeOn,
|
isNudgeOn,
|
||||||
|
isStrongMatch,
|
||||||
|
noStrongFollowUp,
|
||||||
locBoxToBoundingBox,
|
locBoxToBoundingBox,
|
||||||
offersAlerts,
|
offersAlerts,
|
||||||
onboardingSettingsUpdate,
|
onboardingSettingsUpdate,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
} from "@/libs/onboarding";
|
} from "@/libs/onboarding";
|
||||||
import {
|
import {
|
||||||
fetchSimilar,
|
fetchSimilar,
|
||||||
|
hasServerProfileText,
|
||||||
SIMILAR_BUSY_MESSAGE,
|
SIMILAR_BUSY_MESSAGE,
|
||||||
SIMILAR_NETWORK_MESSAGE,
|
SIMILAR_NETWORK_MESSAGE,
|
||||||
SIMILAR_RATE_LIMITED_MESSAGE,
|
SIMILAR_RATE_LIMITED_MESSAGE,
|
||||||
@@ -224,8 +227,73 @@ describe("location helpers", () => {
|
|||||||
it("labels similarity by the server's cuts", () => {
|
it("labels similarity by the server's cuts", () => {
|
||||||
expect(similarityLabel(0.9)).toBe("strong");
|
expect(similarityLabel(0.9)).toBe("strong");
|
||||||
expect(similarityLabel(0.85)).toBe("strong");
|
expect(similarityLabel(0.85)).toBe("strong");
|
||||||
expect(similarityLabel(0.82)).toBe("good");
|
expect(similarityLabel(0.82)).toBe("weak");
|
||||||
expect(similarityLabel(0.79)).toBe("some");
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1337,6 +1337,8 @@ export default class AccountViewView extends Vue {
|
|||||||
// the profile draft the phone kept
|
// the profile draft the phone kept
|
||||||
onboardingState: OnboardingState = { stage: "interests" };
|
onboardingState: OnboardingState = { stage: "interests" };
|
||||||
nextStepProfileSaved: boolean = false;
|
nextStepProfileSaved: boolean = false;
|
||||||
|
/** `?nextStep=1`, from the matches view: show the card outside the nudge */
|
||||||
|
nextStepRequested: boolean = false;
|
||||||
nextStepSmsDisabledReason: string = "";
|
nextStepSmsDisabledReason: string = "";
|
||||||
// the profile editor holds the kept interests, not a saved profile
|
// the profile editor holds the kept interests, not a saved profile
|
||||||
profileDraftFromPhone: boolean = false;
|
profileDraftFromPhone: boolean = false;
|
||||||
@@ -1430,7 +1432,9 @@ export default class AccountViewView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
// Profile not created yet; leave defaults
|
// Profile not created yet; leave defaults
|
||||||
}
|
}
|
||||||
if (!profile?.description) {
|
if (profile?.description) {
|
||||||
|
this.nextStepProfileSaved = true;
|
||||||
|
} else {
|
||||||
this.fillProfileFromKeptInterests();
|
this.fillProfileFromKeptInterests();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3187,7 +3191,10 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showNextStepCard(): boolean {
|
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. */
|
/** 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> {
|
private async loadOnboardingState(): Promise<void> {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
this.onboardingState = parseOnboardingState(settings.onboardingState);
|
this.onboardingState = parseOnboardingState(settings.onboardingState);
|
||||||
|
this.nextStepRequested = this.$route.query["nextStep"] === "1";
|
||||||
setNudgeOn(isNudgeOn(this.onboardingState, this.isRegistered));
|
setNudgeOn(isNudgeOn(this.onboardingState, this.isRegistered));
|
||||||
this.nextStepSmsDisabledReason = (await this.activeIdentityIsPasskey())
|
this.nextStepSmsDisabledReason = (await this.activeIdentityIsPasskey())
|
||||||
? "Not available for passkey identities."
|
? "Not available for passkey identities."
|
||||||
@@ -3310,6 +3318,7 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async onNextStepDone(): Promise<void> {
|
async onNextStepDone(): Promise<void> {
|
||||||
|
this.nextStepRequested = false;
|
||||||
await this.saveOnboardingState(withDone(this.onboardingState));
|
await this.saveOnboardingState(withDone(this.onboardingState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-14
@@ -296,6 +296,7 @@ import {
|
|||||||
parseNewActivityCursorState,
|
parseNewActivityCursorState,
|
||||||
serializeNewActivityCursorState,
|
serializeNewActivityCursorState,
|
||||||
} from "../libs/newActivity";
|
} from "../libs/newActivity";
|
||||||
|
import { hasServerProfileText } from "../libs/partnerServer";
|
||||||
import {
|
import {
|
||||||
onboardingSettingsUpdate,
|
onboardingSettingsUpdate,
|
||||||
parseOnboardingState,
|
parseOnboardingState,
|
||||||
@@ -778,20 +779,12 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async hasServerProfileText(): Promise<boolean> {
|
private async hasServerProfileText(): Promise<boolean> {
|
||||||
try {
|
const settings = await this.$accountSettings();
|
||||||
const settings = await this.$accountSettings();
|
return hasServerProfileText(
|
||||||
const partnerApiServer =
|
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER,
|
||||||
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER;
|
await getHeaders(this.activeDid),
|
||||||
const response = await this.axios.get(
|
this.activeDid,
|
||||||
`${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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -80,14 +80,79 @@
|
|||||||
back later as more people join.
|
back later as more people join.
|
||||||
</p>
|
</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
|
<div
|
||||||
v-for="group in groups"
|
v-for="group in visibleGroups"
|
||||||
:key="group.key"
|
:key="group.key"
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
:data-testId="'interestMatches-' + group.key"
|
:data-testId="'interestMatches-' + group.key"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold mb-1">{{ group.title }}</h2>
|
<button
|
||||||
<ul class="border-t border-slate-300">
|
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
|
<li
|
||||||
v-for="match in group.matches"
|
v-for="match in group.matches"
|
||||||
:key="match.subjectType + ':' + match.subjectId"
|
:key="match.subjectType + ':' + match.subjectId"
|
||||||
@@ -114,6 +179,7 @@
|
|||||||
{{ titleFor(match) }}
|
{{ titleFor(match) }}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
|
v-if="similarityLabel(match.similarity)"
|
||||||
class="text-xs uppercase px-1.5 py-0.5 rounded"
|
class="text-xs uppercase px-1.5 py-0.5 rounded"
|
||||||
:class="labelClasses(match.similarity)"
|
:class="labelClasses(match.similarity)"
|
||||||
>{{ similarityLabel(match.similarity) }}</span
|
>{{ similarityLabel(match.similarity) }}</span
|
||||||
@@ -144,14 +210,19 @@ import InterestsPromptDialog from "../components/InterestsPromptDialog.vue";
|
|||||||
import NextStepBanner from "../components/NextStepBanner.vue";
|
import NextStepBanner from "../components/NextStepBanner.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
|
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||||
import TopMessage from "../components/TopMessage.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 { UNNAMED_PROJECT } from "../constants/entities";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { didInfo, getHeaders } from "../libs/endorserServer";
|
import { didInfo, getHeaders } from "../libs/endorserServer";
|
||||||
import {
|
import {
|
||||||
boundingBoxAround,
|
boundingBoxAround,
|
||||||
hasPoint,
|
hasPoint,
|
||||||
|
isStrongMatch,
|
||||||
|
noStrongFollowUp,
|
||||||
|
NoStrongFollowUp,
|
||||||
|
offersAlerts,
|
||||||
onboardingSettingsUpdate,
|
onboardingSettingsUpdate,
|
||||||
parseOnboardingState,
|
parseOnboardingState,
|
||||||
SEARCH_RADIUS_KM,
|
SEARCH_RADIUS_KM,
|
||||||
@@ -160,6 +231,7 @@ import {
|
|||||||
} from "../libs/onboarding";
|
} from "../libs/onboarding";
|
||||||
import {
|
import {
|
||||||
fetchSimilar,
|
fetchSimilar,
|
||||||
|
hasServerProfileText,
|
||||||
SimilarMatch,
|
SimilarMatch,
|
||||||
SimilarResult,
|
SimilarResult,
|
||||||
} from "../libs/partnerServer";
|
} from "../libs/partnerServer";
|
||||||
@@ -171,6 +243,40 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
|||||||
/** Fewer nearby results than this, and a second search fills "Farther away". */
|
/** Fewer nearby results than this, and a second search fills "Farther away". */
|
||||||
const FEW_NEARBY = 5;
|
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({
|
@Component({
|
||||||
name: "InterestMatchesView",
|
name: "InterestMatchesView",
|
||||||
components: {
|
components: {
|
||||||
@@ -178,6 +284,7 @@ const FEW_NEARBY = 5;
|
|||||||
NextStepBanner,
|
NextStepBanner,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
|
RegistrationNotice,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
@@ -186,7 +293,10 @@ export default class InterestMatchesView extends Vue {
|
|||||||
$route!: RouteLocationNormalizedLoaded;
|
$route!: RouteLocationNormalizedLoaded;
|
||||||
$router!: Router;
|
$router!: Router;
|
||||||
|
|
||||||
|
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||||
|
REGISTER_MESSAGE = REGISTER_MESSAGE;
|
||||||
SEARCH_RADIUS_KM = SEARCH_RADIUS_KM;
|
SEARCH_RADIUS_KM = SEARCH_RADIUS_KM;
|
||||||
|
WEAK_TOGGLE_KEY = WEAK_TOGGLE_KEY;
|
||||||
similarityLabel = similarityLabel;
|
similarityLabel = similarityLabel;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
@@ -194,21 +304,70 @@ export default class InterestMatchesView extends Vue {
|
|||||||
allMyDids: string[] = [];
|
allMyDids: string[] = [];
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
farther: SimilarMatch[] = [];
|
farther: SimilarMatch[] = [];
|
||||||
|
/** Set after a search with no strong match; empty until then */
|
||||||
|
followUp: NoStrongFollowUp | "" = "";
|
||||||
|
givenName = "";
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
/** Known only once followUp is set */
|
||||||
|
needsAlert = false;
|
||||||
|
needsProfile = false;
|
||||||
nearby: SimilarMatch[] = [];
|
nearby: SimilarMatch[] = [];
|
||||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
point: { lat: number; lon: number } | null = null;
|
point: { lat: number; lon: number } | null = null;
|
||||||
searched = false;
|
searched = false;
|
||||||
|
showWeak = false;
|
||||||
text = "";
|
text = "";
|
||||||
|
|
||||||
get groups(): Array<{ key: string; title: string; matches: SimilarMatch[] }> {
|
get strongGroups(): MatchGroup[] {
|
||||||
const people = this.nearby.filter((m) => m.subjectType === "profile");
|
const strong = (m: SimilarMatch) => isStrongMatch(m.similarity);
|
||||||
const projects = this.nearby.filter((m) => m.subjectType === "plan");
|
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 [
|
return [
|
||||||
{ key: "people", title: "People", matches: people },
|
...this.strongGroups,
|
||||||
{ key: "projects", title: "Projects", matches: projects },
|
toggle,
|
||||||
{ key: "farther", title: "Farther away", matches: this.farther },
|
...(this.showWeak ? this.weakGroups : []),
|
||||||
].filter((group) => group.matches.length > 0);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async mounted(): Promise<void> {
|
async mounted(): Promise<void> {
|
||||||
@@ -216,6 +375,7 @@ export default class InterestMatchesView extends Vue {
|
|||||||
const activeIdentity = await this.$getActiveIdentity();
|
const activeIdentity = await this.$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
|
this.givenName = settings.firstName || "";
|
||||||
this.allContacts = await this.$getAllContacts();
|
this.allContacts = await this.$getAllContacts();
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
@@ -267,6 +427,7 @@ export default class InterestMatchesView extends Vue {
|
|||||||
}
|
}
|
||||||
this.nearby = first.matches;
|
this.nearby = first.matches;
|
||||||
this.farther = [];
|
this.farther = [];
|
||||||
|
this.showWeak = false;
|
||||||
if (box && first.matches.length < FEW_NEARBY) {
|
if (box && first.matches.length < FEW_NEARBY) {
|
||||||
// the server caches the query's vector, so this costs no inference
|
// the server caches the query's vector, so this costs no inference
|
||||||
const wide = await fetchSimilar(this.partnerApiServer, headers, {
|
const wide = await fetchSimilar(this.partnerApiServer, headers, {
|
||||||
@@ -285,6 +446,7 @@ export default class InterestMatchesView extends Vue {
|
|||||||
}
|
}
|
||||||
this.searched = true;
|
this.searched = true;
|
||||||
await this.recordMatchesShown();
|
await this.recordMatchesShown();
|
||||||
|
await this.loadFollowUp(headers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[InterestMatchesView] Search failed:", error);
|
logger.error("[InterestMatchesView] Search failed:", error);
|
||||||
this.errorMessage =
|
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 {
|
private showError(result: Exclude<SimilarResult, { kind: "ok" }>): void {
|
||||||
logger.warn("[InterestMatchesView] Search not answered:", result);
|
logger.warn("[InterestMatchesView] Search not answered:", result);
|
||||||
this.errorMessage = result.message;
|
this.errorMessage = result.message;
|
||||||
@@ -327,7 +516,7 @@ export default class InterestMatchesView extends Vue {
|
|||||||
switch (similarityLabel(similarity)) {
|
switch (similarityLabel(similarity)) {
|
||||||
case "strong":
|
case "strong":
|
||||||
return "bg-green-100 text-green-800";
|
return "bg-green-100 text-green-800";
|
||||||
case "good":
|
case "weak":
|
||||||
return "bg-blue-100 text-blue-800";
|
return "bg-blue-100 text-blue-800";
|
||||||
default:
|
default:
|
||||||
return "bg-slate-100 text-slate-600";
|
return "bg-slate-100 text-slate-600";
|
||||||
|
|||||||
Reference in New Issue
Block a user