add the nearest-neighbor feature to the claim screen
This commit is contained in:
@@ -398,7 +398,165 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
Network Connections section: shows nearest neighbors in the registration
|
||||
graph for all DIDs in this claim. The same conventions and styling are used
|
||||
in UserProfileView.vue for user-profile nearest neighbors. Keep changes in sync.
|
||||
-->
|
||||
<div v-if="activeDid && hasVisibleNeighbors" class="mt-8">
|
||||
<h2 class="text-lg font-semibold mb-3">
|
||||
Network Connections
|
||||
<button
|
||||
title="What is this?"
|
||||
class="ml-1 align-middle"
|
||||
@click="showNeighborsInfo = true"
|
||||
>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-base text-blue-500 cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<!-- Info modal for network connections explanation -->
|
||||
<div
|
||||
v-if="showNeighborsInfo"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
|
||||
@click.self="showNeighborsInfo = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
|
||||
<p class="text-sm text-slate-700">
|
||||
This section shows
|
||||
{{
|
||||
Object.values(claimNeighbors).flat().length === 1
|
||||
? "a contact that is"
|
||||
: "contacts that are"
|
||||
}}
|
||||
nearer to the people involved in this activity. If you want more
|
||||
information, reach out to one of them and ask for an introduction.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
|
||||
@click="showNeighborsInfo = false"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingNeighbors">
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin-pulse text-2xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="neighborsError"
|
||||
class="bg-red-50 border border-red-300 rounded-md p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<font-awesome
|
||||
icon="exclamation-triangle"
|
||||
class="text-red-500 mt-0.5"
|
||||
/>
|
||||
<p class="text-red-700">{{ neighborsError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="Object.keys(claimNeighbors).length > 0">
|
||||
<div v-for="(neighbors, did) in claimNeighbors" :key="did" class="mb-4">
|
||||
<h3
|
||||
v-if="Object.keys(claimNeighbors).length > 1"
|
||||
class="text-sm font-medium text-slate-600 mb-1"
|
||||
>
|
||||
Near {{ didInfo(did as string) }}:
|
||||
</h3>
|
||||
<!-- DID has no linked neighbors on this server -->
|
||||
<p
|
||||
v-if="neighbors.length === 0"
|
||||
class="text-sm text-slate-500 italic"
|
||||
>
|
||||
Nobody on this server is linked to {{ didInfo(did as string) }}. The
|
||||
data may be a mistake, or a test, or a reference to someone on a
|
||||
different system. Anyway, we have no way to contact them.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="neighbor in neighbors"
|
||||
:key="neighbor.did"
|
||||
class="bg-slate-50 border border-slate-300 rounded-md"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 p-3">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<button
|
||||
title="Copy claim link and expand"
|
||||
class="text-blue-600 flex-shrink-0"
|
||||
@click="onNeighborExpandClick(neighbor.did)"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="
|
||||
expandedNeighborDid === neighbor.did
|
||||
? 'chevron-down'
|
||||
: 'chevron-right'
|
||||
"
|
||||
class="text-sm"
|
||||
/>
|
||||
{{ getNeighborDisplayName(neighbor.did) }}
|
||||
</button>
|
||||
<span :class="getRelationBadgeClass(neighbor.relation)">
|
||||
{{ getRelationLabel(neighbor.relation) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expandedNeighborDid === neighbor.did"
|
||||
class="border-t border-slate-300 p-3 bg-white"
|
||||
>
|
||||
<router-link
|
||||
:to="{ path: '/did/' + encodeURIComponent(neighbor.did) }"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium underline"
|
||||
>
|
||||
Go to contact info
|
||||
</router-link>
|
||||
and send them the claim link from your clipboard. Ask them for
|
||||
an introduction.
|
||||
<div
|
||||
v-if="neighborIsNotInContacts(neighbor.did)"
|
||||
class="flex flex-col gap-1 mt-2"
|
||||
>
|
||||
<p class="text-xs text-slate-600">
|
||||
This person is connected to you, but they are not in this
|
||||
device's contacts. Copy this DID link and check on another
|
||||
device or check with different people.
|
||||
</p>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="text-xs truncate text-slate-600 min-w-0">
|
||||
{{ neighbor.did }}
|
||||
</span>
|
||||
<button
|
||||
title="Copy DID Link"
|
||||
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
|
||||
@click.prevent="
|
||||
copyTextToClipboard(
|
||||
'DID link',
|
||||
`${APP_SERVER}/deep-link/did/${encodeURIComponent(neighbor.did)}`,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="copy" class="text-sm" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
|
||||
-->
|
||||
<h2
|
||||
@@ -631,12 +789,19 @@ export default class ClaimView extends Vue {
|
||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||
providersForGive: ProviderInfo[] = [];
|
||||
showIdCopy = false;
|
||||
showNeighborsInfo = false;
|
||||
showVeriClaimDump = false;
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
||||
|
||||
// Network Connections state (same pattern as UserProfileView.vue)
|
||||
claimNeighbors: Record<string, Array<{ did: string; relation: string }>> = {};
|
||||
expandedNeighborDid: string | null = null;
|
||||
loadingNeighbors = false;
|
||||
neighborsError = "";
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
@@ -745,6 +910,16 @@ export default class ClaimView extends Vue {
|
||||
return (claim as { image?: string })?.image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Network Connections section should be shown.
|
||||
* Hidden if the only DIDs in claimNeighbors are the active user,
|
||||
* or if there are no entries at all (after filtering).
|
||||
*/
|
||||
get hasVisibleNeighbors(): boolean {
|
||||
const keys = Object.keys(this.claimNeighbors);
|
||||
return keys.length > 0 || this.loadingNeighbors;
|
||||
}
|
||||
|
||||
resetThisValues() {
|
||||
this.confirmerIdList = [];
|
||||
this.confsVisibleErrorMessage = "";
|
||||
@@ -759,6 +934,10 @@ export default class ClaimView extends Vue {
|
||||
this.isEditedGlobalId = false;
|
||||
this.numConfsNotVisible = 0;
|
||||
this.providersForGive = [];
|
||||
this.claimNeighbors = {};
|
||||
this.expandedNeighborDid = null;
|
||||
this.loadingNeighbors = false;
|
||||
this.neighborsError = "";
|
||||
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
this.veriClaimDump = "";
|
||||
this.veriClaimDidsVisible = {};
|
||||
@@ -825,6 +1004,7 @@ export default class ClaimView extends Vue {
|
||||
const claimId = this.$route.params.id as string;
|
||||
if (claimId) {
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
await this.loadClaimNeighbors();
|
||||
} else {
|
||||
this.notify.error("No claim ID was provided.");
|
||||
}
|
||||
@@ -1000,6 +1180,125 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads nearest neighbors for all DIDs in this claim via the
|
||||
* endorser-ch claimNearestNeighbors endpoint.
|
||||
* Same display conventions as UserProfileView.vue's loadNeighbors.
|
||||
*/
|
||||
async loadClaimNeighbors() {
|
||||
if (!this.veriClaim.id) return;
|
||||
|
||||
this.loadingNeighbors = true;
|
||||
this.neighborsError = "";
|
||||
try {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/claim/claimNearestNeighbors/" +
|
||||
encodeURIComponent(this.veriClaim.id as string);
|
||||
const headers = await serverUtil.getHeaders(this.activeDid);
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
const raw = resp.data.data || {};
|
||||
// Filter out the current user's own DID entry — their neighbors
|
||||
// aren't useful here since "You" is already known.
|
||||
const filtered: Record<
|
||||
string,
|
||||
Array<{ did: string; relation: string }>
|
||||
> = {};
|
||||
for (const [did, neighbors] of Object.entries(raw)) {
|
||||
if (did === this.activeDid) continue;
|
||||
filtered[did] = neighbors as Array<{
|
||||
did: string;
|
||||
relation: string;
|
||||
}>;
|
||||
}
|
||||
this.claimNeighbors = filtered;
|
||||
} else {
|
||||
this.claimNeighbors = {};
|
||||
this.neighborsError = "Failed to load network connections.";
|
||||
}
|
||||
} catch (error) {
|
||||
await this.$logError(
|
||||
"Error loading claim neighbors: " + JSON.stringify(error),
|
||||
);
|
||||
this.claimNeighbors = {};
|
||||
this.neighborsError =
|
||||
"An error occurred while loading network connections.";
|
||||
} finally {
|
||||
this.loadingNeighbors = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for a neighbor DID (same as UserProfileView.vue)
|
||||
*/
|
||||
getNeighborDisplayName(did: string): string {
|
||||
return serverUtil.didInfo(
|
||||
did,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
neighborIsNotInContacts(did: string): boolean {
|
||||
return !this.allContacts.some((contact) => contact.did === did);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets human-readable label for relation type (same as UserProfileView.vue)
|
||||
*/
|
||||
getRelationLabel(relation: string): string {
|
||||
switch (relation) {
|
||||
case "REGISTERED_BY_YOU":
|
||||
return "Registered by You";
|
||||
case "REGISTERED_YOU":
|
||||
return "Registered You";
|
||||
case "TARGET":
|
||||
return "Yourself";
|
||||
default:
|
||||
return relation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets CSS classes for relation badge styling (same as UserProfileView.vue)
|
||||
*/
|
||||
getRelationBadgeClass(relation: string): string {
|
||||
const baseClasses =
|
||||
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
|
||||
switch (relation) {
|
||||
case "REGISTERED_BY_YOU":
|
||||
return `${baseClasses} bg-blue-100 text-blue-700`;
|
||||
case "REGISTERED_YOU":
|
||||
return `${baseClasses} bg-green-100 text-green-700`;
|
||||
case "TARGET":
|
||||
return `${baseClasses} bg-purple-100 text-purple-700`;
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-700`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking expand on a neighbor - copies claim link and toggles
|
||||
*/
|
||||
async onNeighborExpandClick(did: string) {
|
||||
if (this.expandedNeighborDid === did) {
|
||||
this.expandedNeighborDid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyToClipboard(this.windowDeepLink);
|
||||
this.notify.copied("Claim link");
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying claim link: ${error}`, true);
|
||||
this.notify.error("Failed to copy claim link.");
|
||||
}
|
||||
|
||||
this.expandedNeighborDid = did;
|
||||
}
|
||||
|
||||
async showFullClaim(claimId: string) {
|
||||
const url =
|
||||
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||
@@ -1110,6 +1409,7 @@ export default class ClaimView extends Vue {
|
||||
(this.$router as Router).push(route).then(async () => {
|
||||
this.resetThisValues();
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
await this.loadClaimNeighbors();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nearest Neighbors Section -->
|
||||
<!--
|
||||
Network Connections section: shows nearest neighbors in the registration
|
||||
graph for this user profile. The same conventions and styling are used in
|
||||
ClaimView.vue for claim-level nearest neighbors. Keep changes in sync.
|
||||
-->
|
||||
<div
|
||||
v-if="
|
||||
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
|
||||
@@ -63,7 +67,46 @@
|
||||
"
|
||||
class="mt-6"
|
||||
>
|
||||
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
|
||||
<h2 class="text-lg font-semibold mb-3">
|
||||
Network Connections
|
||||
<button
|
||||
title="What is this?"
|
||||
class="ml-1 align-middle"
|
||||
@click="showNeighborsInfo = true"
|
||||
>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-base text-blue-500 cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<!-- Info modal for network connections explanation -->
|
||||
<div
|
||||
v-if="showNeighborsInfo"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
|
||||
@click.self="showNeighborsInfo = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
|
||||
<p class="text-sm text-slate-700">
|
||||
This section shows
|
||||
{{
|
||||
neighbors.length === 1
|
||||
? "a contact that is"
|
||||
: "contacts that are"
|
||||
}}
|
||||
nearer to this person. If you want more information, reach out to
|
||||
one of them and ask for an introduction.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
|
||||
@click="showNeighborsInfo = false"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingNeighbors">
|
||||
<div class="flex justify-center items-center py-8">
|
||||
@@ -124,8 +167,8 @@
|
||||
>
|
||||
Go to contact info
|
||||
</router-link>
|
||||
and send them the link in your clipboard and ask for an
|
||||
introduction to this person.
|
||||
and send them the profile link from your clipboard. Ask them to
|
||||
introduce you to this person.
|
||||
<div
|
||||
v-if="
|
||||
getNeighborDisplayName(neighbor.did) === '' ||
|
||||
@@ -269,6 +312,7 @@ export default class UserProfileView extends Vue {
|
||||
neighborsError = "";
|
||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||
profile: UserProfile | null = null;
|
||||
showNeighborsInfo = false;
|
||||
|
||||
// make this function available to the Vue template
|
||||
didInfo = didInfo;
|
||||
|
||||
Reference in New Issue
Block a user