556 lines
18 KiB
Vue
556 lines
18 KiB
Vue
<!--
|
|
InterestMatchesView.vue - people and projects closest in meaning to the
|
|
person's interests (GET /api/partner/similar), nearest first when they gave
|
|
a point. Reads the interests kept in settings.onboardingState, or `text`,
|
|
`lat`, `lon` from the route query. See
|
|
doc/CHANGES/PLAN-onboarding-by-interests.md.
|
|
-->
|
|
<template>
|
|
<QuickNav selected="Discover" />
|
|
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<TopMessage />
|
|
<NextStepBanner />
|
|
|
|
<div class="flex gap-4 items-center mb-4">
|
|
<a
|
|
class="text-lg text-center px-2 py-1 cursor-pointer"
|
|
aria-label="Back"
|
|
@click="$router.back()"
|
|
>
|
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
|
</a>
|
|
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
|
|
Matches for Your Interests
|
|
</h1>
|
|
</div>
|
|
|
|
<div
|
|
v-if="text"
|
|
class="bg-slate-100 rounded-md px-4 py-3 mb-4 text-sm text-slate-700"
|
|
>
|
|
<div class="italic">"{{ text }}"</div>
|
|
<div v-if="point" class="text-slate-500 mt-1">
|
|
<font-awesome icon="location-dot" class="fa-fw" />
|
|
Within about {{ SEARCH_RADIUS_KM }} km of {{ point.lat.toFixed(2) }},
|
|
{{ point.lon.toFixed(2) }}
|
|
</div>
|
|
<span
|
|
class="text-blue-500 cursor-pointer"
|
|
data-testId="changeInterests"
|
|
@click="openPrompt()"
|
|
>Change</span
|
|
>
|
|
</div>
|
|
<div v-else class="text-center mt-8">
|
|
<p class="mb-4">Tell us what you're interested in, and we'll search.</p>
|
|
<button
|
|
class="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"
|
|
@click="openPrompt()"
|
|
>
|
|
Enter your interests
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="text-center text-slate-500 mt-8">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
|
Finding matches…
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="errorMessage"
|
|
class="text-center mt-8"
|
|
data-testId="interestMatchesError"
|
|
>
|
|
<p class="mb-4 text-slate-700">{{ errorMessage }}</p>
|
|
<button
|
|
class="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"
|
|
@click="search()"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
|
|
<template v-else-if="searched">
|
|
<p
|
|
v-if="nearby.length === 0 && farther.length === 0"
|
|
class="text-center text-slate-500 mt-8"
|
|
>
|
|
Nothing matches yet. Try describing your interests another way, or check
|
|
back later as more people join.
|
|
</p>
|
|
|
|
<p
|
|
v-else-if="strongGroups.length === 0"
|
|
class="text-center text-slate-500 mt-8"
|
|
data-testId="interestMatchesNoStrong"
|
|
>
|
|
No strong matches yet. Try describing your interests another way, or
|
|
check back later as more people join.
|
|
</p>
|
|
|
|
<!-- With no strong match, how they'd hear about one later -->
|
|
<template v-if="strongGroups.length === 0 && followUp">
|
|
<RegistrationNotice
|
|
v-if="followUp === 'register'"
|
|
:passkeys-enabled="PASSKEYS_ENABLED"
|
|
:given-name="givenName"
|
|
:message="REGISTER_MESSAGE"
|
|
/>
|
|
<p
|
|
v-else-if="followUp === 'alertsOn'"
|
|
class="text-center text-slate-600 mt-4"
|
|
data-testId="interestMatchesAlertsOn"
|
|
>
|
|
Your daily alert is on, so we'll let you know when a strong match
|
|
joins or posts.
|
|
</p>
|
|
<div
|
|
v-else-if="followUp === 'setUp'"
|
|
class="text-center mt-4"
|
|
data-testId="interestMatchesSetUp"
|
|
>
|
|
<p class="text-slate-600 mb-2">
|
|
{{ setUpMessage }}
|
|
</p>
|
|
<router-link
|
|
:to="{ name: 'account', query: { nextStep: '1' } }"
|
|
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
|
data-testId="interestMatchesSetUpLink"
|
|
>
|
|
{{ setUpLinkText }}
|
|
</router-link>
|
|
</div>
|
|
<p
|
|
v-else-if="followUp === 'profileOnly'"
|
|
class="text-center text-slate-600 mt-4"
|
|
data-testId="interestMatchesProfileOnly"
|
|
>
|
|
Your saved profile lets people with interests like yours find you. To
|
|
be alerted about new matches, use the Android or iPhone app.
|
|
</p>
|
|
</template>
|
|
|
|
<div
|
|
v-for="group in visibleGroups"
|
|
:key="group.key"
|
|
class="mt-6"
|
|
:data-testId="'interestMatches-' + group.key"
|
|
>
|
|
<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"
|
|
class="border-b border-slate-300"
|
|
>
|
|
<a
|
|
class="block py-3 flex gap-4 cursor-pointer"
|
|
@click="onClickMatch(match)"
|
|
>
|
|
<div v-if="match.subjectType === 'plan'">
|
|
<ProjectIcon
|
|
:entity-id="match.record.handleId"
|
|
:icon-size="40"
|
|
:image-url="match.record.image"
|
|
class="block border border-slate-300 rounded-md max-h-10 max-w-10"
|
|
/>
|
|
</div>
|
|
<div v-else class="text-slate-400 text-2xl w-10 text-center">
|
|
<font-awesome icon="user" class="fa-fw" />
|
|
</div>
|
|
<div class="grow overflow-hidden">
|
|
<div class="flex items-center gap-2">
|
|
<h3 class="text-base font-semibold truncate grow">
|
|
{{ 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
|
|
>
|
|
</div>
|
|
<p class="text-sm text-slate-600 line-clamp-2">
|
|
{{ match.record.description }}
|
|
</p>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
|
|
<InterestsPromptDialog
|
|
ref="interestsPrompt"
|
|
@find-matches="onNewInterests"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
|
|
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, 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,
|
|
similarityLabel,
|
|
withMatchesShown,
|
|
} from "../libs/onboarding";
|
|
import {
|
|
fetchSimilar,
|
|
hasServerProfileText,
|
|
SimilarMatch,
|
|
SimilarResult,
|
|
} from "../libs/partnerServer";
|
|
import { retrieveAccountDids } from "../libs/util";
|
|
import { refreshNudge } from "../utils/onboardingNudge";
|
|
import { logger } from "../utils/logger";
|
|
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: {
|
|
InterestsPromptDialog,
|
|
NextStepBanner,
|
|
ProjectIcon,
|
|
QuickNav,
|
|
RegistrationNotice,
|
|
TopMessage,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
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 = "";
|
|
allContacts: Contact[] = [];
|
|
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 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 [
|
|
...this.strongGroups,
|
|
toggle,
|
|
...(this.showWeak ? this.weakGroups : []),
|
|
];
|
|
}
|
|
|
|
async mounted(): Promise<void> {
|
|
const settings = await this.$accountSettings();
|
|
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();
|
|
|
|
const queryText = this.$route.query["text"]?.toString() || "";
|
|
if (queryText) {
|
|
this.text = queryText;
|
|
const lat = Number(this.$route.query["lat"]);
|
|
const lon = Number(this.$route.query["lon"]);
|
|
this.point =
|
|
this.$route.query["lat"] != null &&
|
|
this.$route.query["lon"] != null &&
|
|
Number.isFinite(lat) &&
|
|
Number.isFinite(lon)
|
|
? { lat, lon }
|
|
: null;
|
|
} else {
|
|
this.loadFromState(settings.onboardingState);
|
|
}
|
|
|
|
if (this.text) {
|
|
await this.search();
|
|
}
|
|
}
|
|
|
|
private loadFromState(onboardingState: string | undefined): void {
|
|
const state = parseOnboardingState(onboardingState);
|
|
this.text = state.interests || "";
|
|
this.point = hasPoint(state) ? { lat: state.lat, lon: state.lon } : null;
|
|
}
|
|
|
|
async search(): Promise<void> {
|
|
if (!this.text) return;
|
|
this.isLoading = true;
|
|
this.errorMessage = "";
|
|
try {
|
|
const headers = await getHeaders(this.activeDid);
|
|
const box = this.point
|
|
? boundingBoxAround(this.point.lat, this.point.lon)
|
|
: null;
|
|
const first = await fetchSimilar(this.partnerApiServer, headers, {
|
|
text: this.text,
|
|
to: "both",
|
|
limit: 50,
|
|
box,
|
|
});
|
|
if (first.kind !== "ok") {
|
|
this.showError(first);
|
|
return;
|
|
}
|
|
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, {
|
|
text: this.text,
|
|
to: "both",
|
|
limit: 50,
|
|
});
|
|
if (wide.kind === "ok") {
|
|
const seen = new Set(
|
|
first.matches.map((m) => m.subjectType + ":" + m.subjectId),
|
|
);
|
|
this.farther = wide.matches.filter(
|
|
(m) => !seen.has(m.subjectType + ":" + m.subjectId),
|
|
);
|
|
}
|
|
}
|
|
this.searched = true;
|
|
await this.recordMatchesShown();
|
|
await this.loadFollowUp(headers);
|
|
} catch (error) {
|
|
logger.error("[InterestMatchesView] Search failed:", error);
|
|
this.errorMessage =
|
|
"Something went wrong while searching. Please try again.";
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** The first results seen turn on the nudge (stage `matches` → `save`). */
|
|
private async recordMatchesShown(): Promise<void> {
|
|
const settings = await this.$accountSettings();
|
|
const state = parseOnboardingState(settings.onboardingState);
|
|
const next = withMatchesShown(state, new Date());
|
|
if (next !== state) {
|
|
const update = onboardingSettingsUpdate(next);
|
|
await this.$saveSettings(update);
|
|
refreshNudge({ ...settings, ...update });
|
|
}
|
|
}
|
|
|
|
titleFor(match: SimilarMatch): string {
|
|
if (match.subjectType === "plan") {
|
|
return match.record.name || UNNAMED_PROJECT;
|
|
}
|
|
return didInfo(
|
|
match.record.issuerDid,
|
|
this.activeDid,
|
|
this.allMyDids,
|
|
this.allContacts,
|
|
);
|
|
}
|
|
|
|
labelClasses(similarity: number): string {
|
|
switch (similarityLabel(similarity)) {
|
|
case "strong":
|
|
return "bg-green-100 text-green-800";
|
|
case "weak":
|
|
return "bg-blue-100 text-blue-800";
|
|
default:
|
|
return "bg-slate-100 text-slate-600";
|
|
}
|
|
}
|
|
|
|
onClickMatch(match: SimilarMatch): void {
|
|
if (match.subjectType === "plan") {
|
|
this.$router.push({
|
|
path: "/project/" + encodeURIComponent(match.record.handleId),
|
|
});
|
|
} else {
|
|
this.$router.push({
|
|
path: "/user-profile/" + encodeURIComponent(match.subjectId),
|
|
});
|
|
}
|
|
}
|
|
|
|
openPrompt(): void {
|
|
(this.$refs.interestsPrompt as InterestsPromptDialog).open({
|
|
fromHelp: true,
|
|
});
|
|
}
|
|
|
|
/** New words from the prompt, which already kept them in settings. */
|
|
async onNewInterests(): Promise<void> {
|
|
const settings = await this.$accountSettings();
|
|
this.loadFromState(settings.onboardingState);
|
|
if (Object.keys(this.$route.query).length > 0) {
|
|
await this.$router.replace({ name: "interest-matches" });
|
|
}
|
|
this.searched = false;
|
|
await this.search();
|
|
}
|
|
}
|
|
</script>
|