allow meeting organizer to see info about embeddings, and add match to pages

This commit is contained in:
2026-02-08 13:45:27 -07:00
parent e38b752b27
commit 1c3d449c85
12 changed files with 355 additions and 77 deletions

View File

@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
Medium Components (200-500 lines): 12 components (30%)
├── GiftDetailsStep.vue (450 lines)

View File

@@ -116,7 +116,7 @@ echo "=============================="
# Analyze critical files identified in the assessment
critical_files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -0,0 +1,245 @@
<template>
<div class="group-onboard-match-display">
<!-- Loading -->
<div
v-if="isLoading"
class="flex items-center justify-center gap-2 py-6 text-slate-600"
>
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Error -->
<div
v-else-if="errorMessage"
class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700"
>
Inform the organizer that there was an error. {{ errorMessage }}
</div>
<!-- Matched person -->
<div
v-else-if="matchedPerson"
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"
>
<h2 class="mb-3 font-bold text-slate-700">Your Current Match</h2>
<div class="flex items-start gap-3">
<EntityIcon
:contact="matchedPersonContact"
class="!size-14 shrink-0 overflow-hidden rounded-full border border-slate-300 bg-white"
/>
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-900">
{{ matchedPerson.name || "(No name)" }}
</p>
<p class="mt-0.5 truncate text-xs text-slate-500">
{{ matchedPerson.did }}
</p>
<p
v-if="matchedPerson.description"
class="mt-2 line-clamp-3 text-sm text-slate-600"
>
{{ matchedPerson.description }}
</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-facing-decorator";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import EntityIcon from "./EntityIcon.vue";
import { Contact } from "@/db/tables/contacts";
import { AxiosErrorResponse } from "@/interfaces";
/** Participant from GET /api/partner/groupOnboardMatch pair */
interface MatchPairParticipant {
issuerDid: string;
content: string;
description?: string;
decryptedContentObject?: {
name: string;
did: string;
isRegistered: boolean;
} | null;
}
interface MatchPair {
pairNumber: number;
similarity: number;
participants: MatchPairParticipant[];
}
/** Normalized matched person for display */
interface MatchedPersonData {
name: string;
did: string;
isRegistered: boolean;
description?: string;
}
@Component({
components: {
EntityIcon,
},
mixins: [PlatformServiceMixin],
})
export default class GroupOnboardMatchDisplay extends Vue {
@Prop({ required: true })
meetingPassword!: string;
/** When provided, used to determine this person's match instead of calling groupOnboardMatch */
@Prop()
matchPairs?: MatchPair[] | null;
activeDid = "";
apiServer = "";
errorMessage = "";
isLoading = true;
matchedPerson: MatchedPersonData | null = null;
/** Pair that contains the current user (for similarity display if needed) */
myPair: MatchPair | null = null;
/** Contact-like object for EntityIcon from matched person */
get matchedPersonContact(): Contact | undefined {
if (!this.matchedPerson) return undefined;
return {
did: this.matchedPerson.did,
name: this.matchedPerson.name,
};
}
async created() {
const settings = await this.$accountSettings();
this.apiServer = settings?.apiServer || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity?.activeDid || "";
await this.fetchMatches();
}
@Watch("meetingPassword")
async onPasswordChange() {
if (
this.meetingPassword &&
(this.matchPairs != null || (this.apiServer && this.activeDid))
) {
await this.fetchMatches();
}
}
@Watch("matchPairs")
async onMatchPairsChange() {
if (this.activeDid && this.meetingPassword) {
await this.fetchMatches();
}
}
async fetchMatches(): Promise<void> {
const usePropPairs =
this.matchPairs != null &&
Array.isArray(this.matchPairs) &&
this.matchPairs.length > 0;
const needApi = !usePropPairs;
if (
needApi &&
(!this.meetingPassword?.trim() || !this.apiServer || !this.activeDid)
) {
this.isLoading = false;
this.matchedPerson = null;
this.myPair = null;
this.errorMessage = "";
return;
}
if (usePropPairs && (!this.meetingPassword?.trim() || !this.activeDid)) {
this.isLoading = false;
this.matchedPerson = null;
this.myPair = null;
this.errorMessage = "";
return;
}
this.isLoading = true;
this.errorMessage = "";
this.matchedPerson = null;
this.myPair = null;
try {
let pairs: MatchPair[] | null = null;
if (usePropPairs) {
// Shallow-copy so we can set decryptedContentObject without mutating the prop
pairs = (this.matchPairs ?? []).map((p) => ({
...p,
participants: p.participants.map((part) => ({ ...part })),
}));
} else {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMatch`,
{ headers },
);
pairs = response?.data?.data?.pairs ?? null;
}
if (!Array.isArray(pairs) || pairs.length === 0) {
this.isLoading = false;
return;
}
// Decrypt each participant's content and find the pair containing this user
for (const pair of pairs) {
if (!pair.participants || pair.participants.length !== 2) continue;
for (const participant of pair.participants) {
try {
const decrypted = await decryptMessage(
participant.content,
this.meetingPassword,
);
participant.decryptedContentObject = JSON.parse(decrypted);
} catch {
participant.decryptedContentObject = null;
}
}
const myIndex = pair.participants.findIndex(
(p) => p.issuerDid === this.activeDid,
);
if (myIndex === -1) continue;
this.myPair = pair;
const other = pair.participants[1 - myIndex];
const obj = other.decryptedContentObject;
this.matchedPerson = {
name: obj?.name ?? "",
did: obj?.did ?? other.issuerDid,
isRegistered: !!obj?.isRegistered,
description: other.description,
};
break;
}
this.isLoading = false;
} catch (error) {
this.$logAndConsole(
"Error fetching group onboard match: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"Failed to load your match.";
this.matchedPerson = null;
this.myPair = null;
this.isLoading = false;
}
}
}
</script>

View File

@@ -49,9 +49,9 @@
<div class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list now"
@@ -198,9 +198,9 @@
<div v-if="membersToShow().length > 0" class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list now"
@@ -273,7 +273,7 @@ interface DecryptedMember {
},
mixins: [PlatformServiceMixin],
})
export default class MembersList extends Vue {
export default class MeetingMembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>;

View File

@@ -448,8 +448,8 @@ export const NOTIFY_UNCONFIRMED_HOURS_DYNAMIC = {
};
// Complex modal constants (for raw $notify calls with advanced features)
// MembersList.vue complex modals
// Used in: MembersList.vue (complex modal for adding contacts)
// MeetingMembersList.vue complex modals
// Used in: MeetingMembersList.vue (complex modal for adding contacts)
export const NOTIFY_ADD_CONTACT_FIRST = {
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
@@ -457,7 +457,7 @@ export const NOTIFY_ADD_CONTACT_FIRST = {
noText: "Skip Adding Contact",
};
// Used in: MembersList.vue (complex modal for continuing without adding)
// Used in: MeetingMembersList.vue (complex modal for continuing without adding)
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",

View File

@@ -1,5 +1,7 @@
export interface UserProfile {
description: string;
generateEmbedding?: boolean;
embeddingIsForEmptyString?: boolean;
locLat?: number;
locLon?: number;
locLat2?: number;

View File

@@ -164,7 +164,9 @@
>
{{ userProfileData.description }}
</p>
<p v-else class="text-slate-500 italic">No description.</p>
<p v-else class="text-slate-500 italic">
This person has no profile description or it's not visible to you.
</p>
</div>
</div>
@@ -319,31 +321,54 @@
class="mt-4 pt-4 border-t border-slate-300"
data-testid="generateEmbeddingSection"
>
<label class="block text-sm font-medium text-gray-700 mb-2">
Always generate embedding
</label>
<div class="flex items-center gap-2">
<label class="block text-sm font-medium text-gray-700 mb-2 mt-2">
Always generate embedding
</label>
<button
type="button"
role="switch"
:aria-checked="generateEmbedding"
:disabled="generateEmbeddingSaving || generateEmbeddingLoading"
:aria-checked="
generateEmbedding ?? userProfileData?.generateEmbedding
"
:disabled="generateEmbeddingSaving || userProfileLoading"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="generateEmbedding ? 'bg-blue-600' : 'bg-gray-200'"
:class="
(generateEmbedding ?? userProfileData?.generateEmbedding)
? 'bg-blue-600'
: 'bg-gray-200'
"
@click="toggleGenerateEmbedding"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition"
:class="generateEmbedding ? 'translate-x-5' : 'translate-x-1'"
:class="
(generateEmbedding ?? userProfileData?.generateEmbedding)
? 'translate-x-5'
: 'translate-x-1'
"
/>
</button>
<span class="text-sm text-gray-600">
{{ generateEmbedding ? "On" : "Off" }}
<span v-if="generateEmbeddingLoading" class="ml-1">(loading)</span>
{{
(generateEmbedding ?? userProfileData?.generateEmbedding)
? "On"
: "Off"
}}
<span v-if="userProfileLoading" class="ml-1">(loading…)</span>
<span v-else-if="generateEmbeddingSaving" class="ml-1"
>(saving…)</span
>
</span>
<span class="text-sm text-gray-600">
{{
userProfileData?.embeddingIsForEmptyString == null
? ""
: userProfileData?.embeddingIsForEmptyString
? "- Embedding is for blank description"
: "- Embedding is for non-blank description"
}}
</span>
</div>
</div>
@@ -494,7 +519,7 @@ export default class DIDView extends Vue {
contactLabels: string[] = [];
contactYaml = "";
generateEmbedding: boolean | null = null;
generateEmbedding: boolean | null = null; // used when there is no profile
generateEmbeddingLoading = false;
generateEmbeddingSaving = false;
hitEnd = false;
@@ -509,7 +534,6 @@ export default class DIDView extends Vue {
showUserProfile = false;
userProfileData: UserProfile | null = null;
userProfileError: string | null = null;
userProfileFetched = false;
userProfileLoading = false;
viewingDid?: string;
@@ -543,7 +567,7 @@ export default class DIDView extends Vue {
await this.loadClaimsAbout();
await this.checkIfOwnDID();
if (this.showGeneralAdvanced && this.activeDid) {
await this.loadGenerateEmbeddingState();
await this.loadUserProfile();
}
}
}
@@ -623,48 +647,36 @@ export default class DIDView extends Vue {
this.isMyDid = allAccountDids.includes(this.viewingDid);
}
/**
* Loads partner profile generateEmbedding state for the viewed DID (when showGeneralAdvanced).
*/
private async loadGenerateEmbeddingState() {
if (!this.viewingDid || !this.activeDid) return;
this.generateEmbeddingLoading = true;
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(this.viewingDid)}`;
const response = await this.axios.get(url, { headers });
const data = response.data?.data;
this.generateEmbedding =
data && typeof data.generateEmbedding === "boolean"
? data.generateEmbedding
: false;
} catch {
this.generateEmbedding = false;
} finally {
this.generateEmbeddingLoading = false;
}
}
/**
* Toggles the "always generate embedding" flag for the viewed DID on the partner API.
* Only permissioned (admin) users can change this; API returns 403 otherwise.
*/
async toggleGenerateEmbedding() {
if (
!this.viewingDid ||
!this.activeDid ||
this.generateEmbeddingSaving ||
this.generateEmbeddingLoading
) {
if (!this.viewingDid || !this.activeDid) {
return;
}
const newValue = !this.generateEmbedding;
const newValue = !(this.userProfileData
? this.userProfileData.generateEmbedding
: this.generateEmbedding);
this.generateEmbeddingSaving = true;
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.partnerApiServer}/api/partner/userProfileGenerateEmbedding/${encodeURIComponent(this.viewingDid)}`;
await this.axios.put(url, { generateEmbedding: newValue }, { headers });
this.generateEmbedding = newValue;
if (this.userProfileData) {
this.userProfileData.generateEmbedding = newValue;
this.userProfileData.embeddingIsForEmptyString = newValue; // the server should have generated it or erased it
} else {
this.generateEmbedding = newValue;
if (newValue) {
this.userProfileData = {
description: "",
issuerDid: this.viewingDid,
generateEmbedding: newValue,
embeddingIsForEmptyString: true,
};
}
}
this.notify.success(
newValue
? "Contact tagged to always generate embedding."
@@ -719,7 +731,7 @@ export default class DIDView extends Vue {
*/
toggleUserProfile() {
this.showUserProfile = !this.showUserProfile;
if (this.showUserProfile && !this.userProfileFetched) {
if (this.showUserProfile && !this.userProfileData) {
this.loadUserProfile();
}
}
@@ -730,7 +742,6 @@ export default class DIDView extends Vue {
*/
async loadUserProfile() {
if (!this.viewingDid || !this.activeDid) return;
this.userProfileFetched = true;
this.userProfileLoading = true;
this.userProfileError = null;
this.userProfileData = null;

View File

@@ -40,8 +40,16 @@
</div>
</div>
<!-- Members List -->
<MembersList v-else :password="password" @error="handleError" />
<div v-else>
<MeetingMemberMatch
:match-pairs="matchPairs"
:meeting-password="password || ''"
class="mt-4"
/>
<!-- Members List -->
<MeetingMembersList :password="password" @error="handleError" />
</div>
<!-- Project Link Section -->
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
@@ -67,7 +75,8 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingMembersList from "../components/MeetingMembersList.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { encryptMessage } from "../libs/crypto";
import {
@@ -84,7 +93,8 @@ import { AxiosErrorResponse } from "@/interfaces";
components: {
QuickNav,
TopMessage,
MembersList,
MeetingMemberMatch,
MeetingMembersList,
UserNameDialog,
},
mixins: [PlatformServiceMixin],

View File

@@ -278,6 +278,13 @@
@close="handleDialogClose"
/>
<div v-if="!!matchPairs">
<MeetingMemberMatch
:meeting-password="currentMeeting.password || ''"
class="mt-4"
/>
</div>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -307,20 +314,19 @@
</li>
</ul>
<MembersList
<MeetingMembersList
ref="membersList"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
class="mt-4"
@error="handleMembersError"
/>
</div>
<div class="mt-8 p-4 border rounded-lg bg-white shadow">
<!-- Pairwise matches (organizer only: this page is organizer's meeting) -->
<div class="mt-6 pt-4 border-t border-gray-200">
<h3 class="font-semibold mb-2">Pairs</h3>
<p class="text-sm text-gray-600 mb-3">
Match members by profile similarity
</p>
<div class="border-gray-200">
<h3 class="font-semibold mb-2">Matching Pairs</h3>
<div class="flex flex-wrap gap-2 mb-4">
<button
type="button"
@@ -428,7 +434,8 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
import MeetingMembersList from "../components/MeetingMembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
@@ -473,7 +480,7 @@ interface MeetingSetupInputs {
interface MatchPairParticipant {
issuerDid: string;
content: string;
// there's a similar structure in MembersList.vue with extra Member info
// there's a similar structure in MeetingMembersList.vue with extra Member info
decryptedContentObject: {
name: string;
did: string;
@@ -492,7 +499,8 @@ interface MatchPair {
components: {
QuickNav,
TopMessage,
MembersList,
MeetingMembersList,
MeetingMemberMatch,
MeetingProjectDialog,
ProjectIcon,
},
@@ -1102,7 +1110,6 @@ export default class OnboardMeetingView extends Vue {
);
this.matchPairs = null;
this.previousMatchedPairs = [];
this.notify.success("Matches cleared.", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(
"Error clearing matches: " + errorStringForLog(error),
@@ -1187,20 +1194,20 @@ export default class OnboardMeetingView extends Vue {
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
* Handle dialog open event - stop auto-refresh in MeetingMembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
const membersList = this.$refs.membersList as MeetingMembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
* Handle dialog close event - start auto-refresh in MeetingMembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
const membersList = this.$refs.membersList as MeetingMembersList;
if (membersList) {
membersList.startAutoRefresh();
}

View File

@@ -28,6 +28,9 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
server: {
port: parseInt(process.env.VITE_PORT || "8080"),
fs: { strict: false },
//allowedHosts: ['bab3-68-69-173-46.ngrok-free.app'],
//allowedHosts: ['*'],
// CORS headers disabled to allow images from any domain
// This means SharedArrayBuffer is unavailable, but absurd-sql
// will automatically fall back to IndexedDB mode which still works