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.
 
 
 
 
 
 

1419 lines
45 KiB

<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="APP_SERVER + '/help-onboarding'"
target="_blank"
class="help-link"
>
Onboarding Guide
</a>
</span>
</div>
<!-- New Contact Controls -->
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<span class="flex">
<router-link
v-if="isRegistered"
:to="{ name: 'invite-one' }"
class="btn-action-icon"
>
<font-awesome icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<span
v-else
class="btn-action-icon-disabled"
@click="showNotRegisteredWarning"
>
<font-awesome icon="envelope-open-text" class="fa-fw text-2xl" />
</span>
<button
class="btn-action-icon"
@click="
isRegistered
? showOnboardMeetingDialog()
: $router.push({ name: 'onboard-meeting-list' })
"
>
<font-awesome icon="chair" class="fa-fw text-2xl" />
</button>
<button class="btn-action-icon" @click="handleQRCodeClick">
<font-awesome icon="qrcode" class="fa-fw text-2xl" />
</button>
</span>
<textarea
v-model="contactInput"
type="text"
placeholder="New URL or DID, Name, Public Key, Next Public Key Hash"
class="contact-input"
/>
<button class="btn-primary-add" @click="onClickNewContact()">
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
<!-- Contact List Controls -->
<div v-if="contacts.length > 0" class="flex justify-between">
<div class="">
<div v-if="!showGiveNumbers" class="flex items-center">
<input
type="checkbox"
:checked="allContactsSelected"
class="contact-checkbox"
data-testId="contactCheckAllTop"
@click="toggleAllContacts"
/>
<button
:class="copyButtonClass"
data-testId="copySelectedContactsButtonTop"
@click="copySelectedContacts()"
>
Copy
</button>
<font-awesome
icon="circle-info"
class="info-icon"
@click="showCopySelectionsInfo()"
/>
</div>
</div>
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
class="btn-secondary"
:class="giveAmountsButtonClass"
@click="toggleShowGiveTotals()"
>
{{ giveAmountsButtonText }}
<font-awesome icon="left-right" class="fa-fw" />
</button>
<button class="btn-secondary" @click="toggleShowContactAmounts()">
{{ showGiveNumbers ? "Hide Actions" : "See Actions" }}
</button>
</div>
</div>
<div v-if="showGiveNumbers" class="give-help-text">
<div class="w-full text-center text-sm italic text-slate-600">
Only the most recent hours are included. <br />To see more, click
<span class="help-badge">
<font-awesome icon="file-lines" class="text-xs fa-fw" />
</span>
<br />
</div>
</div>
<!-- Results List -->
<ul v-if="contacts.length > 0" id="listContacts" class="contact-list">
<li
v-for="contact in filteredContacts()"
:key="contact.did"
class="contact-item"
data-testId="contactListItem"
>
<div class="flex items-center justify-between gap-3">
<div class="flex overflow-hidden min-w-0 items-center gap-3">
<input
v-if="!showGiveNumbers"
type="checkbox"
:checked="contactsSelected.includes(contact.did)"
class="contact-checkbox"
data-testId="contactCheckOne"
@click="toggleContact(contact.did)"
/>
<EntityIcon
:contact="contact"
:icon-size="48"
class="contact-icon"
@click="showLargeIdenticon = contact"
/>
<div class="overflow-hidden">
<h2 class="contact-name">
<router-link
:to="{ path: '/did/' + encodeURIComponent(contact.did) }"
title="See more about this person"
>
{{ contactNameNonBreakingSpace(contact.name) }}
</router-link>
</h2>
<div class="contact-details">
<router-link
:to="{ path: '/did/' + encodeURIComponent(contact.did) }"
title="See more about this person"
>
<font-awesome icon="circle-info" class="info-icon-small" />
</router-link>
<span class="contact-did">{{ contact.did }}</span>
</div>
<div class="contact-notes">
{{ contact.notes }}
</div>
</div>
</div>
<div
v-if="showGiveNumbers && contact.did != activeDid"
class="flex gap-1.5 items-end"
>
<div class="text-center">
<div class="give-label">From/To</div>
<div class="flex items-center">
<button
class="btn-give-left"
:title="givenToMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(contact.did, activeDid)"
>
{{ getGiveAmount(contact.did, "toMe") }}
</button>
<button
class="btn-give-right"
:title="givenByMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(activeDid, contact.did)"
>
{{ getGiveAmount(contact.did, "byMe") }}
</button>
</div>
</div>
<button
class="btn-offer"
data-testId="offerButton"
@click="openOfferDialog(contact.did, contact.name)"
>
Offer
</button>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="btn-details"
title="See more given activity"
>
<font-awesome icon="file-lines" class="fa-fw" />
</router-link>
</div>
</div>
</li>
</ul>
<p v-else>There are no contacts.</p>
<!-- Bottom Controls -->
<div v-if="contacts.length > 0" class="bottom-controls">
<input
v-if="!showGiveNumbers"
type="checkbox"
:checked="allContactsSelected"
class="contact-checkbox"
data-testId="contactCheckAllBottom"
@click="toggleAllContacts"
/>
<button
v-if="!showGiveNumbers"
:class="copyButtonClass"
@click="copySelectedContacts()"
>
Copy
</button>
</div>
<!-- Dialogs -->
<GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<!-- Large Identicon Modal -->
<div v-if="showLargeIdenticon" class="identicon-modal">
<div class="identicon-modal-bg">
<EntityIcon
:contact="showLargeIdenticon"
:icon-size="512"
class="identicon-modal-image"
@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 { Capacitor } from "@capacitor/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 { logConsoleAndDb } from "../db/index";
import { Contact } from "../db/tables/contacts";
// Removed unused import: databaseUtil - migrated to PlatformServiceMixin
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import {
CONTACT_CSV_HEADER,
createEndorserJwtForDid,
errorStringForLog,
getHeaders,
isDid,
register,
setVisibilityUtil,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { UserInfo, VerifiableCredentialClaim } from "../interfaces/common";
import { GiveSummaryRecord } from "../interfaces/records";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_BLANK_INVITE,
NOTIFY_INVITE_REGISTRATION_SUCCESS,
NOTIFY_INVITE_ERROR,
NOTIFY_ONBOARDING_CONFIRM,
NOTIFY_REGISTER_NOT_AVAILABLE,
NOTIFY_REGISTER_PERSON_SUCCESS,
NOTIFY_REGISTER_PERSON_ERROR,
NOTIFY_VISIBILITY_ERROR,
NOTIFY_UNCONFIRMED_HOURS,
NOTIFY_REGISTER_CONTACT,
NOTIFY_ONBOARDING_MEETING,
} from "@/constants/notifications";
@Component({
components: {
GiftedDialog,
EntityIcon,
OfferDialog,
QuickNav,
ContactNameDialog,
TopMessage,
},
mixins: [PlatformServiceMixin],
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
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;
APP_SERVER = APP_SERVER;
AppString = AppString;
libsUtil = libsUtil;
// Computed properties for template simplification
get allContactsSelected(): boolean {
return this.contactsSelected.length === this.contacts.length;
}
get copyButtonClass(): string {
return this.contactsSelected.length > 0 ? "btn-primary" : "btn-disabled";
}
get giveAmountsButtonClass(): string {
return this.showGiveTotals
? "from-green-400 to-green-700"
: this.showGiveConfirmed
? "from-blue-400 to-blue-700"
: "from-yellow-400 to-yellow-700";
}
get giveAmountsButtonText(): string {
return this.showGiveTotals
? "Totals"
: this.showGiveConfirmed
? "Confirmed Amounts"
: "Unconfirmed Amounts";
}
// Methods for template simplification
showNotRegisteredWarning(): void {
this.notify.warning(NOTIFY_REGISTER_NOT_AVAILABLE.message, TIMEOUTS.LONG);
}
toggleAllContacts(): void {
if (this.contactsSelected.length === this.contacts.length) {
this.contactsSelected = [];
} else {
this.contactsSelected = this.contacts.map((contact) => contact.did);
}
}
toggleContact(did: string): void {
const index = this.contactsSelected.indexOf(did);
if (index > -1) {
this.contactsSelected.splice(index, 1);
} else {
this.contactsSelected.push(did);
}
}
getGiveAmount(contactDid: string, direction: "toMe" | "byMe"): number {
const confirmedMap =
direction === "toMe" ? this.givenToMeConfirmed : this.givenByMeConfirmed;
const unconfirmedMap =
direction === "toMe"
? this.givenToMeUnconfirmed
: this.givenByMeUnconfirmed;
if (this.showGiveTotals) {
return (
(confirmedMap[contactDid] || 0) + (unconfirmedMap[contactDid] || 0)
);
} else if (this.showGiveConfirmed) {
return confirmedMap[contactDid] || 0;
} else {
return unconfirmedMap[contactDid] || 0;
}
}
public async created() {
this.notify = createNotifyHelpers(this.$notify);
const settingsRow = await this.$getSettingsRow([
"activeDid",
"apiServer",
"isRegistered",
"showContactGivesInline",
"hideRegisterPromptOnNewContact",
]);
this.activeDid = (settingsRow?.[0] as string) || "";
this.apiServer = (settingsRow?.[1] as string) || "";
this.isRegistered = !!settingsRow?.[2];
// if these detect a query parameter, they can and then redirect to this URL without a query parameter
// to avoid problems when they reload or they go forward & back and it tries to reprocess
await this.processContactJwt();
await this.processInviteJwt();
this.showGiveNumbers = !!settingsRow?.[3];
this.hideRegisterPromptOnNewContact = !!settingsRow?.[4];
if (this.showGiveNumbers) {
this.loadGives();
}
this.contacts = await this.$getAllContacts();
}
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
this.$router.push({ path: "/contacts" });
}
}
private async processInviteJwt() {
// handle an invite JWT sent via URL
const importedInviteJwt = this.$route.query["inviteJwt"] as string;
if (importedInviteJwt === "") {
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.notify.error(NOTIFY_BLANK_INVITE.message, TIMEOUTS.VERY_LONG);
} 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 this.$updateSettings({ isRegistered: true }, this.activeDid);
this.isRegistered = true;
this.notify.success(
NOTIFY_INVITE_REGISTRATION_SUCCESS.message,
TIMEOUTS.STANDARD,
);
// 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 VerifiableCredentialClaim;
const agentIdentifier = (
registration as {
vc?: { credentialSubject?: { agent?: { identifier?: string } } };
}
).vc?.credentialSubject?.agent?.identifier;
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?",
"",
async (name) => {
await this.addContact({
did: agentIdentifier ?? "",
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: agentIdentifier ?? "",
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();
},
);
} catch (error: unknown) {
const err = error as {
response?: { data?: { error?: { message?: string } } };
message?: string;
};
let message: string = NOTIFY_INVITE_ERROR.message;
if (err.response && err.response.data && err.response.data.error) {
if (err.response.data.error.message) {
message = err.response.data.error.message;
} else {
message =
typeof err.response.data.error === "string"
? err.response.data.error
: JSON.stringify(err.response.data.error);
}
} else if (typeof err.message === "string") {
message = err.message;
}
this.notify.error(message, TIMEOUTS.MODAL);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
this.$router.push({ path: "/contacts" });
}
}
private contactNameNonBreakingSpace(contactName?: string) {
return (contactName || AppString.NO_CONTACT_NAME).replace(/\s/g, "\u00A0");
}
private danger(message: string, _title: string = "Error", timeout = 5000) {
this.notify.error(message, timeout);
}
private showOnboardingInfo() {
this.notify.confirm(
NOTIFY_ONBOARDING_CONFIRM.message,
async () => {
this.$router.push({ name: "home" });
},
TIMEOUTS.MODAL,
);
}
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 {
logger.error(
"Got bad response status & data of",
resp.status,
resp.data,
);
const message = `Got an error retrieving your ${useRecipient ? "given" : "received"} data from the server.`;
this.notify.error(message, TIMEOUTS.STANDARD);
}
};
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.error("Got an error loading your gives.", TIMEOUTS.STANDARD);
}
}
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.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(contactInput);
if (!jwt) {
this.danger("Invalid contact URL format.", "Invalid URL");
return;
}
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // "did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return;
}
if (
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
) {
const jwt = getContactJwtFromJwtUrl(contactInput);
if (!jwt) {
this.danger("Invalid contact URL format.", "Invalid URL");
return;
}
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // "did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
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.success(
"Each contact was added. Nothing was sent to the server.",
TIMEOUTS.STANDARD, // 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.");
}
this.contacts = await this.$getAllContacts();
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.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(
lineRaw: string,
): Promise<IndexableType> {
const newContact = libsUtil.csvLineToContact(lineRaw);
await this.$insertContact(newContact);
return newContact.did || "";
}
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;
}
const contactPromise = this.$insertContact(newContact);
return contactPromise
.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: NOTIFY_REGISTER_CONTACT.title,
text: NOTIFY_REGISTER_CONTACT.text,
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
TIMEOUTS.MODAL,
);
}, 1000);
}
}
this.notify.success(addedMessage, TIMEOUTS.STANDARD);
})
.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", 5000);
});
}
// note that this is also in DIDView.vue
private async register(contact: Contact) {
this.notify.sent(TIMEOUTS.BRIEF);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
this.notify.success(
`${contact.name || "That unnamed person"} ${NOTIFY_REGISTER_PERSON_SUCCESS.message}`,
TIMEOUTS.STANDARD,
);
} else {
this.notify.error(
(regResult.error as string) || NOTIFY_REGISTER_PERSON_ERROR.message,
TIMEOUTS.MODAL,
);
}
} 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.error(userMessage, TIMEOUTS.MODAL);
}
}
// 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,
contact,
visibility,
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
if (showSuccessAlert) {
this.notify.success(
`${contact.name || "That user"} can ${visibility ? "" : "not "}see your activity.`,
TIMEOUTS.STANDARD,
);
}
return true;
} else {
logger.error(
"Got strange result from setting visibility. It can happen when setting visibility on oneself.",
result,
);
const message =
(result.error as string) || NOTIFY_VISIBILITY_ERROR.message;
this.notify.error(message, TIMEOUTS.LONG);
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: NOTIFY_UNCONFIRMED_HOURS.title,
text: message,
onNo: async () => {
this.showGiftedDialog(giverDid, recipientDid);
},
onYes: async () => {
this.$router.push({
name: "contact-amounts",
query: { contactDid: giverDid },
});
},
},
TIMEOUTS.MODAL,
);
} 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 this.$updateSettings({
showContactGivesInline: newShowValue,
});
} catch (err) {
const fullError =
"Error updating contact-amounts setting: " + errorStringForLog(err);
logConsoleAndDb(fullError, true);
this.notify.error(
"The setting may not have saved. Try again, maybe after restarting the app.",
TIMEOUTS.LONG,
);
}
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 selectedContactsFull = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
const selectedContacts: Array<Contact> = selectedContactsFull.map((c) => {
const contact: Contact = {
did: c.did,
name: c.name,
};
if (c.nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
}
if (c.profileImageUrl) {
contact.profileImageUrl = c.profileImageUrl;
}
if (c.publicKeyBase64) {
contact.publicKeyBase64 = c.publicKeyBase64;
}
return contact;
});
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {
this.notify.copied("contact link", TIMEOUTS.STANDARD);
});
}
private showCopySelectionsInfo() {
this.notify.info(
"Contact info will include name, ID, profile image, and public key.",
TIMEOUTS.LONG,
);
}
private async showOnboardMeetingDialog() {
try {
// First check if they're in a meeting
const headers = await getHeaders(this.activeDid);
const memberResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
if (memberResponse.data.data) {
// They're in a meeting, check if they're the host
const hostResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers },
);
if (hostResponse.data.data) {
// They're the host, take them to setup
this.$router.push({ name: "onboard-meeting-setup" });
} else {
// They're not the host, take them to list
this.$router.push({ name: "onboard-meeting-list" });
}
} else {
// They're not in a meeting, show the dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_ONBOARDING_MEETING.title,
text: NOTIFY_ONBOARDING_MEETING.text,
onYes: async () => {
this.$router.push({ name: "onboard-meeting-setup" });
},
yesText: NOTIFY_ONBOARDING_MEETING.yesText,
onNo: async () => {
this.$router.push({ name: "onboard-meeting-list" });
},
noText: NOTIFY_ONBOARDING_MEETING.noText,
},
TIMEOUTS.MODAL,
);
}
} catch (error) {
logConsoleAndDb(
"Error checking meeting status:" + errorStringForLog(error),
);
this.danger(
"There was an error checking your meeting status.",
"Meeting Error",
);
}
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
}
</script>
<style scoped>
/* Button Classes */
.help-link {
@apply 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;
}
.btn-action-icon {
@apply 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;
}
.btn-action-icon-disabled {
@apply 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 cursor-not-allowed;
}
.btn-primary {
@apply text-base 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-3 px-3
py-1.5 rounded-md cursor-pointer;
}
.btn-disabled {
@apply text-base bg-gradient-to-b from-slate-400 to-slate-700
shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-slate-300 ml-3 px-3
py-1.5 rounded-md cursor-not-allowed;
}
.btn-secondary {
@apply text-base bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)]
text-white px-3 py-1.5 rounded-md from-slate-400 to-slate-700;
}
.btn-primary-add {
@apply px-4 rounded-r bg-green-200 border border-green-400;
}
.btn-give-left {
@apply 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.5 py-1.5
rounded-l-md;
}
.btn-give-right {
@apply 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.5 py-1.5
rounded-r-md border-l;
}
.btn-offer {
@apply 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;
}
.btn-details {
@apply 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;
}
/* Input Classes */
.contact-input {
@apply block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10;
}
.contact-checkbox {
@apply align-middle ml-2 h-6 w-6 flex-shrink-0;
}
/* Layout Classes */
.contact-list {
@apply border-t border-slate-300 my-2;
}
.contact-item {
@apply border-b border-slate-300 pt-1 pb-1;
}
.contact-icon {
@apply shrink-0 align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden;
}
.contact-name {
@apply text-base font-semibold truncate;
}
.contact-details {
@apply flex gap-1.5 items-center overflow-hidden;
}
.contact-did {
@apply text-xs truncate;
}
.contact-notes {
@apply text-sm;
}
.bottom-controls {
@apply mt-2 w-full text-left;
}
.give-help-text {
@apply my-3;
}
.give-label {
@apply text-xs leading-none mb-1;
}
/* Info Icons */
.info-icon {
@apply text-2xl text-blue-500 ml-2;
}
.info-icon-small {
@apply text-base text-blue-500;
}
.help-badge {
@apply 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-0.5
rounded;
}
/* Modal Classes */
.identicon-modal {
@apply fixed z-[100] top-0 inset-x-0 w-full;
}
.identicon-modal-bg {
@apply absolute inset-0 h-screen flex flex-col items-center
justify-center bg-slate-900/50;
}
.identicon-modal-image {
@apply flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white
rounded-lg shadow-lg;
}
</style>