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.
1264 lines
41 KiB
1264 lines
41 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="/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
|
|
: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>
|
|
|
|
<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 { AppString, NotificationIface } from "@/constants/app";
|
|
import {
|
|
db,
|
|
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,
|
|
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
|
|
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: "warning",
|
|
title: "Blank Invite",
|
|
text: "The invite was not included. This can happen when your device cuts off the link, so you might 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,
|
|
);
|
|
|
|
// now add the inviter as a contact
|
|
const payload: JWTPayload =
|
|
decodeEndorserJwt(importedInviteJwt).payload;
|
|
const registration = payload as VerifiableCredential;
|
|
(this.$refs.contactNameDialog as ContactNameDialog).open(
|
|
"Who Invited You?",
|
|
"",
|
|
(name) => {
|
|
// not doing await on purpose, so that they always see the onboarding
|
|
this.addContact({
|
|
did: registration.vc.credentialSubject.agent.identifier,
|
|
name: name,
|
|
registered: true,
|
|
});
|
|
this.showOnboardingInfo();
|
|
},
|
|
() => {
|
|
// not doing await on purpose, so that they always see the onboarding
|
|
this.addContact({
|
|
did: registration.vc.credentialSubject.agent.identifier,
|
|
name: "(person who invited you)",
|
|
registered: true,
|
|
});
|
|
this.showOnboardingInfo();
|
|
},
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
console.error("Error redeeming invite:", error);
|
|
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);
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async onClickNewContact(): Promise<void> {
|
|
const contactInput = this.contactInput.trim();
|
|
if (!contactInput) {
|
|
this.danger("There was no contact info to add.", "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) {
|
|
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) {
|
|
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) => {
|
|
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);
|
|
});
|
|
}
|
|
|
|
// 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) {
|
|
console.error("Error when registering:", error);
|
|
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) {
|
|
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,
|
|
);
|
|
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();
|
|
}
|
|
}
|
|
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 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),
|
|
);
|
|
const message =
|
|
"To add contacts, paste this into the box on the 'Contacts' screen.\n\n" +
|
|
JSON.stringify(selectedContacts);
|
|
useClipboard()
|
|
.copy(message)
|
|
.then(() => {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Copied",
|
|
text: "Those contacts were copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
|
|
},
|
|
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>
|
|
|