add instructions to connect to any user profile (#224)

See https://app.clickup.com/t/86b76734v

Reviewed-on: #224
Co-authored-by: Trent Larson <trent@trentlarson.com>
Co-committed-by: Trent Larson <trent@trentlarson.com>
This commit was merged in pull request #224.
This commit is contained in:
2025-11-19 18:58:49 +00:00
committed by trentlarson
parent e64902321f
commit c84a3b6705
4 changed files with 319 additions and 25 deletions

29
src/constants/contacts.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Constants for contact-related functionality
* Created: 2025-11-16
*/
/**
* Contact method types with user-friendly labels
* Used in: ContactEditView.vue, DIDView.vue
*/
export const CONTACT_METHOD_TYPES = [
{ value: "CELL", label: "Mobile" },
{ value: "EMAIL", label: "Email" },
{ value: "WHATSAPP", label: "WhatsApp" },
] as const;
/**
* Type for contact method type values
*/
export type ContactMethodType = (typeof CONTACT_METHOD_TYPES)[number]["value"];
/**
* Helper function to get label for a contact method type
* @param type - The contact method type value (e.g., "CELL", "EMAIL")
* @returns The user-friendly label or the original type if not found
*/
export function getContactMethodLabel(type: string): string {
const methodType = CONTACT_METHOD_TYPES.find((m) => m.value === type);
return methodType ? methodType.label : type;
}

View File

@@ -85,22 +85,12 @@
class="absolute bg-white border border-gray-300 rounded-md mt-1" class="absolute bg-white border border-gray-300 rounded-md mt-1"
> >
<div <div
v-for="methodType in contactMethodTypes"
:key="methodType.value"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer" class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'CELL')" @click="setMethodType(index, methodType.value)"
> >
CELL {{ methodType.label }}
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'EMAIL')"
>
EMAIL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'WHATSAPP')"
>
WHATSAPP
</div> </div>
</div> </div>
</div> </div>
@@ -157,6 +147,7 @@ import {
} from "../constants/notifications"; } from "../constants/notifications";
import { Contact, ContactMethod } from "../db/tables/contacts"; import { Contact, ContactMethod } from "../db/tables/contacts";
import { AppString } from "../constants/app"; import { AppString } from "../constants/app";
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
/** /**
* Contact Edit View Component * Contact Edit View Component
@@ -224,6 +215,8 @@ export default class ContactEditView extends Vue {
/** App string constants */ /** App string constants */
AppString = AppString; AppString = AppString;
/** Contact method types for dropdown */
contactMethodTypes = CONTACT_METHOD_TYPES;
/** /**
* Component lifecycle hook that initializes the contact edit form * Component lifecycle hook that initializes the contact edit form

View File

@@ -42,6 +42,39 @@
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> <font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link> </router-link>
</h2> </h2>
<!-- Notes -->
<div v-if="contactFromDid.notes" class="mt-3">
<p class="text-sm text-slate-700 whitespace-pre-wrap">
{{ contactFromDid.notes }}
</p>
</div>
<!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-wrap gap-2">
<div
v-for="(method, index) in contactFromDid.contactMethods"
:key="index"
class="inline-flex items-center gap-2 text-sm"
>
<span class="font-semibold text-slate-600"
>{{ getContactMethodLabel(method.type) }}:</span
>
<span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span>
<a
v-if="method.type === 'CELL'"
:href="`sms:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send text message"
>
<font-awesome icon="message" class="text-base" />
</a>
</div>
</div>
</div>
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails"> <button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
Details Details
<font-awesome <font-awesome
@@ -302,6 +335,7 @@ import {
NOTIFY_CONTACT_INVALID_DID, NOTIFY_CONTACT_INVALID_DID,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities"; import { THAT_UNNAMED_PERSON } from "@/constants/entities";
import { getContactMethodLabel } from "@/constants/contacts";
/** /**
* DIDView Component * DIDView Component
@@ -352,6 +386,7 @@ export default class DIDView extends Vue {
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact; didInfoForContact = didInfoForContact;
displayAmount = displayAmount; displayAmount = displayAmount;
getContactMethodLabel = getContactMethodLabel;
/** /**
* Initializes notification helpers * Initializes notification helpers

View File

@@ -54,6 +54,108 @@
</p> </p>
</div> </div>
<!-- Nearest Neighbors Section -->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<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>
<div 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 profile 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="{ name: 'did', params: { did: neighbor.did } }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
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="onCopyDidClick(neighbor.did)"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map for first coordinates --> <!-- Map for first coordinates -->
<div v-if="hasFirstLocation" class="mt-4"> <div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2> <h2 class="text-lg font-semibold">Location</h2>
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
expandedNeighborDid: string | null = null;
isLoading = true; isLoading = true;
loadingNeighbors = false;
neighbors: Array<{ did: string; relation: string }> = [];
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER; partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null; profile: UserProfile | null = null;
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
*/ */
async mounted() { async mounted() {
await this.initializeSettings(); await this.initializeSettings();
await this.loadContacts();
await this.loadProfile(); await this.loadProfile();
await this.loadNeighbors();
} }
/** /**
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
this.activeDid = activeIdentity.activeDid || ""; this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer; this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
}
/**
* Loads all contacts from database
*/
private async loadContacts() {
this.allContacts = await this.$getAllContacts(); this.allContacts = await this.$getAllContacts();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
} }
@@ -249,23 +350,100 @@ export default class UserProfileView extends Vue {
} }
/** /**
* Copies profile link to clipboard * Loads nearest neighbors from partner API
* *
* Creates a deep link to the profile and copies it to the clipboard * Fetches network connections for the profile and displays them
* Shows success notification when completed * with appropriate relation labels
*/
async loadNeighbors() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
return;
}
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.neighbors = result.data;
this.neighborsError = "";
} else {
logger.warn("Failed to load neighbors:", response.status);
this.neighbors = [];
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
logger.error("Error loading neighbors:", error);
this.neighbors = [];
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Copies a deep link to the profile to the clipboard
*/ */
async onCopyLinkClick() { async onCopyLinkClick() {
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`; const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try { try {
await copyToClipboard(deepLink); await copyToClipboard(deepLink);
this.notify.copied("profile link", TIMEOUTS.STANDARD); this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) { } catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true); this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link."); this.notify.error("Failed to copy profile link.");
} }
} }
/**
* Copies a deep link to the provided DID to the clipboard
*/
async onCopyDidClick(did: string) {
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("DID link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying DID link: ${error}`, true);
this.notify.error("Failed to copy DID link.");
}
}
/**
* Handles clicking the expand button next to a neighbor's name
* Copies the profile link to clipboard and toggles the expanded section
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
// don't copy the link
return;
}
// Copy the profile link
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
// Toggle the expanded section
this.expandedNeighborDid = did;
}
/** /**
* Computed properties for template logic streamlining * Computed properties for template logic streamlining
*/ */
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
get tileLayerUrl() { get tileLayerUrl() {
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
} }
/**
* Gets display name for a neighbor's DID
* Uses didInfo utility to show contact name if available, otherwise DID
* @param did - The DID to get display name for
* @returns Formatted display name
*/
getNeighborDisplayName(did: string): string {
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
}
neighborIsNotInContacts(did: string) {
return !this.allContacts.some((contact) => contact.did === did);
}
noNeighborsAreInContacts() {
return this.neighbors.every(
(neighbor) =>
!this.allContacts.some((contact) => contact.did === neighbor.did),
);
}
/**
* Gets human-readable label for relation type
* @param relation - The relation type from API
* @returns Display label for the relation
*/
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
* @param relation - The relation type from API
* @returns CSS class string for badge
*/
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`;
}
}
} }
</script> </script>