<template> <QuickNav selected="Contacts" /> <TopMessage /> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light"> Your Contacts </h1> <div class="flex justify-between py-2 mt-8"> <span /> <span> <a href="/help-onboarding" target="_blank" class="text-xs uppercase 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-1.5 py-1 rounded-md ml-1" > Onboarding Guide </a> </span> </div> <!-- New Contact --> <div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch"> <router-link v-if="isRegistered" :to="{ name: 'invite-one' }" class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md" > <fa icon="envelope-open-text" class="fa-fw text-2xl" /> </router-link> <span v-else class="flex items-center 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-1.5 py-1 mr-1 rounded-md" > <fa icon="envelope-open-text" class="fa-fw text-2xl" @click=" danger( 'You must get registered before you can invite others.', 'Not Registered', ) " /> </span> <router-link :to="{ name: 'contact-qr' }" class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md" > <fa icon="qrcode" class="fa-fw text-2xl" /> </router-link> <textarea type="text" placeholder="New URL or DID, Name, Public Key, Next Public Key Hash" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10" v-model="contactInput" /> <button class="px-4 rounded-r bg-green-200 border border-l-0 border-green-400" @click="onClickNewContact()" > <fa icon="plus" class="fa-fw" /> </button> </div> <div class="flex justify-between" v-if="contacts.length > 0"> <div class="w-full text-left"> <input type="checkbox" v-if="!showGiveNumbers" :checked="contactsSelected.length === contacts.length" @click=" contactsSelected.length === contacts.length ? (contactsSelected = []) : (contactsSelected = contacts.map((contact) => contact.did)) " class="align-middle ml-2 h-6 w-6" data-testId="contactCheckAllTop" /> <button href="" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md" :style=" contactsSelected.length > 0 ? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);' : 'background-image: linear-gradient(to bottom, #94a3b8, #374151);' " @click="copySelectedContacts()" v-if="!showGiveNumbers" data-testId="copySelectedContactsButtonTop" > Copy Selections </button> </div> <div class="w-full text-right"> <button href="" class="text-md 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-1 py-1 rounded-md" @click="toggleShowContactAmounts()" > {{ showGiveNumbers ? "Hide Hours, Offer, etc" : "See Hours, Offer, etc" }} </button> </div> </div> <div class="flex justify-between mt-1" v-if="showGiveNumbers"> <div class="w-full text-right"> In the following, only the most recent hours are included. To see more, click <span class="text-sm uppercase 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-1 py-1 rounded-md" > <fa icon="file-lines" class="fa-fw" /> </span> <br /> <button href="" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md mt-1" v-bind:class="showGiveAmountsClassNames()" @click="toggleShowGiveTotals()" > {{ showGiveTotals ? "Totals" : showGiveConfirmed ? "Confirmed Amounts" : "Unconfirmed Amounts" }} <fa icon="left-right" class="fa-fw" /> </button> </div> </div> <!-- Results List --> <ul id="listContacts" v-if="contacts.length > 0" class="border-t border-slate-300 mt-1" > <li class="border-b border-slate-300 pt-1 pb-1" v-for="contact in filteredContacts()" :key="contact.did" data-testId="contactListItem" > <div class="grow overflow-hidden"> <div class="flex items-center"> <EntityIcon :contact="contact" :iconSize="24" class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer" @click="showLargeIdenticon = contact" /> <input type="checkbox" v-if="!showGiveNumbers" :checked="contactsSelected.includes(contact.did)" @click=" contactsSelected.includes(contact.did) ? contactsSelected.splice( contactsSelected.indexOf(contact.did), 1, ) : contactsSelected.push(contact.did) " class="ml-2 h-6 w-6" data-testId="contactCheckOne" /> <h2 class="text-base font-semibold ml-2"> {{ contact.name || AppString.NO_CONTACT_NAME }} </h2> <router-link :to="{ path: '/did/' + encodeURIComponent(contact.did), }" title="See more about this person" > <fa icon="circle-info" class="text-xl text-blue-500 ml-4" /> </router-link> <span class="ml-4 text-sm overflow-hidden">{{ shortDid(contact.did) }}</span ><!-- The first 18 characters of did:peer are the same. --> </div> <div id="ContactActions" class="flex gap-1.5 mt-2"> <div v-if="showGiveNumbers && contact.did != activeDid" class="ml-auto flex gap-1.5" > <button class="text-sm 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-2 py-1.5 rounded-l-md" @click="confirmShowGiftedDialog(contact.did, activeDid)" :title="givenToMeDescriptions[contact.did] || ''" > From: <br /> {{ /* 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 */ }} </button> <button class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l" @click="confirmShowGiftedDialog(activeDid, contact.did)" :title="givenByMeDescriptions[contact.did] || ''" > To: <br /> {{ /* 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 */ }} </button> <button class="text-sm 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-2 py-1.5 rounded-md border border-blue-400" @click="openOfferDialog(contact.did, contact.name)" data-testId="offerButton" > Offer </button> <router-link :to="{ name: 'contact-amounts', query: { contactDid: contact.did }, }" class="text-sm 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-2 py-1.5 rounded-md border border-slate-400" title="See more given activity" > <fa icon="file-lines" class="fa-fw" /> </router-link> </div> </div> </div> </li> </ul> <p v-else>There are no contacts.</p> <div class="mt-2 w-full text-left" v-if="contacts.length > 0"> <input type="checkbox" v-if="!showGiveNumbers" :checked="contactsSelected.length === contacts.length" @click=" contactsSelected.length === contacts.length ? (contactsSelected = []) : (contactsSelected = contacts.map((contact) => contact.did)) " class="align-middle ml-2 h-6 w-6" data-testId="contactCheckAllBottom" /> <button href="" class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md" :style=" contactsSelected.length > 0 ? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);' : 'background-image: linear-gradient(to bottom, #94a3b8, #374151);' " @click="copySelectedContacts()" v-if="!showGiveNumbers" > Copy Selections </button> </div> <GiftedDialog ref="customGivenDialog" /> <OfferDialog ref="customOfferDialog" /> <ContactNameDialog ref="contactNameDialog" /> <div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full"> <div class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <EntityIcon :contact="showLargeIdenticon" :iconSize="512" class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" @click="showLargeIdenticon = undefined" /> </div> </div> </section> </template> <script lang="ts"> import { AxiosError } from "axios"; import { Buffer } from "buffer/"; import { IndexableType } from "dexie"; import { JWTPayload } from "did-jwt"; import * as R from "ramda"; import { Component, Vue } from "vue-facing-decorator"; import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { useClipboard } from "@vueuse/core"; import QuickNav from "@/components/QuickNav.vue"; import EntityIcon from "@/components/EntityIcon.vue"; import GiftedDialog from "@/components/GiftedDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue"; import ContactNameDialog from "@/components/ContactNameDialog.vue"; import TopMessage from "@/components/TopMessage.vue"; import { APP_SERVER, AppString, NotificationIface } from "@/constants/app"; import { db, logConsoleAndDb, retrieveSettingsForActiveAccount, updateAccountSettings, updateDefaultSettings, } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { getContactPayloadFromJwtUrl } from "@/libs/crypto"; import { decodeEndorserJwt } from "@/libs/crypto/vc"; import { CONTACT_CSV_HEADER, CONTACT_URL_PREFIX, createEndorserJwtForDid, errorStringForLog, GiveSummaryRecord, getHeaders, isDid, register, setVisibilityUtil, UserInfo, VerifiableCredential, } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; import { generateSaveAndActivateIdentity } from "@/libs/util"; @Component({ components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav, ContactNameDialog, TopMessage, }, }) export default class ContactsView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; activeDid = ""; apiServer = ""; contacts: Array<Contact> = []; contactInput = ""; contactEdit: Contact | null = null; contactNewName = ""; contactsSelected: Array<string> = []; // { "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> = {}; hideRegisterPromptOnNewContact = false; isRegistered = false; showDidCopy = false; showPubKeyCopy = false; showPubKeyHashCopy = false; showGiveNumbers = false; showGiveTotals = true; showGiveConfirmed = true; showLargeIdenticon?: Contact; AppString = AppString; libsUtil = libsUtil; public async created() { await db.open(); const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.isRegistered = !!settings.isRegistered; this.showGiveNumbers = !!settings.showContactGivesInline; this.hideRegisterPromptOnNewContact = !!settings.hideRegisterPromptOnNewContact; if (this.showGiveNumbers) { this.loadGives(); } // .orderBy("name") wouldn't retrieve any entries with a blank name // .toCollection.sortBy("name") didn't sort in an order I understood const baseContacts = await db.contacts.toArray(); this.contacts = baseContacts.sort((a, b) => (a.name || "").localeCompare(b.name || ""), ); // handle a contact sent via URL // @deprecated: use /contact-import/:jwt with a JWT that has an array of contacts const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded) .query["contactJwt"] as string; if (importedContactJwt) { // really should fully verify contents const { payload } = decodeEndorserJwt(importedContactJwt); const userInfo = payload["own"] as UserInfo; const newContact = { did: payload["iss"], name: userInfo.name, nextPubKeyHashB64: userInfo.nextPublicEncKeyHash, profileImageUrl: userInfo.profileImageUrl, publicKeyBase64: userInfo.publicEncKey, registered: userInfo.registered, } as Contact; this.addContact(newContact); } // handle an invite JWT sent via URL const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded) .query["inviteJwt"] as string; if (importedInviteJwt === "") { // this happens when a platform (usually iOS) doesn't include anything after the "=" in a shared link. this.$notify( { group: "alert", type: "danger", title: "Blank Invite", text: "The invite was not included, which can happen when your iOS device cuts off the link. Try pasting the full link into a browser.", }, 7000, ); } else if (importedInviteJwt) { // make sure user is created if (!this.activeDid) { this.activeDid = await generateSaveAndActivateIdentity(); } // send invite directly to server, with auth for this user const headers = await getHeaders(this.activeDid); try { const response = await this.axios.post( this.apiServer + "/api/v2/claim", { jwtEncoded: importedInviteJwt }, { headers }, ); if (response.status != 201) { throw { error: { response: response } }; } await updateAccountSettings(this.activeDid, { isRegistered: true }); this.isRegistered = true; this.$notify( { group: "alert", type: "success", title: "Registered", text: "You are now registered.", }, 3000, ); // wait for a second before continuing so they see the registration message await new Promise((resolve) => setTimeout(resolve, 1000)); // now add the inviter as a contact // (similar code is in InviteOneAcceptView.vue) const payload: JWTPayload = decodeEndorserJwt(importedInviteJwt).payload; const registration = payload as VerifiableCredential; (this.$refs.contactNameDialog as ContactNameDialog).open( "Who Invited You?", "", async (name) => { await this.addContact({ did: registration.vc.credentialSubject.agent.identifier, name: name, registered: true, }); // wait for a second before continuing so they see the user-added message await new Promise((resolve) => setTimeout(resolve, 1000)); this.showOnboardingInfo(); }, async () => { // on cancel, will still add the contact await this.addContact({ did: registration.vc.credentialSubject.agent.identifier, name: "(person who invited you)", registered: true, }); // wait for a second before continuing so they see the user-added message await new Promise((resolve) => setTimeout(resolve, 1000)); this.showOnboardingInfo(); }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { const fullError = "Error redeeming invite: " + errorStringForLog(error); logConsoleAndDb(fullError, true); let message = "Got an error sending the invite."; if ( error.response && error.response.data && error.response.data.error ) { if (error.response.data.error.message) { message = error.response.data.error.message; } else { message = error.response.data.error; } } else if (error.message) { message = error.message; } this.$notify( { group: "alert", type: "danger", title: "Error with Invite", text: message, }, 5000, ); } } } private danger(message: string, title: string = "Error", timeout = 5000) { this.$notify( { group: "alert", type: "danger", title: title, text: message, }, timeout, ); } private showOnboardingInfo() { this.$notify( { group: "modal", type: "confirm", title: "They're Added To Your List", text: "Would you like to go to the main page now?", onYes: async () => { (this.$router as Router).push({ name: "home" }); }, }, -1, ); } private filteredContacts() { return this.showGiveNumbers ? this.contactsSelected.length === 0 ? this.contacts : this.contacts.filter((contact) => this.contactsSelected.includes(contact.did), ) : this.contacts; } private async loadGives() { if (!this.activeDid) { return; } const handleResponse = ( resp: { status: number; data: { data: GiveSummaryRecord[] } }, descriptions: Record<string, string>, confirmed: Record<string, number>, unconfirmed: Record<string, number>, useRecipient: boolean, ) => { if (resp.status === 200) { const allData = resp.data.data; for (const give of allData) { const otherDid = useRecipient ? give.recipientDid : give.agentDid; if (give.unit === "HUR") { if (give.amountConfirmed) { const prevAmount = confirmed[otherDid] || 0; confirmed[otherDid] = prevAmount + give.amount; } else { const prevAmount = unconfirmed[otherDid] || 0; unconfirmed[otherDid] = prevAmount + give.amount; } if (!descriptions[otherDid] && give.description) { descriptions[otherDid] = give.description; } } } } else { console.error( "Got bad response status & data of", resp.status, resp.data, ); this.$notify( { group: "alert", type: "danger", title: "Retrieval Error", text: "Got an error retrieving your " + (useRecipient ? "given" : "received") + " data from the server.", }, 5000, ); } }; try { const headers = await getHeaders(this.activeDid, this.$notify); const givenByUrl = this.apiServer + "/api/v2/report/gives?agentDid=" + encodeURIComponent(this.activeDid); const givenToUrl = this.apiServer + "/api/v2/report/gives?recipientDid=" + encodeURIComponent(this.activeDid); const [givenByMeResp, givenToMeResp] = await Promise.all([ this.axios.get(givenByUrl, { headers }), this.axios.get(givenToUrl, { headers }), ]); const givenByMeDescriptions = {}; const givenByMeConfirmed = {}; const givenByMeUnconfirmed = {}; handleResponse( givenByMeResp, givenByMeDescriptions, givenByMeConfirmed, givenByMeUnconfirmed, true, ); this.givenByMeDescriptions = givenByMeDescriptions; this.givenByMeConfirmed = givenByMeConfirmed; this.givenByMeUnconfirmed = givenByMeUnconfirmed; const givenToMeDescriptions = {}; const givenToMeConfirmed = {}; const givenToMeUnconfirmed = {}; handleResponse( givenToMeResp, givenToMeDescriptions, givenToMeConfirmed, givenToMeUnconfirmed, false, ); this.givenToMeDescriptions = givenToMeDescriptions; this.givenToMeConfirmed = givenToMeConfirmed; this.givenToMeUnconfirmed = givenToMeUnconfirmed; } catch (error) { const fullError = "Error loading gives: " + errorStringForLog(error); logConsoleAndDb(fullError, true); this.$notify( { group: "alert", type: "danger", title: "Load Error", text: "Got an error loading your gives.", }, 5000, ); } } private async onClickNewContact(): Promise<void> { const contactInput = this.contactInput.trim(); if (!contactInput) { this.danger( "There was no contact info to add. Try the other green buttons.", "No Contact", ); return; } if (contactInput.startsWith(CONTACT_URL_PREFIX)) { await this.addContactFromScan(contactInput); return; } if (contactInput.startsWith(CONTACT_CSV_HEADER)) { const lines = contactInput.split(/\n/); const lineAdded = []; for (const line of lines) { if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) { continue; } lineAdded.push(this.addContactFromEndorserMobileLine(line)); } try { await Promise.all(lineAdded); this.$notify( { group: "alert", type: "success", title: "Contacts Added", text: "Each contact was added. Nothing was sent to the server.", }, 3000, // keeping it up so that the "visibility" message is seen ); } catch (e) { const fullError = "Error adding contacts from CSV: " + errorStringForLog(e); logConsoleAndDb(fullError, true); this.danger("An error occurred. Some contacts may have been added."); } // .orderBy("name") wouldn't retrieve any entries with a blank name // .toCollection.sortBy("name") didn't sort in an order I understood const baseContacts = await db.contacts.toArray(); this.contacts = baseContacts.sort((a, b) => (a.name || "").localeCompare(b.name || ""), ); return; } if (contactInput.startsWith("did:")) { let did = contactInput; let name, publicKeyInput, nextPublicKeyHashInput; const commaPos1 = contactInput.indexOf(","); if (commaPos1 > -1) { did = contactInput.substring(0, commaPos1).trim(); name = contactInput.substring(commaPos1 + 1).trim(); const commaPos2 = contactInput.indexOf(",", commaPos1 + 1); if (commaPos2 > -1) { name = contactInput.substring(commaPos1 + 1, commaPos2).trim(); publicKeyInput = contactInput.substring(commaPos2 + 1).trim(); const commaPos3 = contactInput.indexOf(",", commaPos2 + 1); if (commaPos3 > -1) { publicKeyInput = contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier nextPublicKeyHashInput = contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier } } } // help with potential mistakes while this sharing requires copy-and-paste let publicKeyBase64 = publicKeyInput; 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", ); } let nextPubKeyHashB64 = nextPublicKeyHashInput; if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) { // it must be all hex (compressed public key), so convert nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier } const newContact = { did, name, publicKeyBase64, nextPubKeyHashB64: nextPubKeyHashB64, }; await this.addContact(newContact); return; } if (contactInput.includes("[")) { // assume there's a JSON array of contacts in the input const jsonContactInput = contactInput.substring( contactInput.indexOf("["), contactInput.lastIndexOf("]") + 1, ); try { const contacts = JSON.parse(jsonContactInput); (this.$router as Router).push({ name: "contact-import", query: { contacts: JSON.stringify(contacts) }, }); } catch (e) { const fullError = "Error adding contacts from array: " + errorStringForLog(e); logConsoleAndDb(fullError, true); this.danger("The input could not be parsed.", "Invalid Contact List"); } return; } this.danger("No contact info was found in that input.", "No Contact Info"); } private async addContactFromEndorserMobileLine( line: string, ): Promise<IndexableType> { // Note that Endorser Mobile puts name first, then did, etc. let name = line; let did = ""; let publicKeyInput, seesMe, registered; const commaPos1 = line.indexOf(","); if (commaPos1 > -1) { name = line.substring(0, commaPos1).trim(); did = line.substring(commaPos1 + 1).trim(); const commaPos2 = line.indexOf(",", commaPos1 + 1); if (commaPos2 > -1) { did = line.substring(commaPos1 + 1, commaPos2).trim(); publicKeyInput = line.substring(commaPos2 + 1).trim(); const commaPos3 = line.indexOf(",", commaPos2 + 1); if (commaPos3 > -1) { publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim(); seesMe = line.substring(commaPos3 + 1).trim() == "true"; const commaPos4 = line.indexOf(",", commaPos3 + 1); if (commaPos4 > -1) { seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true"; registered = line.substring(commaPos4 + 1).trim() == "true"; } } } } // help with potential mistakes while this sharing requires copy-and-paste let publicKeyBase64 = publicKeyInput; 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, seesMe, registered, }; return db.contacts.add(newContact); } private async addContactFromScan(url: string): Promise<void> { const payload = getContactPayloadFromJwtUrl(url); if (!payload) { this.$notify( { group: "alert", type: "danger", title: "No Contact Info", text: "The contact info could not be parsed.", }, 3000, ); return; } else { return this.addContact({ did: payload.iss, name: payload.own.name, nextPubKeyHashB64: payload.own.nextPublicEncKeyHash, profileImageUrl: payload.own.profileImageUrl, publicKeyBase64: payload.own.publicEncKey, registered: payload.own.registered, } as Contact); } } private async addContact(newContact: Contact) { if (!newContact.did) { this.danger("Cannot add a contact without a DID.", "Incomplete Contact"); return; } if (!isDid(newContact.did)) { this.danger("The DID must begin with 'did:'", "Invalid DID"); return; } return db.contacts .add(newContact) .then(() => { const allContacts = this.contacts.concat([newContact]); this.contacts = R.sort( (a: Contact, b) => (a.name || "").localeCompare(b.name || ""), allContacts, ); let addedMessage; if (this.activeDid) { this.setVisibility(newContact, true, false); newContact.seesMe = true; // didn't work inside setVisibility addedMessage = "They were added, and your activity is visible to them."; } else { addedMessage = "They were added."; } this.contactInput = ""; if (this.isRegistered) { if (!this.hideRegisterPromptOnNewContact && !newContact.registered) { setTimeout(() => { this.$notify( { group: "modal", type: "confirm", title: "Register", text: "Do you want to register them?", onCancel: async (stopAsking?: boolean) => { if (stopAsking) { await updateDefaultSettings({ hideRegisterPromptOnNewContact: stopAsking, }); this.hideRegisterPromptOnNewContact = stopAsking; } }, onNo: async (stopAsking?: boolean) => { if (stopAsking) { await updateDefaultSettings({ hideRegisterPromptOnNewContact: stopAsking, }); this.hideRegisterPromptOnNewContact = stopAsking; } }, onYes: async () => { await this.register(newContact); }, promptToStopAsking: true, }, -1, ); }, 500); } } this.$notify( { group: "alert", type: "success", title: "Contact Added", text: addedMessage, }, 3000, ); }) .catch((err) => { const fullError = "Error when adding contact to storage: " + errorStringForLog(err); logConsoleAndDb(fullError, true); let message = "An error prevented this import."; if ( err.message?.indexOf("Key already exists in the object store.") > -1 ) { message = "A contact with that DID is already in your contact list. Edit them directly below."; } if (err.name === "ConstraintError") { message += " Check that the contact doesn't conflict with any you already have."; } this.danger(message, "Contact Not Added", -1); }); } // note that this is also in DIDView.vue private async confirmSetVisibility(contact: Contact, visibility: boolean) { const visibilityPrompt = visibility ? "Are you sure you want to make your activity visible to them?" : "Are you sure you want to hide all your activity from them?"; this.$notify( { group: "modal", type: "confirm", title: "Set Visibility", text: visibilityPrompt, onYes: async () => { const success = await this.setVisibility(contact, visibility, true); if (success) { contact.seesMe = visibility; // didn't work inside setVisibility } }, }, -1, ); } // note that this is also in DIDView.vue private async register(contact: Contact) { this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); try { const regResult = await register( this.activeDid, this.apiServer, this.axios, contact, ); if (regResult.success) { contact.registered = true; await db.contacts.update(contact.did, { registered: true }); this.$notify( { group: "alert", type: "success", title: "Registration Success", text: (contact.name || "That unnamed person") + " has been registered.", }, 5000, ); } else { this.$notify( { group: "alert", type: "danger", title: "Registration Error", text: (regResult.error as string) || "Something went wrong during registration.", }, 5000, ); } } catch (error) { const fullError = "Error when registering: " + errorStringForLog(error); logConsoleAndDb(fullError, true); let userMessage = "There was an error."; const serverError = error as AxiosError; if (serverError.isAxiosError) { if ( serverError.response?.data && typeof serverError.response.data === "object" && "error" in serverError.response.data && typeof serverError.response.data.error === "object" && serverError.response.data.error !== null && "message" in serverError.response.data.error ) { userMessage = serverError.response.data.error.message as string; } else 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.$notify( { group: "alert", type: "danger", title: "Registration Error", text: userMessage, }, 5000, ); } } // note that this is also in DIDView.vue private async setVisibility( contact: Contact, visibility: boolean, showSuccessAlert: boolean, ) { const result = await setVisibilityUtil( this.activeDid, this.apiServer, this.axios, db, contact, visibility, ); if (result.success) { //contact.seesMe = visibility; // why doesn't it affect the UI from here? //console.log("Set result & seesMe", result, contact.seesMe, contact.did); if (showSuccessAlert) { this.$notify( { group: "alert", type: "success", title: "Visibility Set", text: (contact.name || "That user") + " can " + (visibility ? "" : "not ") + "see your activity.", }, 3000, ); } return true; } else { console.error( "Got strange result from setting visibility. It can happen when setting visibility on oneself.", result, ); const message = (result.error as string) || "Could not set visibility on the server."; this.$notify( { group: "alert", type: "danger", title: "Error Setting Visibility", text: message, }, 5000, ); return false; } } private confirmShowGiftedDialog(giverDid: string, recipientDid: string) { // if they have unconfirmed amounts, ask to confirm those if ( recipientDid === this.activeDid && this.givenToMeUnconfirmed[giverDid] > 0 ) { const isAre = this.givenToMeUnconfirmed[giverDid] == 1 ? "is" : "are"; const hours = this.givenToMeUnconfirmed[giverDid] == 1 ? "hour" : "hours"; const message = "There " + isAre + " " + this.givenToMeUnconfirmed[giverDid] + " unconfirmed " + hours + " from them." + " Would you like to confirm some of those hours?"; this.$notify( { group: "modal", type: "confirm", title: "Delete", text: message, onNo: async () => { this.showGiftedDialog(giverDid, recipientDid); }, onYes: async () => { (this.$router as Router).push({ name: "contact-amounts", query: { contactDid: giverDid }, }); }, }, -1, ); } else { this.showGiftedDialog(giverDid, recipientDid); } } private showGiftedDialog(giverDid: string, recipientDid: string) { let giver: libsUtil.GiverReceiverInputInfo | undefined; let receiver: libsUtil.GiverReceiverInputInfo | undefined; if (giverDid) { giver = { did: giverDid, name: libsUtil.nameForDid(this.activeDid, this.contacts, giverDid), }; } if (recipientDid) { receiver = { did: recipientDid, name: libsUtil.nameForDid(this.activeDid, this.contacts, recipientDid), }; } let callback: (amount: number) => void; let customTitle = ""; // choose whether to open dialog to user or from user if (giverDid == this.activeDid) { callback = (amount: number) => { const newList = R.clone(this.givenByMeUnconfirmed); newList[recipientDid] = (newList[recipientDid] || 0) + amount; this.givenByMeUnconfirmed = newList; }; customTitle = "Given to " + (receiver?.name || "Someone Unnamed"); } else { // must be (recipientDid == this.activeDid) callback = (amount: number) => { const newList = R.clone(this.givenToMeUnconfirmed); newList[giverDid] = (newList[giverDid] || 0) + amount; this.givenToMeUnconfirmed = newList; }; customTitle = "Received from " + (giver?.name || "Someone Unnamed"); } (this.$refs.customGivenDialog as GiftedDialog).open( giver, receiver, undefined as unknown as string, customTitle, undefined as unknown as string, callback, ); } openOfferDialog(recipientDid: string, recipientName?: string) { (this.$refs.customOfferDialog as OfferDialog).open( recipientDid, recipientName, ); } private async toggleShowContactAmounts() { const newShowValue = !this.showGiveNumbers; try { await updateDefaultSettings({ showContactGivesInline: newShowValue, }); } catch (err) { const fullError = "Error updating contact-amounts setting: " + errorStringForLog(err); logConsoleAndDb(fullError, true); this.$notify( { group: "alert", type: "danger", title: "Error Updating Contact Setting", text: "The setting may not have saved. Try again, maybe after restarting the app.", }, 5000, ); } this.showGiveNumbers = newShowValue; if ( newShowValue && Object.keys(this.givenByMeDescriptions).length === 0 && Object.keys(this.givenByMeConfirmed).length === 0 && Object.keys(this.givenByMeUnconfirmed).length === 0 && Object.keys(this.givenToMeDescriptions).length === 0 && Object.keys(this.givenToMeConfirmed).length === 0 && Object.keys(this.givenToMeUnconfirmed).length === 0 ) { // assume we should load it all this.loadGives(); } } private 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; } } private showGiveAmountsClassNames() { return { "from-slate-400": this.showGiveTotals, "to-slate-700": this.showGiveTotals, "from-green-400": !this.showGiveTotals && this.showGiveConfirmed, "to-green-700": !this.showGiveTotals && this.showGiveConfirmed, "from-yellow-400": !this.showGiveTotals && !this.showGiveConfirmed, "to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed, }; } private async copySelectedContacts() { if (this.contactsSelected.length === 0) { this.danger("You must select contacts to copy."); return; } const selectedContacts = this.contacts.filter((c) => this.contactsSelected.includes(c.did), ); console.log( "Array of selected contacts:", JSON.stringify(selectedContacts), ); const contactsJwt = await createEndorserJwtForDid(this.activeDid, { contacts: selectedContacts, }); const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt; useClipboard() .copy(contactsJwtUrl) .then(() => { this.$notify( { group: "alert", type: "info", title: "Copied", text: "The link for those contacts is now in the clipboard.", }, 5000, ); }); } private shortDid(did: string) { if (did.startsWith("did:peer:")) { return ( did.substring(0, "did:peer:".length + 2) + "..." + did.substring("did:peer:".length + 18, "did:peer:".length + 25) + "..." ); } else if (did.startsWith("did:ethr:")) { return did.substring(0, "did:ethr:".length + 9) + "..."; } else { return did.substring(0, did.indexOf(":", 4) + 7) + "..."; } } } </script>