forked from trent_larson/crowd-funder-for-time-pwa
Changes: - Move v-model directives before other attributes - Move v-bind directives before event handlers - Reorder attributes for better readability - Fix template attribute ordering across components - Improve eslint rules - add default vite config for testing (handles nostr error too) This follows Vue.js style guide recommendations for attribute ordering and improves template consistency.
394 lines
13 KiB
Vue
394 lines
13 KiB
Vue
<template>
|
|
<QuickNav selected="Contacts"></QuickNav>
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Back -->
|
|
<div class="text-lg text-center font-light relative px-7">
|
|
<h1
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
@click="$router.back()"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Heading -->
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
|
Contact Import
|
|
</h1>
|
|
|
|
<div v-if="checkingImports" class="text-center">
|
|
<font-awesome icon="spinner" class="animate-spin" />
|
|
</div>
|
|
<div v-else>
|
|
<span
|
|
v-if="contactsImporting.length > sameCount"
|
|
class="flex justify-center"
|
|
>
|
|
<input v-model="makeVisible" type="checkbox" class="mr-2" />
|
|
Make my activity visible to these contacts.
|
|
</span>
|
|
|
|
<div v-if="sameCount > 0">
|
|
<span v-if="sameCount == 1"
|
|
>One contact is the same as an existing contact</span
|
|
>
|
|
<span v-else
|
|
>{{ sameCount }} contacts are the same as existing contacts</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Results List -->
|
|
<ul
|
|
v-if="contactsImporting.length > sameCount"
|
|
class="border-t border-slate-300"
|
|
>
|
|
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
|
|
<div
|
|
v-if="
|
|
!contactsExisting[contact.did] ||
|
|
!R.isEmpty(contactDifferences[contact.did])
|
|
"
|
|
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
|
|
>
|
|
<h2 class="text-base font-semibold">
|
|
<input v-model="contactsSelected[index]" type="checkbox" />
|
|
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
|
-
|
|
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
|
|
>Existing</span
|
|
>
|
|
<span v-else class="text-green-500">New</span>
|
|
</h2>
|
|
<div class="text-sm truncate">
|
|
{{ contact.did }}
|
|
</div>
|
|
<div v-if="contactDifferences[contact.did]">
|
|
<div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div></div>
|
|
<div class="font-bold">Old Value</div>
|
|
<div class="font-bold">New Value</div>
|
|
</div>
|
|
<div
|
|
v-for="(value, contactField) in contactDifferences[
|
|
contact.did
|
|
]"
|
|
:key="contactField"
|
|
class="grid grid-cols-3 border"
|
|
>
|
|
<div class="border font-bold p-1">
|
|
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
|
|
</div>
|
|
<div class="border p-1">{{ value.old }}</div>
|
|
<div class="border p-1">{{ value.new }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<button
|
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
|
|
@click="importContacts"
|
|
>
|
|
Import Selected Contacts
|
|
</button>
|
|
</ul>
|
|
<p v-else-if="contactsImporting.length > 0">
|
|
All those contacts are already in your list with the same information.
|
|
</p>
|
|
<div v-else>
|
|
There are no contacts in that import. If some were sent, try again to
|
|
get the full text and paste it. (Note that iOS cuts off data in text
|
|
messages.) Ask the person to send the data a different way, eg. email.
|
|
<div class="mt-4 text-center">
|
|
<textarea
|
|
v-model="inputJwt"
|
|
placeholder="Contact-import data"
|
|
class="mt-4 border-2 border-gray-300 p-2 rounded"
|
|
cols="30"
|
|
@input="() => checkContactJwt(inputJwt)"
|
|
/>
|
|
<br />
|
|
<button
|
|
class="ml-2 p-2 bg-blue-500 text-white rounded"
|
|
@click="() => processContactJwt(inputJwt)"
|
|
>
|
|
Check Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import * as R from "ramda";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import OfferDialog from "../components/OfferDialog.vue";
|
|
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
|
|
import {
|
|
db,
|
|
logConsoleAndDb,
|
|
retrieveSettingsForActiveAccount,
|
|
} from "../db/index";
|
|
import { Contact, ContactMethod } from "../db/tables/contacts";
|
|
import * as libsUtil from "../libs/util";
|
|
import {
|
|
capitalizeAndInsertSpacesBeforeCaps,
|
|
errorStringForLog,
|
|
setVisibilityUtil,
|
|
} from "../libs/endorserServer";
|
|
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
|
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
|
|
|
@Component({
|
|
components: { EntityIcon, OfferDialog, QuickNav },
|
|
})
|
|
export default class ContactImportView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
$router!: Router;
|
|
|
|
AppString = AppString;
|
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
|
libsUtil = libsUtil;
|
|
R = R;
|
|
|
|
activeDid = "";
|
|
apiServer = "";
|
|
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
|
|
contactsImporting: Array<Contact> = []; // contacts from the import
|
|
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
|
|
contactDifferences: Record<
|
|
string,
|
|
Record<
|
|
string,
|
|
{
|
|
new: string | boolean | Array<ContactMethod> | undefined;
|
|
old: string | boolean | Array<ContactMethod> | undefined;
|
|
}
|
|
>
|
|
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
|
|
checkingImports = false;
|
|
inputJwt: string = "";
|
|
makeVisible = true;
|
|
sameCount = 0;
|
|
|
|
async created() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.activeDid = settings.activeDid || "";
|
|
this.apiServer = settings.apiServer || "";
|
|
|
|
// look for any imported contact array from the query parameter
|
|
const importedContacts = this.$route.query["contacts"] as string;
|
|
if (importedContacts) {
|
|
await this.setContactsSelected(JSON.parse(importedContacts));
|
|
}
|
|
|
|
// look for a JWT after /contact-import/ in the window.location.pathname
|
|
const jwt = window.location.pathname.match(
|
|
/\/contact-import\/(ey.+)$/,
|
|
)?.[1];
|
|
if (jwt) {
|
|
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
|
|
// eslint-disable-next-line prettier/prettier
|
|
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
|
|
// decode the JWT
|
|
const parsedJwt = decodeEndorserJwt(jwt);
|
|
|
|
const contacts: Array<Contact> =
|
|
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
|
|
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
|
|
if (!contacts && parsedJwt.payload.own) {
|
|
// handle this single-contact JWT in the contacts page, better suited to single additions
|
|
this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: jwt },
|
|
});
|
|
}
|
|
if (contacts) {
|
|
await this.setContactsSelected(contacts);
|
|
} else {
|
|
// no contacts found so default message should be OK
|
|
}
|
|
}
|
|
|
|
if (
|
|
this.contactsImporting.length === 1 &&
|
|
R.isEmpty(this.contactsExisting)
|
|
) {
|
|
// if there is only one contact and it's new, then we will automatically import it
|
|
this.contactsSelected[0] = true;
|
|
this.importContacts(); // ... which routes to the contacts list
|
|
}
|
|
}
|
|
|
|
async setContactsSelected(contacts: Array<Contact>) {
|
|
this.contactsImporting = contacts;
|
|
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
|
|
|
|
await db.open();
|
|
const baseContacts = await db.contacts.toArray();
|
|
// set the existing contacts, keyed by DID, if they exist in contactsImporting
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
const contactIn = this.contactsImporting[i];
|
|
const existingContact = baseContacts.find(
|
|
(contact) => contact.did === contactIn.did,
|
|
);
|
|
if (existingContact) {
|
|
this.contactsExisting[contactIn.did] = existingContact;
|
|
|
|
const differences: Record<
|
|
string,
|
|
{
|
|
new: string | boolean | Array<ContactMethod> | undefined;
|
|
old: string | boolean | Array<ContactMethod> | undefined;
|
|
}
|
|
> = {};
|
|
Object.keys(contactIn).forEach((key) => {
|
|
// eslint-disable-next-line prettier/prettier
|
|
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
|
|
differences[key] = {
|
|
old: existingContact[key as keyof Contact],
|
|
new: contactIn[key as keyof Contact],
|
|
};
|
|
}
|
|
});
|
|
this.contactDifferences[contactIn.did] = differences;
|
|
if (R.isEmpty(differences)) {
|
|
this.sameCount++;
|
|
}
|
|
|
|
// don't automatically import previous data
|
|
this.contactsSelected[i] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// check the contact-import JWT
|
|
async checkContactJwt(jwtInput: string) {
|
|
if (
|
|
jwtInput.endsWith(APP_SERVER) ||
|
|
jwtInput.endsWith(APP_SERVER + "/") ||
|
|
jwtInput.endsWith("contact-import") ||
|
|
jwtInput.endsWith("contact-import/")
|
|
) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
// process the invite JWT and/or text message containing the URL with the JWT
|
|
async processContactJwt(jwtInput: string) {
|
|
this.checkingImports = true;
|
|
|
|
try {
|
|
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
|
|
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
|
|
// JWT format: { header, payload, signature, data }
|
|
const payload = decodeEndorserJwt(jwt).payload;
|
|
|
|
if (Array.isArray(payload.contacts)) {
|
|
await this.setContactsSelected(payload.contacts);
|
|
} else {
|
|
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
|
|
}
|
|
} catch (error) {
|
|
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
|
logConsoleAndDb(fullError, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was an error processing the contact-import data.",
|
|
},
|
|
3000,
|
|
);
|
|
}
|
|
this.checkingImports = false;
|
|
}
|
|
|
|
async importContacts() {
|
|
this.checkingImports = true;
|
|
let importedCount = 0,
|
|
updatedCount = 0;
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
if (this.contactsSelected[i]) {
|
|
const contact = this.contactsImporting[i];
|
|
const existingContact = this.contactsExisting[contact.did];
|
|
if (existingContact) {
|
|
await db.contacts.update(contact.did, contact);
|
|
updatedCount++;
|
|
} else {
|
|
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
|
|
// DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key.
|
|
await db.contacts.add(R.clone(contact));
|
|
importedCount++;
|
|
}
|
|
}
|
|
}
|
|
if (this.makeVisible) {
|
|
const failedVisibileToContacts = [];
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
if (this.contactsSelected[i]) {
|
|
const contact = this.contactsImporting[i];
|
|
if (contact) {
|
|
const visResult = await setVisibilityUtil(
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
db,
|
|
contact,
|
|
true,
|
|
);
|
|
if (!visResult.success) {
|
|
failedVisibileToContacts.push(contact);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (failedVisibileToContacts.length > 0) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Visibility Error",
|
|
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
|
|
failedVisibileToContacts.length == 1 ? "" : "s"
|
|
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.checkingImports = false;
|
|
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Imported",
|
|
text:
|
|
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
|
|
(updatedCount ? ` ${updatedCount} updated.` : ""),
|
|
},
|
|
3000,
|
|
);
|
|
this.$router.push({ name: "contacts" });
|
|
}
|
|
}
|
|
</script>
|