Files
crowd-funder-for-time-pwa/src/views/InterestMatchesView.vue
T

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&hellip;
</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>