572 lines
18 KiB
Vue
572 lines
18 KiB
Vue
<template>
|
|
<QuickNav selected="Discover" />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<TopMessage />
|
|
|
|
<!-- Sub View Heading -->
|
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
|
Individual Profile
|
|
</h1>
|
|
|
|
<!-- Back -->
|
|
<a
|
|
class="order-first text-lg text-center leading-none p-1"
|
|
@click="$router.go(-1)"
|
|
>
|
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
|
</a>
|
|
|
|
<!-- Help button -->
|
|
<router-link
|
|
:to="{ name: 'help' }"
|
|
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
|
>
|
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Loading Animation -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="fixed left-6 mt-16 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
|
>
|
|
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
|
|
</div>
|
|
|
|
<div v-else-if="profile">
|
|
<!-- Profile Info -->
|
|
<div class="mt-8">
|
|
<div class="text-sm">
|
|
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
|
|
{{ profileDisplayName }}
|
|
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
|
|
<font-awesome
|
|
icon="link"
|
|
class="text-sm text-slate-500 ml-2 mb-1"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<p v-if="profile.description" class="mt-4 text-slate-600">
|
|
{{ profile.description }}
|
|
</p>
|
|
</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 -->
|
|
<div v-if="hasFirstLocation" class="mt-4">
|
|
<h2 class="text-lg font-semibold">Location</h2>
|
|
<div class="h-96 mt-2 w-full">
|
|
<l-map ref="profileMap" :center="firstLocationCoords" :zoom="mapZoom">
|
|
<l-tile-layer
|
|
:url="tileLayerUrl"
|
|
layer-type="base"
|
|
name="OpenStreetMap"
|
|
/>
|
|
<l-marker :lat-lng="firstLocationCoords">
|
|
<l-popup>{{ profileDisplayName }}</l-popup>
|
|
</l-marker>
|
|
</l-map>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map for second coordinates -->
|
|
<div v-if="hasSecondLocation" class="mt-4">
|
|
<h2 class="text-lg font-semibold">Second Location</h2>
|
|
<div class="h-96 mt-2 w-full">
|
|
<l-map
|
|
ref="profileMap"
|
|
:center="secondLocationCoords"
|
|
:zoom="mapZoom"
|
|
>
|
|
<l-tile-layer
|
|
:url="tileLayerUrl"
|
|
layer-type="base"
|
|
name="OpenStreetMap"
|
|
/>
|
|
<l-marker :lat-lng="secondLocationCoords">
|
|
<l-popup>{{ profileDisplayName }}</l-popup>
|
|
</l-marker>
|
|
</l-map>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center mt-8">
|
|
<p class="text-lg text-slate-500">Profile not found.</p>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
|
|
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
|
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import {
|
|
DEFAULT_PARTNER_API_SERVER,
|
|
NotificationIface,
|
|
APP_SERVER,
|
|
} from "../constants/app";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { didInfo, getHeaders } from "../libs/endorserServer";
|
|
import { UserProfile } from "../libs/partnerServer";
|
|
import { retrieveAccountDids } from "../libs/util";
|
|
import { logger } from "../utils/logger";
|
|
import { copyToClipboard } from "../services/ClipboardService";
|
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|
import { NOTIFY_PROFILE_LOAD_ERROR } from "@/constants/notifications";
|
|
|
|
/**
|
|
* User Profile View Component
|
|
*
|
|
* Displays individual user profile information including:
|
|
* - Basic profile data and description
|
|
* - Location information with interactive maps
|
|
* - Profile link sharing functionality
|
|
*
|
|
* Features:
|
|
* - Profile data loading from partner API
|
|
* - Interactive maps for location visualization
|
|
* - Copy-to-clipboard functionality for profile links
|
|
* - Responsive design with loading states
|
|
*
|
|
* @author Matthew Raymer
|
|
*/
|
|
@Component({
|
|
components: {
|
|
LMap,
|
|
LMarker,
|
|
LPopup,
|
|
LTileLayer,
|
|
QuickNav,
|
|
TopMessage,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class UserProfileView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$router!: Router;
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
|
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
activeDid = "";
|
|
allContacts: Array<Contact> = [];
|
|
allMyDids: Array<string> = [];
|
|
expandedNeighborDid: string | null = null;
|
|
isLoading = true;
|
|
loadingNeighbors = false;
|
|
neighbors: Array<{ did: string; relation: string }> = [];
|
|
neighborsError = "";
|
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
|
profile: UserProfile | null = null;
|
|
|
|
// make this function available to the Vue template
|
|
didInfo = didInfo;
|
|
/** Production share domain for deep links */
|
|
APP_SERVER = APP_SERVER;
|
|
|
|
/**
|
|
* Initializes notification helpers
|
|
*/
|
|
created() {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
}
|
|
|
|
/**
|
|
* Component initialization
|
|
*
|
|
* Loads account settings, contacts, and profile data
|
|
* Uses PlatformServiceMixin for database operations
|
|
*/
|
|
async mounted() {
|
|
await this.initializeSettings();
|
|
await this.loadProfile();
|
|
await this.loadNeighbors();
|
|
}
|
|
|
|
/**
|
|
* Initializes account settings from database
|
|
*/
|
|
private async initializeSettings() {
|
|
const settings = await this.$accountSettings();
|
|
|
|
// Get activeDid from active_identity table (single source of truth)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
|
this.activeDid = activeIdentity.activeDid || "";
|
|
|
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
|
|
|
this.allContacts = await this.$getAllContacts();
|
|
this.allMyDids = await retrieveAccountDids();
|
|
}
|
|
|
|
/**
|
|
* Loads user profile data from partner API
|
|
*
|
|
* Handles profile loading with error handling and loading states
|
|
*/
|
|
async loadProfile() {
|
|
const profileId: string = this.$route.params.id as string;
|
|
if (!profileId) {
|
|
this.isLoading = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${this.partnerApiServer}/api/partner/userProfile/${encodeURIComponent(profileId)}`,
|
|
{
|
|
method: "GET",
|
|
headers: await getHeaders(this.activeDid),
|
|
},
|
|
);
|
|
|
|
if (response.status === 200) {
|
|
const result = await response.json();
|
|
this.profile = result.data;
|
|
if (this.profile && this.profile.rowId !== profileId) {
|
|
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
|
|
this.profile.rowId = profileId;
|
|
}
|
|
} else {
|
|
throw new Error("Failed to load profile");
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error loading profile:", error);
|
|
this.notify.error(NOTIFY_PROFILE_LOAD_ERROR.message, TIMEOUTS.LONG);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads nearest neighbors from partner API
|
|
*
|
|
* Fetches network connections for the profile and displays them
|
|
* 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() {
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
|
|
/**
|
|
* Gets the display name for the profile using didInfo utility
|
|
* @returns Formatted display name for the profile owner
|
|
*/
|
|
get profileDisplayName() {
|
|
return this.didInfo(
|
|
this.profile?.issuerDid,
|
|
this.activeDid,
|
|
this.allMyDids,
|
|
this.allContacts,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if the profile has first location coordinates
|
|
* @returns True if both latitude and longitude are available
|
|
*/
|
|
get hasFirstLocation() {
|
|
return this.profile?.locLat && this.profile?.locLon;
|
|
}
|
|
|
|
/**
|
|
* Gets the coordinate array for the first location
|
|
* @returns Array of [latitude, longitude] for map center
|
|
*/
|
|
get firstLocationCoords() {
|
|
return [this.profile?.locLat, this.profile?.locLon];
|
|
}
|
|
|
|
/**
|
|
* Checks if the profile has second location coordinates
|
|
* @returns True if both latitude and longitude are available
|
|
*/
|
|
get hasSecondLocation() {
|
|
return this.profile?.locLat2 && this.profile?.locLon2;
|
|
}
|
|
|
|
/**
|
|
* Gets the coordinate array for the second location
|
|
* @returns Array of [latitude, longitude] for map center
|
|
*/
|
|
get secondLocationCoords() {
|
|
return [this.profile?.locLat2, this.profile?.locLon2];
|
|
}
|
|
|
|
/**
|
|
* Standard map zoom level for profile location maps
|
|
* @returns Default zoom level for location display
|
|
*/
|
|
get mapZoom() {
|
|
return 12;
|
|
}
|
|
|
|
/**
|
|
* OpenStreetMap tile layer URL template
|
|
* @returns URL template for map tile fetching
|
|
*/
|
|
get tileLayerUrl() {
|
|
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>
|