<template> <!-- QUICK NAV --> <nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50"> <ul class="flex text-2xl p-2 gap-2"> <!-- Home Feed --> <li class="basis-1/5 rounded-md text-slate-500"> <router-link :to="{ name: 'home' }" class="block text-center py-3 px-1" ><fa icon="house-chimney" class="fa-fw"></fa ></router-link> </li> <!-- Search --> <li class="basis-1/5 rounded-md text-slate-500"> <router-link :to="{ name: 'discover' }" class="block text-center py-3 px-1" ><fa icon="magnifying-glass" class="fa-fw"></fa ></router-link> </li> <!-- Contacts --> <li class="basis-1/5 rounded-md text-slate-500"> <router-link :to="{ name: 'projects' }" class="block text-center py-3 px-1" ><fa icon="folder-open" class="fa-fw"></fa ></router-link> </li> <!-- Contacts --> <li class="basis-1/5 rounded-md bg-slate-400 text-white"> <router-link :to="{ name: 'contacts' }" class="block text-center py-3 px-1" ><fa icon="users" class="fa-fw"></fa ></router-link> </li> <!-- Profile --> <li class="basis-1/5 rounded-md text-slate-500"> <router-link :to="{ name: 'account' }" class="block text-center py-3 px-1" ><fa icon="circle-user" class="fa-fw"></fa ></router-link> </li> </ul> </nav> <section id="Content" class="p-6 pb-24"> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> Your Contacts </h1> <div class="flex justify-between py-2"> <span /> <span> <router-link :to="{ name: 'help' }" class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1" > Help </router-link> </span> </div> <!-- New Contact --> <div class="mb-4 flex"> <input type="text" placeholder="DID, Name, Public Key" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2" v-model="contactInput" /> <button class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" @click="onClickNewContact()" > <fa icon="plus" class="fa-fw"></fa> </button> </div> <div class="flex justify-between" v-if="showGiveNumbers"> <div class="w-full text-right"> Hours to Add: <input class="border border rounded border-slate-400 w-24 text-right" type="text" placeholder="1" v-model="hourInput" /> <br /> <input class="border border rounded border-slate-400 w-48" type="text" placeholder="Description" v-model="hourDescriptionInput" /> <br /> <br /> <button href="" class="text-center text-md text-white px-1.5 py-2 rounded-md mb-6" v-bind:class="showGiveAmountsClassNames()" @click="toggleShowGiveTotals()" > {{ showGiveTotals ? "Total" : showGiveConfirmed ? "Confirmed" : "Unconfirmed" }} </button> </div> </div> <!-- Results List --> <ul class=""> <li class="border-b border-slate-300" v-for="contact in contacts" :key="contact.did" > <div class="grow overflow-hidden"> <h2 class="text-base font-semibold"> {{ contact.name || "(no name)" }} </h2> <div class="text-sm truncate">{{ contact.did }}</div> <div class="text-sm truncate" v-if="contact.publicKeyBase64"> Public Key (base 64): {{ contact.publicKeyBase64 }} </div> <button v-if="contact.seesMe" class="tooltip" @click="setVisibility(contact, false)" > <fa icon="eye" class="text-slate-900 fa-fw ml-1" /> <span class="tooltiptext">They can see you</span> </button> <button v-else class="tooltip" @click="setVisibility(contact, true)"> <span class="tooltiptext">They cannot see you</span> <fa icon="eye-slash" class="text-slate-900 fa-fw ml-1" /> </button> <button class="tooltip" @click="checkVisibility(contact)"> <span class="tooltiptext">Check Visibility</span> <fa icon="rotate" class="text-slate-900 fa-fw ml-1" /> </button> <button v-if="contact.registered" class="tooltip"> <span class="tooltiptext">Registered</span> <fa icon="person-circle-check" class="text-slate-900 fa-fw ml-1" /> </button> <button v-else @click="register(contact)" class="tooltip"> <span class="tooltiptext">Registration Unknown</span> <fa icon="person-circle-question" class="text-slate-900 fa-fw ml-1" /> </button> <button @click="deleteContact(contact)" class="px-9 tooltip"> <span class="tooltiptext">Delete!</span> <fa icon="trash-can" class="text-red-600 fa-fw ml-1" /> </button> <div v-if="showGiveNumbers" class="float-right"> <div class="float-right"> <div class="tooltip"> to: {{ /* eslint-disable prettier/prettier */ this.showGiveTotals ? ((givenByMeConfirmed[contact.did] || 0) + (givenByMeUnconfirmed[contact.did] || 0)) : this.showGiveConfirmed ? (givenByMeConfirmed[contact.did] || 0) : (givenByMeUnconfirmed[contact.did] || 0) /* eslint-enable prettier/prettier */ }} <span v-if="givenByMeDescriptions[contact.did]" class="tooltiptext-left" > {{ givenByMeDescriptions[contact.did] }} </span> <button class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6" @click="onClickAddGive(activeDid, contact.did)" > + </button> </div> <div class="tooltip px-2"> from: {{ /* eslint-disable prettier/prettier */ this.showGiveTotals ? ((givenToMeConfirmed[contact.did] || 0) + (givenToMeUnconfirmed[contact.did] || 0)) : this.showGiveConfirmed ? (givenToMeConfirmed[contact.did] || 0) : (givenToMeUnconfirmed[contact.did] || 0) /* eslint-enable prettier/prettier */ }} <span v-if="givenToMeDescriptions[contact.did]" class="tooltiptext-left" > {{ givenToMeDescriptions[contact.did] }} </span> <button class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6" @click="onClickAddGive(contact.did, activeDid)" > + </button> </div> <router-link :to="{ name: 'contact-amounts', query: { contactDid: contact.did }, }" class="tooltip" > <fa icon="file-lines" class="text-slate-600 fa-fw ml-1" /> <span class="tooltiptext-left">See All Given Activity</span> </router-link> </div> </div> </div> </li> </ul> </section> <!-- This same popup code is in many files. --> <div v-bind:class="computedAlertClassNames()"> <button class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2" @click="onClickClose()" > <fa icon="xmark"></fa> </button> <h4 class="font-bold pr-5">{{ alertTitle }}</h4> <p>{{ alertMessage }}</p> </div> </template> <script lang="ts"> import { AxiosError } from "axios"; import * as didJwt from "did-jwt"; import * as R from "ramda"; import { IIdentifier } from "@veramo/core"; import { Options, Vue } from "vue-class-component"; import { accountsDB, db } from "@/db"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { accessToken, SimpleSigner } from "@/libs/crypto"; import { GiveServerRecord, GiveVerifiableCredential, RegisterVerifiableCredential, SERVICE_ID, } from "@/libs/endorserServer"; // eslint-disable-next-line @typescript-eslint/no-var-requires const Buffer = require("buffer/").Buffer; @Options({ components: {}, }) export default class ContactsView extends Vue { activeDid = ""; apiServer = ""; contacts: Array<Contact> = []; contactInput = ""; // { "did:...": concatenated-descriptions } entry for each contact givenByMeDescriptions: Record<string, string> = {}; // { "did:...": amount } entry for each contact givenByMeConfirmed: Record<string, number> = {}; // { "did:...": amount } entry for each contact givenByMeUnconfirmed: Record<string, number> = {}; // { "did:...": concatenated-descriptions } entry for each contact givenToMeDescriptions: Record<string, string> = {}; // { "did:...": amount } entry for each contact givenToMeConfirmed: Record<string, number> = {}; // { "did:...": amount } entry for each contact givenToMeUnconfirmed: Record<string, number> = {}; hourDescriptionInput = ""; hourInput = "0"; showGiveNumbers = false; showGiveTotals = true; showGiveConfirmed = true; // 'created' hook runs when the Vue instance is first created async created() { await db.open(); const settings = await db.settings.get(MASTER_SETTINGS_KEY); this.activeDid = settings?.activeDid || ""; this.apiServer = settings?.apiServer || ""; this.showGiveNumbers = !!settings?.showContactGivesInline; if (this.showGiveNumbers) { this.loadGives(); } const allContacts = await db.contacts.toArray(); this.contacts = R.sort( (a: Contact, b) => (a.name || "").localeCompare(b.name || ""), allContacts ); } async loadGives() { await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === this.activeDid, accounts); const identity = JSON.parse(account?.identity || "undefined"); if (!identity) { console.error( "Attempted to load Give records with no identity available." ); return; } // load all the time I have given try { const url = this.apiServer + "/api/v2/report/gives?agentDid=" + encodeURIComponent(identity.did); const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; const resp = await this.axios.get(url, { headers }); //console.log("All gifts you've given:", resp.data); if (resp.status === 200) { const contactDescriptions: Record<string, string> = {}; const contactConfirmed: Record<string, number> = {}; const contactUnconfirmed: Record<string, number> = {}; const allData: Array<GiveServerRecord> = resp.data.data; for (const give of allData) { if (give.unit == "HUR") { const recipDid: string = give.recipientDid; if (give.amountConfirmed) { const prevAmount = contactConfirmed[recipDid] || 0; contactConfirmed[recipDid] = prevAmount + give.amount; } else { const prevAmount = contactUnconfirmed[recipDid] || 0; contactUnconfirmed[recipDid] = prevAmount + give.amount; } if (!contactDescriptions[recipDid] && give.description) { // Since many make the tooltip too big, we'll just use the latest. contactDescriptions[recipDid] = give.description; } } } //console.log("Done retrieving gives", contactConfirmed); this.givenByMeDescriptions = contactDescriptions; this.givenByMeConfirmed = contactConfirmed; this.givenByMeUnconfirmed = contactUnconfirmed; } else { console.log( "Got bad response status & data of", resp.status, resp.data ); this.alertTitle = "Error With Server"; this.alertMessage = "Got an error retrieving your given time from the server."; this.isAlertVisible = true; } } catch (error) { this.alertTitle = "Error With Server"; this.alertMessage = error as string; this.isAlertVisible = true; } // load all the time I have received try { const url = this.apiServer + "/api/v2/report/gives?recipientDid=" + encodeURIComponent(identity.did); const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; const resp = await this.axios.get(url, { headers }); //console.log("All gifts you've recieved:", resp.data); if (resp.status === 200) { const contactDescriptions: Record<string, string> = {}; const contactConfirmed: Record<string, number> = {}; const contactUnconfirmed: Record<string, number> = {}; const allData: Array<GiveServerRecord> = resp.data.data; for (const give of allData) { if (give.unit == "HUR") { if (give.amountConfirmed) { const prevAmount = contactConfirmed[give.agentDid] || 0; contactConfirmed[give.agentDid] = prevAmount + give.amount; } else { const prevAmount = contactUnconfirmed[give.agentDid] || 0; contactUnconfirmed[give.agentDid] = prevAmount + give.amount; } if (!contactDescriptions[give.agentDid] && give.description) { // Since many make the tooltip too big, we'll just use the latest. contactDescriptions[give.agentDid] = give.description; } } } //console.log("Done retrieving receipts", contactConfirmed); this.givenToMeDescriptions = contactDescriptions; this.givenToMeConfirmed = contactConfirmed; this.givenToMeUnconfirmed = contactUnconfirmed; } else { console.log( "Got bad response status & data of", resp.status, resp.data ); this.alertTitle = "Error With Server"; this.alertMessage = "Got an error retrieving your received time from the server."; this.isAlertVisible = true; } } catch (error) { this.alertTitle = "Error With Server"; this.alertMessage = error as string; this.isAlertVisible = true; } } async onClickNewContact(): Promise<void> { let did = this.contactInput; let name, publicKeyBase64; const commaPos1 = this.contactInput.indexOf(","); if (commaPos1 > -1) { did = this.contactInput.substring(0, commaPos1).trim(); name = this.contactInput.substring(commaPos1 + 1).trim(); const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1); if (commaPos2 > -1) { name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim(); publicKeyBase64 = this.contactInput.substring(commaPos2 + 1).trim(); } } // help with potential mistakes while this sharing requires copy-and-paste if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) { // it must be all hex (compressed public key), so convert publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64"); } const newContact = { did, name, publicKeyBase64 }; await db.contacts.add(newContact); const allContacts = this.contacts.concat([newContact]); this.contacts = R.sort( (a: Contact, b) => (a.name || "").localeCompare(b.name || ""), allContacts ); } async deleteContact(contact: Contact) { if ( confirm( "Are you sure you want to delete " + this.nameForDid(this.contacts, contact.did) + " with DID " + contact.did + " ?" ) ) { await db.open(); await db.contacts.delete(contact.did); this.contacts = R.without([contact], this.contacts); } } async register(contact: Contact) { if ( confirm( "Are you sure you want to use one of your registrations for " + this.nameForDid(this.contacts, contact.did) + "?" ) ) { await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === this.activeDid, accounts); const identity = JSON.parse(account?.identity || "undefined"); // Make a claim const vcClaim: RegisterVerifiableCredential = { "@context": "https://schema.org", "@type": "RegisterAction", agent: { identifier: identity.did }, object: SERVICE_ID, recipient: { identifier: contact.did }, }; // Make a payload for the claim const vcPayload = { vc: { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], credentialSubject: vcClaim, }, }; // Create a signature using private key of identity if (identity.keys[0].privateKeyHex !== null) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const privateKeyHex: string = identity.keys[0].privateKeyHex!; const signer = await SimpleSigner(privateKeyHex); const alg = undefined; // Create a JWT for the request const vcJwt: string = await didJwt.createJWT(vcPayload, { alg: alg, issuer: identity.did, signer: signer, }); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; try { const resp = await this.axios.post(url, payload, { headers }); //console.log("Got resp data:", resp.data); if (resp.data?.success?.handleId) { contact.registered = true; db.contacts.update(contact.did, { registered: true }); this.alertTitle = "Registration Success"; this.alertMessage = contact.name + " has been registered."; this.isAlertVisible = true; } } catch (error) { let userMessage = "There was an error. See logs for more info."; const serverError = error as AxiosError; if (serverError) { if (serverError.message) { userMessage = serverError.message; // Info for the user } else { userMessage = JSON.stringify(serverError.toJSON()); } } else { userMessage = error as string; } // Now set that error for the user to see. this.alertTitle = "Error With Server"; this.alertMessage = userMessage; this.isAlertVisible = true; } } } } async setVisibility(contact: Contact, visibility: boolean) { const url = this.apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe"); await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === this.activeDid, accounts); const identity = JSON.parse(account?.identity || "undefined"); const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; const payload = JSON.stringify({ did: contact.did }); try { const resp = await this.axios.post(url, payload, { headers }); if (resp.status === 200) { contact.seesMe = visibility; db.contacts.update(contact.did, { seesMe: visibility }); } else { this.alertTitle = "Error With Server"; console.log("Bad response setting visibility: ", resp.data); if (resp.data.error?.message) { this.alertMessage = resp.data.error?.message; } else { this.alertMessage = "Bad server response of " + resp.status; } this.isAlertVisible = true; } } catch (err) { this.alertTitle = "Error With Server"; this.alertMessage = err as string; this.isAlertVisible = true; } } async checkVisibility(contact: Contact) { const url = this.apiServer + "/api/report/canDidExplicitlySeeMe?did=" + encodeURIComponent(contact.did); await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === this.activeDid, accounts); const identity = JSON.parse(account?.identity || "undefined"); const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { const visibility = resp.data; contact.seesMe = visibility; db.contacts.update(contact.did, { seesMe: visibility }); this.alertTitle = "Refreshed"; this.alertMessage = this.nameForContact(contact, true) + " can " + (visibility ? "" : "not ") + "see your activity."; this.isAlertVisible = true; } else { this.alertTitle = "Error With Server"; if (resp.data.error?.message) { this.alertMessage = resp.data.error?.message; } else { this.alertMessage = "Bad server response of " + resp.status; } this.isAlertVisible = true; } } catch (err) { this.alertTitle = "Error With Server"; this.alertMessage = err as string; this.isAlertVisible = true; } } // from https://stackoverflow.com/a/175787/845494 // private isNumeric(str: string): boolean { return !isNaN(+str); } private nameForDid(contacts: Array<Contact>, did: string): string { const contact = R.find((con) => con.did == did, contacts); return this.nameForContact(contact); } private nameForContact(contact?: Contact, capitalize?: boolean): string { return contact?.name || (capitalize ? "T" : "t") + "this unnamed user"; } async onClickAddGive(fromDid: string, toDid: string): Promise<void> { await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === this.activeDid, accounts); const identity = JSON.parse(account?.identity || "undefined"); // if they have unconfirmed amounts, ask to confirm those first if (toDid == identity?.did && this.givenToMeUnconfirmed[fromDid] > 0) { if ( confirm( "There are " + this.givenToMeUnconfirmed[fromDid] + " unconfirmed hours from them." + " Would you like to confirm some of those hours?" ) ) { this.$router.push({ name: "contact-amounts", query: { contactDid: fromDid }, }); } } if (!this.isNumeric(this.hourInput)) { this.alertTitle = "Input Error"; this.alertMessage = "This is not a valid number of hours: " + this.hourInput; this.isAlertVisible = true; } else if (!parseFloat(this.hourInput)) { this.alertTitle = "Input Error"; this.alertMessage = "Giving 0 hours does nothing."; this.isAlertVisible = true; } else if (!identity) { this.alertTitle = "Status Error"; this.alertMessage = "No identity is available."; this.isAlertVisible = true; } else { // ask to confirm amount let toFrom; if (fromDid == identity?.did) { toFrom = "from you to " + this.nameForDid(this.contacts, toDid); } else { toFrom = "from " + this.nameForDid(this.contacts, fromDid) + " to you"; } let description; if (this.hourDescriptionInput) { description = " with description '" + this.hourDescriptionInput + "'"; } else { description = " with no description"; } if ( confirm( "Are you sure you want to record " + this.hourInput + " hours " + toFrom + description + "?" ) ) { this.createAndSubmitGive( identity, fromDid, toDid, parseFloat(this.hourInput), this.hourDescriptionInput ); } } } private async createAndSubmitGive( identity: IIdentifier, fromDid: string, toDid: string, amount: number, description: string ): Promise<void> { // Make a claim const vcClaim: GiveVerifiableCredential = { "@context": "https://schema.org", "@type": "GiveAction", agent: { identifier: fromDid }, object: { amountOfThisGood: amount, unitCode: "HUR" }, recipient: { identifier: toDid }, }; if (description) { vcClaim.description = description; } // Make a payload for the claim const vcPayload = { vc: { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], credentialSubject: vcClaim, }, }; // Create a signature using private key of identity if (identity.keys[0].privateKeyHex !== null) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const privateKeyHex: string = identity.keys[0].privateKeyHex!; const signer = await SimpleSigner(privateKeyHex); const alg = undefined; // Create a JWT for the request const vcJwt: string = await didJwt.createJWT(vcPayload, { alg: alg, issuer: identity.did, signer: signer, }); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; try { const resp = await this.axios.post(url, payload, { headers }); //console.log("Got resp data:", resp.data); if (resp.data?.success?.handleId) { this.alertTitle = "Done"; this.alertMessage = "Successfully logged time to the server."; this.isAlertVisible = true; if (fromDid === identity.did) { const newList = R.clone(this.givenByMeUnconfirmed); newList[toDid] = (newList[toDid] || 0) + amount; this.givenByMeUnconfirmed = newList; } else { const newList = R.clone(this.givenToMeConfirmed); newList[fromDid] = (newList[fromDid] || 0) + amount; this.givenToMeConfirmed = newList; } } } catch (error) { let userMessage = "There was an error. See logs for more info."; const serverError = error as AxiosError; if (serverError) { if (serverError.message) { userMessage = serverError.message; // Info for the user } else { userMessage = JSON.stringify(serverError.toJSON()); } } else { userMessage = error as string; } // Now set that error for the user to see. this.alertTitle = "Error With Server"; this.alertMessage = userMessage; this.isAlertVisible = true; } } } public toggleShowGiveTotals() { if (this.showGiveTotals) { this.showGiveTotals = false; this.showGiveConfirmed = true; } else if (this.showGiveConfirmed) { this.showGiveTotals = false; // stays the same this.showGiveConfirmed = false; } else { this.showGiveTotals = true; this.showGiveConfirmed = true; } } // This same popup code is in many files. alertTitle = ""; alertMessage = ""; isAlertVisible = false; public onClickClose() { this.isAlertVisible = false; this.alertTitle = ""; this.alertMessage = ""; } public computedAlertClassNames() { return { hidden: !this.isAlertVisible, "dismissable-alert": true, "bg-slate-100": true, "p-5": true, rounded: true, "drop-shadow-lg": true, fixed: true, "top-3": true, "inset-x-3": true, "transition-transform": true, "ease-in": true, "duration-300": true, }; } public showGiveAmountsClassNames() { return { "bg-slate-500": this.showGiveTotals, "bg-green-600": !this.showGiveTotals && this.showGiveConfirmed, "bg-yellow-600": !this.showGiveTotals && !this.showGiveConfirmed, }; } } </script> <style> /* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */ /* Tooltip container */ .tooltip { position: relative; display: inline-block; border-bottom: 1px dotted black; /* If you want dots under the hoverable text */ } /* Tooltip text */ .tooltip .tooltiptext { visibility: hidden; width: 200px; background-color: black; color: #fff; text-align: center; padding: 5px 0; border-radius: 6px; position: absolute; z-index: 1; } /* How do we share with the above so code isn't duplicated? */ .tooltip .tooltiptext-left { visibility: hidden; width: 200px; background-color: black; color: #fff; text-align: center; padding: 5px 0; border-radius: 6px; position: absolute; z-index: 1; bottom: 0%; right: 105%; margin-left: -60px; } /* Show the tooltip text when you mouse over the tooltip container */ .tooltip:hover .tooltiptext { visibility: visible; } .tooltip:hover .tooltiptext-left { visibility: visible; } </style>