<template> <QuickNav selected="Contacts"></QuickNav> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- 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> <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 class="mt-4 mb-4 flex items-stretch"> <router-link :to="{ name: 'contact-qr' }" 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="qrcode" class="fa-fw text-2xl" /> </router-link> <textarea type="text" placeholder="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-slate-200 border border-l-0 border-slate-400" @click="onClickNewContact()" > <fa icon="plus" class="fa-fw"></fa> </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 Given Hours" : "Show Given Hours" }} </button> </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 v-if="contacts.length > 0" class="border-t border-slate-300"> <li class="border-b border-slate-300 pt-2.5 pb-4" v-for="contact in contacts" :key="contact.did" > <div class="grow overflow-hidden"> <h2 class="text-base font-semibold"> <EntityIcon :contact="contact" :iconSize="24" class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer" @click="showLargeIdenticon = contact" /> {{ contact.name || AppString.NO_CONTACT_NAME }} <button @click=" contactEdit = contact; contactNewName = contact.name || ''; " title="Edit" > <fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa> </button> <router-link :to="{ path: '/did/' + encodeURIComponent(contact.did), }" title="See more about this DID" > <fa icon="circle-info" class="text-blue-500 ml-4" /> </router-link> </h2> <div class="text-sm truncate"> {{ contact.did }} <button @click=" libsUtil.doCopyTwoSecRedo( contact.did, () => (showDidCopy = !showDidCopy), ) " class="ml-2 mr-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showDidCopy">Copied DID</span> </div> <div class="text-sm truncate" v-if="contact.publicKeyBase64"> Public Key (base 64): {{ contact.publicKeyBase64 }} </div> <div class="text-sm truncate" v-if="contact.nextPubKeyHashB64"> Next Public Key Hash (base 64): {{ contact.nextPubKeyHashB64 }} </div> <div id="ContactActions" class="flex gap-1.5 mt-2"> <div v-if="activeDid"> <button v-if="contact.seesMe" 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="confirmSetVisibility(contact, false)" title="They can see you" > <fa icon="eye" class="fa-fw" /> </button> <button v-else 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="confirmSetVisibility(contact, true)" title="They cannot see you" > <fa icon="eye-slash" class="fa-fw" /> </button> <button 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="checkVisibility(contact)" title="Check Visibility" v-if="activeDid" > <fa icon="rotate" class="fa-fw" /> </button> <button @click="confirmRegister(contact)" 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 ml-6 px-2 py-1.5 rounded-md" v-if="activeDid" title="Registration" > <fa v-if="contact.registered" icon="person-circle-check" class="fa-fw" /> <fa v-else icon="person-circle-question" class="fa-fw" /> </button> </div> <button @click="confirmDeleteContact(contact)" class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md" title="Delete" > <fa icon="trash-can" class="fa-fw" /> </button> <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(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 */ }} <br /> <fa icon="plus" /> </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(contact.did, this.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 */ }} <br /> <fa icon="plus" /> </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)" > 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> <GiftedDialog ref="customGivenDialog" /> <OfferDialog ref="customOfferDialog" /> <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> <div v-if="contactEdit !== null" class="dialog-overlay"> <div class="dialog"> <h1 class="text-xl font-bold text-center mb-4">Edit Name</h1> <input type="text" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" placeholder="Name" v-model="contactNewName" /> <div class="flex justify-between"> <button class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400" @click="onClickSaveName(contactEdit, contactNewName)" > <fa icon="save" /> </button> <span class="inline-block w-2" /> <button class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400" @click="onClickCancelName()" > <fa icon="ban" /> </button> </div> </div> </div> </section> </template> <script lang="ts"> import { AxiosError } from "axios"; import { Buffer } from "buffer/"; import { IndexableType } from "dexie"; import * as R from "ramda"; import { Component, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; import { AppString, NotificationIface } from "@/constants/app"; import { db } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { getContactPayloadFromJwtUrl } from "@/libs/crypto"; import { CONTACT_CSV_HEADER, CONTACT_URL_PREFIX, GiverReceiverInputInfo, GiveSummaryRecord, getHeaders, isDid, register, setVisibilityUtil, } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; import QuickNav from "@/components/QuickNav.vue"; import EntityIcon from "@/components/EntityIcon.vue"; import GiftedDialog from "@/components/GiftedDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue"; @Component({ components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav }, }) export default class ContactsView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; activeDid = ""; apiServer = ""; contacts: Array<Contact> = []; contactInput = ""; contactEdit: Contact | null = null; contactNewName = ""; // { "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; showGiveNumbers = false; showGiveTotals = true; showGiveConfirmed = true; showLargeIdenticon?: Contact; AppString = AppString; libsUtil = libsUtil; async created() { await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; 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 || ""), ); } danger(message: string, title: string = "Error", timeout = 5000) { this.$notify( { group: "alert", type: "danger", title: title, text: message, }, timeout, ); } 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.", }, -1, ); } }; try { const headers = await getHeaders(this.activeDid); 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) { console.error("Error loading gives", error); this.$notify( { group: "alert", type: "danger", title: "Load Error", text: "Got an error loading your gives.", }, 5000, ); } } async onClickNewContact(): Promise<void> { if (!this.contactInput) { this.danger("There was no contact info to add.", "No Contact"); return; } if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) { await this.addContactFromScan(this.contactInput); return; } if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) { const lines = this.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) { 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; } let did = this.contactInput; let name, publicKeyInput, nextPublicKeyHashInput; 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(); publicKeyInput = this.contactInput.substring(commaPos2 + 1).trim(); const commaPos3 = this.contactInput.indexOf(",", commaPos2 + 1); if (commaPos3 > -1) { publicKeyInput = this.contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier nextPublicKeyHashInput = this.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); } 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); } 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); } } 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 db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: stopAsking, }); this.hideRegisterPromptOnNewContact = stopAsking; } }, onNo: async (stopAsking: boolean) => { if (stopAsking) { await db.settings.update(MASTER_SETTINGS_KEY, { 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) => { console.error("Error when adding contact to storage:", err); 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); }); } // prompt with confirmation if they want to delete a contact confirmDeleteContact(contact: Contact) { this.$notify( { group: "modal", type: "confirm", title: "Delete", text: "Are you sure you want to remove " + this.nameForDid(this.contacts, contact.did) + " with DID " + contact.did + " from your contact list?", onYes: async () => { await this.deleteContact(contact); }, }, -1, ); } async deleteContact(contact: Contact) { await db.open(); await db.contacts.delete(contact.did); this.contacts = R.without([contact], this.contacts); } // confirm to register a new contact async confirmRegister(contact: Contact) { this.$notify( { group: "modal", type: "confirm", title: "Register", text: "Are you sure you want to register " + this.nameForDid(this.contacts, contact.did) + (contact.registered ? " -- especially since they are already marked as registered" : "") + "?", onYes: async () => { await this.register(contact); }, }, -1, ); } 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; 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) { console.error("Error when registering:", error); let userMessage = "There was an error. See logs for more info."; const serverError = error as AxiosError; if (serverError) { if (serverError.response?.data?.error?.message) { userMessage = serverError.response.data.error.message; } 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, ); } } 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 () => { await this.setVisibility(contact, visibility, true); }, }, -1, ); } async setVisibility( contact: Contact, visibility: boolean, showSuccessAlert: boolean, ) { const result = await setVisibilityUtil( this.activeDid, this.apiServer, this.axios, db, contact, visibility, ); if (result.success) { if (showSuccessAlert) { this.$notify( { group: "alert", type: "success", title: "Visibility Set", text: (contact.name || "That user") + " can " + (visibility ? "" : "not ") + "see your activity.", }, 3000, ); } } else if (result.error) { this.$notify( { group: "alert", type: "danger", title: "Error Setting Visibility", text: result.error as string, }, 5000, ); } else { console.error("Got strange result from setting visibility:", result); } } async checkVisibility(contact: Contact) { const url = this.apiServer + "/api/report/canDidExplicitlySeeMe?did=" + encodeURIComponent(contact.did); const headers = await getHeaders(this.activeDid); if (!headers["Authorization"]) { this.$notify( { group: "alert", type: "danger", title: "No Identity", text: "There is no identity to use to check visibility.", }, 3000, ); return; } try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { const visibility = resp.data; contact.seesMe = visibility; // console.log("Visibility checked:", visibility, contact.did, contact.name); db.contacts.update(contact.did, { seesMe: visibility }); this.$notify( { group: "alert", type: "info", title: "Visibility Refreshed", text: this.nameForContact(contact, true) + " can " + (visibility ? "" : "not ") + "see your activity.", }, 3000, ); } else { console.error("Got bad server response checking visibility:", resp); const message = resp.data.error?.message || "Got bad server response."; this.$notify( { group: "alert", type: "danger", title: "Error Checking Visibility", text: message, }, 5000, ); } } catch (err) { console.error("Caught error from request to check visibility:", err); this.$notify( { group: "alert", type: "danger", title: "Error Checking Visibility", text: "Check connectivity and try again.", }, 3000, ); } } private nameForDid(contacts: Array<Contact>, did: string): string { if (did === this.activeDid) { return "you"; } const contact = R.find((con) => con.did == did, contacts); return this.nameForContact(contact); } private nameForContact(contact?: Contact, capitalize?: boolean): string { return ( (contact?.name as string) || (capitalize ? "This" : "this") + " unnamed user" ); } 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: GiverReceiverInputInfo, receiver: GiverReceiverInputInfo; if (giverDid) { giver = { did: giverDid, name: this.nameForDid(this.contacts, giverDid), }; } if (recipientDid) { receiver = { did: recipientDid, name: this.nameForDid(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; } 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; } (this.$refs.customGivenDialog as GiftedDialog).open( giver, receiver, undefined as string, customTitle, callback, ); } openOfferDialog(recipientDid: string) { (this.$refs.customOfferDialog as OfferDialog).open(recipientDid); } private async onClickCancelName() { this.contactEdit = null; this.contactNewName = ""; } private async onClickSaveName(contact: Contact, newName: string) { contact.name = newName; return db.contacts .update(contact.did, { name: newName }) .then(() => (this.contactEdit = null)); } public async toggleShowContactAmounts() { const newShowValue = !this.showGiveNumbers; try { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { showContactGivesInline: newShowValue, }); } catch (err) { 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.", }, -1, ); console.error( "Telling user to try again after contact-amounts setting update because:", err, ); } 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(); } } 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; } } public 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, }; } } </script> <style> .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; padding: 1.5rem; } .dialog { background-color: white; padding: 1rem; border-radius: 0.5rem; width: 100%; max-width: 500px; } /* Tooltip, generated on "title" attributes on "fa" icons Kudos to 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>