You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

881 lines
29 KiB

<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
)
"
>
Do you agree?
</span>
<span v-else> Confirmation Details </span>
</h1>
</div>
<div v-if="giveDetails && !isLoading">
<div class="flex justify-center">
<button
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
)
"
class="col-span-1 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="confirmConfirmClaim()"
>
Confirm
<font-awesome
icon="circle-check"
class="ml-2 text-white cursor-pointer"
/>
</button>
<button
v-else
class="col-span-1 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-4 py-2 rounded-md"
@click="notifyWhyCannotConfirm()"
>
Confirm
<font-awesome
icon="circle-check"
class="ml-2 text-white cursor-pointer"
/>
</button>
</div>
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div class="flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<div class="text-sm">
<div>
<font-awesome icon="arrow-left" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
<div v-if="giveDetails.amount">
<font-awesome
icon="hand-holding-dollar"
class="fa-fw text-slate-400"
/>
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
</div>
<div v-if="giveDetails.description">
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{ giveDetails.amount ? "and:" : "" }}
{{ giveDetails.description }}
</div>
<div class="ml-6">to</div>
<div>
<font-awesome icon="arrow-right" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
on
{{ giveDetails.issuedAt.substring(0, 10) }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId" class="mt-2">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails?.fulfillsType || "",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="mt-2">
<font-awesome icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person confirmed this.
</span>
<span v-else> {{ totalConfirmers() }} people confirmed this. </span>
<div v-if="totalConfirmers() > 0">
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
"
>
Nobody that you know confirmed this claim, nor do they have any
confirmers in their network.
</div>
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
"
>
<!-- Only show if this person has links to confirmers (below). -->
Nobody that you know issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
:key="confirmerId"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!--
Never need to show this message:
"Nobody that you know can see someone who confirmed this claim."
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
If there is somebody in the confirmerIdList then that's all they need to show.
-->
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who issued or
confirmed this claim.
<ul class="ml-4">
<li
v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
>
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="!isRegistered">
You cannot confirm this because you are not registered. Find someone
to register you, maybe on the Help page.
</div>
<div v-else-if="giveDetails.issuerDid == activeDid">
You cannot confirm this because you issued this claim, so you already
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because some people are hidden.
</div>
</div>
<!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<font-awesome v-if="showVeriClaimDump" icon="chevron-up" />
<font-awesome v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either.
<span v-if="canShare">
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a class="text-blue-500" @click="onClickShareClaim()"
>click to send them this page info</a
>
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span>
<span v-else>
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to
some of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a class="text-blue-500" @click="onClickShareClaim()"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<font-awesome icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<font-awesome
icon="globe"
class="fa-fw text-slate-400"
/>
<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
<div class="mt-2 ml-2">
<a
class="text-blue-500 cursor-pointer"
@click="showClaimPage(veriClaim.id)"
>
<font-awesome icon="file-lines" />
See All Generic Info
</a>
</div>
<div class="mt-2 ml-2">
<a
v-if="isRegistered"
class="text-blue-500 cursor-pointer"
:href="urlForNewGive"
>
<font-awesome icon="file-lines" />
Record a Give Similar to the Original
</a>
</div>
</div>
</div>
<div v-else-if="!isLoading">This does not have details to confirm.</div>
<div
v-if="isLoading"
class="fixed left-6 bottom-24 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>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { GenericVerifiableCredential } from "../interfaces";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { GiveSummaryRecord } from "../interfaces";
import { displayAmount } from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { isGiveAction, retrieveAccountDids } from "../libs/util";
import TopMessage from "../components/TopMessage.vue";
/**
* ConfirmGiftView Component
*
* Displays details about a gift claim and allows users to confirm it if eligible.
* Shows gift details including giver, recipient, amount, description, and confirmation status.
* Handles visibility of hidden DIDs and provides access to detailed claim information.
*
* Key features:
* - Gift confirmation workflow
* - Detailed gift information display
* - Confirmation status tracking
* - Hidden DID handling
* - Claim details expansion
*/
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class ConfirmGiftView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
canShare = false;
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails?: GiveSummaryRecord;
giverName = "";
issuerName = "";
isLoading = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showVeriClaimDump = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
displayAmount = displayAmount;
/**
* Initializes the view with gift claim information
*
* Workflow:
* 1. Retrieves active account settings
* 2. Loads gift claim details from ID in URL
* 3. Processes claim information for display
* 4. Checks user's ability to confirm the gift
*/
async mounted() {
this.isLoading = true;
try {
await this.initializeSettings();
await this.loadClaimFromUrl();
} catch (error) {
console.error("Error in mounted:", error);
this.handleMountError(error);
} finally {
this.isLoading = false;
}
}
/**
* Initializes component settings and user data
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.isRegistered = settings.isRegistered || false;
this.allMyDids = await retrieveAccountDids();
// Check share capability
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
/**
* Loads and processes claim from URL parameters
*/
private async loadClaimFromUrl() {
const pathParam = window.location.pathname.substring("/confirm-gift/".length);
if (!pathParam) {
throw new Error("No claim ID was provided.");
}
const claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid);
}
/**
* Handles errors during component mounting
*/
private handleMountError(error: unknown) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: error instanceof Error ? error.message : "No claim ID was provided.",
},
3000,
);
}
/**
* Loads claim details and associated give information
*
* @param claimId - ID of claim to load
* @param userDid - User's DID
*/
private async loadClaim(claimId: string, userDid: string) {
await this.fetchClaimDetails(claimId, userDid);
if (this.veriClaim.claimType === "GiveAction") {
await this.fetchGiveDetails(claimId, userDid);
await this.processGiveDetails();
await this.fetchConfirmerInfo(claimId, userDid);
}
}
/**
* Fetches basic claim details from server
*/
private async fetchClaimDetails(claimId: string, userDid: string) {
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
try {
const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
this.issuerName = this.didInfo(this.veriClaim.issuer);
} else {
throw new Error("Error getting claim: " + resp.status);
}
} catch (error) {
console.error("Error getting claim:", error);
throw new Error("There was a problem retrieving that claim.");
}
}
/**
* Fetches detailed give information
*/
private async fetchGiveDetails(claimId: string, userDid: string) {
const giveUrl = `${this.apiServer}/api/v2/report/gives?handleId=${encodeURIComponent(claimId)}`;
try {
const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(giveUrl, { headers });
if (resp.status === 200) {
this.giveDetails = resp.data.data[0];
} else {
throw new Error("Error getting detailed give info: " + resp.status);
}
} catch (error) {
console.error("Error getting detailed give info:", error);
throw new Error("Something went wrong retrieving gift data.");
}
}
/**
* Processes give details and builds URL for new give
*/
private async processGiveDetails() {
if (!this.giveDetails) return;
this.urlForNewGive = "/gifted-details?";
this.addGiveDetailsToUrl();
this.processParticipantInfo();
this.processAdditionalDetails();
}
/**
* Adds basic give details to URL
*/
private addGiveDetailsToUrl() {
if (this.giveDetails?.amount) {
this.urlForNewGive += `&amountInput=${encodeURIComponent(String(this.giveDetails.amount))}`;
}
if (this.giveDetails?.unit) {
this.urlForNewGive += `&unitCode=${encodeURIComponent(this.giveDetails.unit)}`;
}
if (this.giveDetails?.description) {
this.urlForNewGive += `&description=${encodeURIComponent(this.giveDetails.description)}`;
}
}
/**
* Processes participant (giver/recipient) information
*/
private processParticipantInfo() {
if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
}
if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
}
}
/**
* Processes additional give details (image, offer, plan)
*/
private processAdditionalDetails() {
if (this.giveDetails?.fullClaim.image) {
this.urlForNewGive += `&image=${encodeURIComponent(this.giveDetails.fullClaim.image)}`;
}
if (this.giveDetails?.type === "Offer" && this.giveDetails?.fulfillsHandleId) {
this.urlForNewGive += `&offerId=${encodeURIComponent(this.giveDetails.fulfillsHandleId)}`;
}
if (this.giveDetails?.fulfillsPlanHandleId) {
this.urlForNewGive += `&fulfillsProjectId=${encodeURIComponent(this.giveDetails.fulfillsPlanHandleId)}`;
}
}
/**
* Fetches confirmer information for the claim
*/
private async fetchConfirmerInfo(claimId: string, userDid: string) {
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
} else {
this.confsVisibleErrorMessage = "Had problems retrieving confirmations.";
}
}
/**
* Calculates total number of confirmers for the gift
* Includes both direct confirmers and those visible through network
*
* @returns Total number of confirmers
*/
totalConfirmers(): number {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
/**
* Formats display amount with proper unit
*
* @param unit - Currency or unit code
* @param amount - Numeric amount
* @returns Formatted amount string
*/
displayAmount(unit: string, amount: number): string {
return displayAmount(unit, amount);
}
/**
* Retrieves human-readable name for a DID
* Falls back to DID if no name available
*
* @param did - DID to get name for
* @returns Human-readable name
*/
didInfo(did: string): string {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Copies text to clipboard and shows notification
*
* @param description - Description of copied content
* @param text - Text to copy
*/
copyToClipboard(description: string, text: string): void {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (description || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
/**
* Navigates to claim page for detailed view
*
* @param claimId - ID of claim to view
*/
showClaimPage(claimId: string): void {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
});
}
/**
* Initiates claim confirmation process
* Verifies user eligibility and handles confirmation workflow
*/
async confirmConfirmClaim(): Promise<void> {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
/**
* Notifies user why they cannot confirm the gift
* Explains requirements or restrictions preventing confirmation
*/
notifyWhyCannotConfirm(): void {
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.veriClaim.claimType,
this.giveDetails,
this.activeDid,
this.confirmerIdList,
);
}
/**
* Formats type string for display by adding spaces before capitals
* Optionally adds a prefix
*
* @param text - Text to format
* @param prefix - Optional prefix to add
* @returns Formatted string
*/
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
text: string,
prefix?: string
): string {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
/**
* Initiates sharing of claim information
* Handles share functionality based on platform capabilities
*/
async onClickShareClaim(): Promise<void> {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation,
});
}
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = undefined;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
}
</script>