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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+5
-12
@@ -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 hasServerProfileText(
|
||||
settings.partnerApiServer || DEFAULT_PARTNER_API_SERVER,
|
||||
await getHeaders(this.activeDid),
|
||||
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.
|
||||
</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-for="group in groups"
|
||||
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 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";
|
||||
|
||||
Reference in New Issue
Block a user