884 lines
28 KiB
Vue
884 lines
28 KiB
Vue
<template>
|
|
<QuickNav selected="Contacts"></QuickNav>
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Sub View Heading -->
|
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
|
Contact Import
|
|
</h1>
|
|
|
|
<!-- Back -->
|
|
<a
|
|
class="order-first text-lg text-center leading-none p-1"
|
|
@click="$router.go(-1)"
|
|
>
|
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
|
</a>
|
|
|
|
<!-- Help button -->
|
|
<router-link
|
|
:to="{ name: 'help' }"
|
|
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
|
>
|
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
|
</router-link>
|
|
</div>
|
|
|
|
<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 new contacts.
|
|
</span>
|
|
|
|
<!-- Labels Section -->
|
|
<div
|
|
v-if="contactsImporting"
|
|
class="mt-4 p-4 border-2 border-slate-700 rounded"
|
|
>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Labels for Imported Contacts
|
|
</label>
|
|
|
|
<!-- Apply to existing contacts checkbox -->
|
|
<div v-if="sameCount > 0" class="mb-3 flex items-center">
|
|
<input
|
|
id="applyLabelsToExisting"
|
|
v-model="applyLabelsToExisting"
|
|
type="checkbox"
|
|
class="mr-2"
|
|
/>
|
|
<label
|
|
for="applyLabelsToExisting"
|
|
class="text-sm text-gray-700 cursor-pointer"
|
|
>
|
|
Apply these labels to existing contacts (contacts that are already
|
|
in your list)
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Selected Labels -->
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
<span
|
|
v-for="label in selectedLabels"
|
|
:key="label"
|
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
|
>
|
|
{{ label }}
|
|
<button
|
|
type="button"
|
|
class="flex-shrink-0 ml-1.5 inline-flex text-blue-400 hover:text-blue-600 focus:outline-none"
|
|
@click="removeSelectedLabel(label)"
|
|
>
|
|
<font-awesome icon="xmark" class="h-4 w-4" />
|
|
</button>
|
|
</span>
|
|
<span
|
|
v-if="selectedLabels.length === 0"
|
|
class="text-xs text-gray-400 italic"
|
|
>
|
|
No labels selected
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Label Input -->
|
|
<div class="flex gap-2 mb-3">
|
|
<input
|
|
v-model="newLabelInput"
|
|
type="text"
|
|
placeholder="Add or create a label..."
|
|
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm p-2"
|
|
@keyup.enter="addNewLabel"
|
|
/>
|
|
<button
|
|
class="px-4 py-1 bg-green-500 text-white rounded-md text-sm whitespace-nowrap"
|
|
@click="addNewLabel"
|
|
>
|
|
Add Label
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Available Labels -->
|
|
<div v-if="availableLabels.length > 0" class="mt-3">
|
|
<p class="text-xs font-medium text-gray-700 mb-2">
|
|
Available labels (click to toggle):
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="label in availableLabels"
|
|
:key="label"
|
|
class="inline-flex items-center px-3 py-1 rounded text-sm font-medium transition-colors"
|
|
:class="
|
|
selectedLabels.includes(label)
|
|
? 'bg-blue-500 text-white hover:bg-blue-600'
|
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
|
"
|
|
@click="toggleLabel(label)"
|
|
>
|
|
{{ label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="sameCount > 0" class="mt-2">
|
|
<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="mt-2 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 v-if="contactField === 'labels'" class="border p-1">
|
|
{{ value.old.join(", ") }}
|
|
</div>
|
|
<div v-else class="border p-1">{{ value.old }}</div>
|
|
<div v-if="contactField === 'labels'" class="border p-1">
|
|
{{ value.new.join(", ") }}
|
|
</div>
|
|
<div v-else 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 Contacts
|
|
</button>
|
|
</ul>
|
|
<div v-else-if="contactsImporting.length > 0">
|
|
<p>
|
|
All those contacts are already in your list with the same information.
|
|
</p>
|
|
<div class="mt-3 flex flex-col items-center gap-2">
|
|
<button
|
|
data-testId="copyUnsignedImportLinkButton"
|
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white px-2 py-1.5 rounded w-fit"
|
|
@click="copyUnsignedImportLink"
|
|
>
|
|
Copy Unsigned Link for These Contacts
|
|
</button>
|
|
|
|
<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 px-2 py-1.5 rounded w-fit"
|
|
:class="{
|
|
'opacity-50 cursor-not-allowed': !canApplyLabelsToExisting,
|
|
}"
|
|
@click="handleApplyLabelsToExistingClick"
|
|
>
|
|
Apply Labels to Existing Contacts
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<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">
|
|
/**
|
|
* @file Contact Import View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component handles the import of contacts into the TimeSafari app.
|
|
* It supports multiple import methods and handles duplicate detection,
|
|
* contact validation, and visibility settings.
|
|
*
|
|
* Import Methods:
|
|
* 1. Direct URL Query Parameters:
|
|
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
|
|
*
|
|
* 2. JWT in URL Path:
|
|
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
|
|
* - Supports both single and bulk imports
|
|
* - JWT payload can be either:
|
|
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
|
|
* b) Single contact: { own: true, did: "...", name: "..." }
|
|
*
|
|
* 3. Manual JWT Input:
|
|
* - Accepts pasted JWT strings
|
|
* - Validates format and content before processing
|
|
*
|
|
* URL Examples:
|
|
* ```
|
|
* # Bulk import via query params
|
|
* /contact-import?contacts=[
|
|
* {"did":"did:example:123","name":"Alice"},
|
|
* {"did":"did:example:456","name":"Bob"}
|
|
* ]
|
|
*
|
|
* # Single contact via JWT
|
|
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
|
|
*
|
|
* # Bulk import via JWT
|
|
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
|
|
*
|
|
* # Redirect to contacts page (single contact)
|
|
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
|
|
* ```
|
|
*
|
|
* Features:
|
|
* - Automatic duplicate detection
|
|
* - Field-by-field comparison for existing contacts
|
|
* - Batch visibility settings
|
|
* - Auto-import for single new contacts
|
|
*
|
|
* @see {@link Contact} for contact data structure
|
|
* @see {@link setVisibilityUtil} for visibility management
|
|
*/
|
|
|
|
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 { copyToClipboard } from "../services/ClipboardService";
|
|
import {
|
|
Contact,
|
|
ContactWithLabels,
|
|
ContactMethod,
|
|
} from "../db/tables/contacts";
|
|
import * as libsUtil from "../libs/util";
|
|
import {
|
|
capitalizeAndInsertSpacesBeforeCaps,
|
|
errorStringForLog,
|
|
setVisibilityUtil,
|
|
} from "../libs/endorserServer";
|
|
import { parseContactImportInput } from "../libs/contactImportPayload";
|
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|
import { ContactLabel } from "@/db/tables/contactLabels";
|
|
|
|
type ContactDifferences = Record<
|
|
string,
|
|
{
|
|
new:
|
|
| string
|
|
| boolean
|
|
| Array<ContactMethod>
|
|
| Array<ContactLabel>
|
|
| undefined;
|
|
old:
|
|
| string
|
|
| boolean
|
|
| Array<ContactMethod>
|
|
| Array<ContactLabel>
|
|
| undefined;
|
|
}
|
|
>;
|
|
|
|
/**
|
|
* Contact Import View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component handles the secure import of contacts into TimeSafari via JWT tokens.
|
|
* It supports both single and multiple contact imports with validation and duplicate detection.
|
|
*
|
|
* Import Workflows:
|
|
* 1. JWT in URL Path (/contact-import/[JWT])
|
|
* - Extracts JWT from path
|
|
* - Decodes and validates contact data
|
|
* - Handles both single and multiple contacts
|
|
*
|
|
* 2. JWT in Query Parameter (/contacts?contactJwt=[JWT])
|
|
* - Used for single contact redirects
|
|
* - Processes JWT from query parameter
|
|
* - Redirects to appropriate view
|
|
*
|
|
* JWT Payload Structure:
|
|
* ```json
|
|
* {
|
|
* "iat": 1740740453,
|
|
* "contacts": [{
|
|
* "did": "did:ethr:0x...",
|
|
* "name": "Optional Name",
|
|
* "nextPubKeyHashB64": "base64 string",
|
|
* "publicKeyBase64": "base64 string"
|
|
* }],
|
|
* "iss": "did:ethr:0x..."
|
|
* }
|
|
* ```
|
|
*
|
|
* Security Features:
|
|
* - JWT validation
|
|
* - Issuer verification
|
|
* - Duplicate detection
|
|
* - Contact data validation
|
|
*
|
|
* @component
|
|
*/
|
|
@Component({
|
|
components: { EntityIcon, OfferDialog, QuickNav },
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class ContactImportView extends Vue {
|
|
/** Notification function injected by Vue */
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
/** Current route instance */
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
/** Router instance for navigation */
|
|
$router!: Router;
|
|
|
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
// Constants
|
|
AppString = AppString;
|
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
|
libsUtil = libsUtil;
|
|
R = R;
|
|
|
|
// Component state
|
|
/** Active user's DID for authentication and visibility settings */
|
|
activeDid = "";
|
|
/** API server URL for backend communication */
|
|
apiServer = "";
|
|
/** Map of existing contacts keyed by DID for duplicate detection */
|
|
contactsExisting: Record<string, ContactWithLabels> = {};
|
|
/** Array of contacts being imported from JWT */
|
|
contactsImporting: Array<ContactWithLabels> = [];
|
|
/** Selection state for each importing contact */
|
|
contactsSelected: Array<boolean> = [];
|
|
/** Each contact's differences between existing and importing info */
|
|
contactDifferences: Record<string, ContactDifferences> = {};
|
|
/** Loading state for import operations */
|
|
checkingImports = false;
|
|
/** JWT input for manual contact import */
|
|
inputJwt: string = "";
|
|
/** Visibility setting for imported contacts */
|
|
makeVisible = true;
|
|
/** Count of duplicate contacts found */
|
|
sameCount = 0;
|
|
|
|
// --- Labels ---
|
|
|
|
/** Labels selected to be applied to imported contacts */
|
|
selectedLabels: Array<string> = [];
|
|
/** Input for new label creation */
|
|
newLabelInput = "";
|
|
/** All available labels from existing contacts */
|
|
availableLabels: Array<string> = [];
|
|
/** Whether to apply labels to existing contacts as well */
|
|
applyLabelsToExisting = false;
|
|
|
|
/**
|
|
* Component lifecycle hook that initializes the contact import process
|
|
*
|
|
* This method handles three distinct import scenarios:
|
|
* 1. Query Parameter Import:
|
|
* - Checks for contacts in URL query parameters
|
|
* - Parses JSON array of contacts if present
|
|
*
|
|
* 2. JWT URL Import:
|
|
* - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$'
|
|
* - Decodes JWT without validation (supports future-dated QR codes)
|
|
* - Handles two JWT payload formats:
|
|
* a. Array format: payload.contacts or direct array
|
|
* b. Single contact format: redirects to contacts page with JWT
|
|
*
|
|
* 3. Auto-Import Logic:
|
|
* - Automatically imports if exactly one new contact is present
|
|
* - Only triggers if no existing contacts match
|
|
*
|
|
* @throws Will not throw but logs errors during JWT processing
|
|
* @emits router.push when redirecting for single contact import
|
|
*/
|
|
async created() {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
|
|
await this.initializeSettings();
|
|
await this.loadAvailableLabels();
|
|
await this.processQueryParams();
|
|
await this.processJwtFromPath();
|
|
await this.handleAutoImport();
|
|
}
|
|
|
|
/**
|
|
* Initializes component settings from active account
|
|
*/
|
|
private async initializeSettings() {
|
|
const settings = await this.$accountSettings();
|
|
|
|
// Get activeDid from active_identity table (single source of truth)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
|
this.activeDid = activeIdentity.activeDid || "";
|
|
|
|
this.apiServer = settings.apiServer || "";
|
|
}
|
|
|
|
/**
|
|
* Loads all available labels from existing contacts
|
|
*/
|
|
private async loadAvailableLabels() {
|
|
try {
|
|
this.availableLabels = await this.$getUniqueContactLabels();
|
|
} catch (error) {
|
|
const fullError =
|
|
"Error loading available labels: " + errorStringForLog(error);
|
|
this.$logAndConsole(fullError, true);
|
|
// Don't show error to user as this is non-critical
|
|
this.availableLabels = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes contacts from URL query parameters
|
|
*/
|
|
private async processQueryParams() {
|
|
const importedContacts = this.$route.query["contacts"] as string;
|
|
if (importedContacts) {
|
|
await this.setContactsSelected(JSON.parse(importedContacts));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes JWT from URL path and handles different JWT formats
|
|
*/
|
|
private async processJwtFromPath() {
|
|
const parsedImport = parseContactImportInput(window.location.pathname);
|
|
if (parsedImport.kind === "error") {
|
|
return;
|
|
}
|
|
|
|
if (parsedImport.kind === "single") {
|
|
this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: parsedImport.jwt },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsedImport.contacts.length === 1) {
|
|
this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: parsedImport.jwt },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsedImport.contacts.length > 0) {
|
|
await this.setContactsSelected(parsedImport.contacts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles automatic import for single new contacts
|
|
*/
|
|
private async handleAutoImport() {
|
|
if (
|
|
this.contactsImporting.length === 1 &&
|
|
R.isEmpty(this.contactsExisting)
|
|
) {
|
|
this.contactsSelected[0] = true;
|
|
await this.importContacts();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes contacts for import and checks for duplicates
|
|
* @param contacts Array of contacts to process
|
|
*/
|
|
async setContactsSelected(contacts: Array<ContactWithLabels>) {
|
|
this.contactsImporting = contacts;
|
|
this.contactsSelected = new Array(this.contactsImporting.length).fill(
|
|
false,
|
|
);
|
|
|
|
// Get all existing contacts for comparison
|
|
const baseContacts = await this.$getAllContacts();
|
|
|
|
// get the labels for each contact
|
|
|
|
// Check for existing contacts and differences
|
|
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) {
|
|
const labels = await this.$getContactLabelsForDid(existingContact.did);
|
|
this.contactsExisting[contactIn.did] = {
|
|
...existingContact,
|
|
labels: labels || [],
|
|
};
|
|
const existingFullContact = this.contactsExisting[contactIn.did];
|
|
|
|
// Compare contact fields for differences
|
|
const differences: ContactDifferences = {};
|
|
Object.keys(contactIn).forEach((key) => {
|
|
if (
|
|
!R.equals(
|
|
contactIn[key as keyof Contact],
|
|
existingFullContact[key as keyof Contact],
|
|
)
|
|
) {
|
|
differences[key] = {
|
|
old: existingFullContact[key as keyof Contact],
|
|
new: contactIn[key as keyof Contact],
|
|
};
|
|
}
|
|
});
|
|
this.contactDifferences[contactIn.did] = differences;
|
|
if (R.isEmpty(differences)) {
|
|
this.sameCount++;
|
|
} else {
|
|
// auto-select contacts with differences
|
|
this.contactsSelected[i] = true;
|
|
}
|
|
} else {
|
|
// auto-select new contacts
|
|
this.contactsSelected[i] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates contact import JWT format
|
|
* @param jwtInput JWT string to validate
|
|
*/
|
|
async checkContactJwt(jwtInput: string) {
|
|
const parsedImport = parseContactImportInput(jwtInput);
|
|
if (
|
|
parsedImport.kind === "error" &&
|
|
parsedImport.code === "truncated_data"
|
|
) {
|
|
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes contact import JWT and updates contacts
|
|
* @param jwtInput JWT string containing contact data
|
|
*/
|
|
async processContactJwt(jwtInput: string) {
|
|
this.checkingImports = true;
|
|
|
|
try {
|
|
const parsedImport = parseContactImportInput(jwtInput);
|
|
if (parsedImport.kind === "error") {
|
|
this.notify.error(parsedImport.message, TIMEOUTS.STANDARD);
|
|
return;
|
|
}
|
|
|
|
if (parsedImport.kind === "single") {
|
|
await this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: parsedImport.jwt },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsedImport.contacts.length === 1) {
|
|
await this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: parsedImport.jwt },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await this.setContactsSelected(parsedImport.contacts);
|
|
} catch (error) {
|
|
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
|
this.$logAndConsole(fullError, true);
|
|
this.notify.error(
|
|
"There was an error processing the contact-import data.",
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
this.checkingImports = false;
|
|
}
|
|
|
|
private buildUnsignedImportLink(): string {
|
|
const contactsForLink: Array<Contact> = this.contactsImporting.map((c) => {
|
|
const contact: Contact = {
|
|
did: c.did,
|
|
};
|
|
if (c.name) {
|
|
contact.name = c.name;
|
|
}
|
|
if (c.nextPubKeyHashB64) {
|
|
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
|
|
}
|
|
if (c.profileImageUrl) {
|
|
contact.profileImageUrl = c.profileImageUrl;
|
|
}
|
|
if (c.publicKeyBase64) {
|
|
contact.publicKeyBase64 = c.publicKeyBase64;
|
|
}
|
|
if (typeof c.registered === "boolean") {
|
|
contact.registered = c.registered;
|
|
}
|
|
return contact;
|
|
});
|
|
const contactsParam = encodeURIComponent(JSON.stringify(contactsForLink));
|
|
return `${APP_SERVER}/deep-link/contact-import?contacts=${contactsParam}`;
|
|
}
|
|
|
|
async copyUnsignedImportLink() {
|
|
if (this.contactsImporting.length === 0) {
|
|
this.notify.error(
|
|
"No contacts are loaded to build a link.",
|
|
TIMEOUTS.SHORT,
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
const link = this.buildUnsignedImportLink();
|
|
await copyToClipboard(link);
|
|
this.notify.copied("unsigned contact import link", TIMEOUTS.STANDARD);
|
|
} catch (error) {
|
|
const fullError =
|
|
"Error copying unsigned import link: " + errorStringForLog(error);
|
|
this.$logAndConsole(fullError, true);
|
|
this.notify.error("Failed to copy link to clipboard.", TIMEOUTS.STANDARD);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a new label to the selected labels list
|
|
*/
|
|
addNewLabel() {
|
|
const label = this.newLabelInput.trim();
|
|
if (label && !this.selectedLabels.includes(label)) {
|
|
this.selectedLabels.push(label);
|
|
// Add to available labels if it's new
|
|
if (!this.availableLabels.includes(label)) {
|
|
this.availableLabels.push(label);
|
|
this.availableLabels.sort();
|
|
}
|
|
this.newLabelInput = "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a label from the selected labels list
|
|
* @param label Label to remove
|
|
*/
|
|
removeSelectedLabel(label: string) {
|
|
const index = this.selectedLabels.indexOf(label);
|
|
if (index > -1) {
|
|
this.selectedLabels.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggles a label in the selected labels list
|
|
* @param label Label to toggle
|
|
*/
|
|
toggleLabel(label: string) {
|
|
const index = this.selectedLabels.indexOf(label);
|
|
if (index > -1) {
|
|
this.selectedLabels.splice(index, 1);
|
|
} else {
|
|
this.selectedLabels.push(label);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Imports selected contacts and sets visibility if requested
|
|
* Updates existing contacts or adds new ones
|
|
*/
|
|
async importContacts() {
|
|
this.checkingImports = true;
|
|
let importedCount = 0,
|
|
updatedCount = 0;
|
|
|
|
// Process selected contacts (new contacts or existing with changes)
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
if (this.contactsSelected[i]) {
|
|
const contactWithLabels = this.contactsImporting[i];
|
|
const contact = {
|
|
...contactWithLabels,
|
|
labels: undefined,
|
|
};
|
|
// Merge existing labels with selected labels for imported contacts
|
|
const existingLabels = contactWithLabels.labels || [];
|
|
const mergedLabels = Array.from(
|
|
new Set([...existingLabels, ...this.selectedLabels]),
|
|
);
|
|
const existingContact = this.contactsExisting[contact.did];
|
|
|
|
if (existingContact) {
|
|
// Update existing contact
|
|
await this.$updateContact(contact.did, contact);
|
|
// update the labels for the contact
|
|
await this.$updateContactLabels(contact.did, mergedLabels);
|
|
updatedCount++;
|
|
} else {
|
|
// Add new contact
|
|
await this.$insertContact(contact);
|
|
// add the labels for the contact
|
|
await this.$insertContactLabels(contact.did, mergedLabels);
|
|
importedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
let labelsAppliedToExistingCount = 0;
|
|
// Apply selected labels to existing contacts with no changes if checkbox is checked
|
|
if (this.applyLabelsToExisting && this.selectedLabels.length > 0) {
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
// Skip if this contact was already processed (selected)
|
|
if (!this.contactsSelected[i]) {
|
|
const contactWithLabels = this.contactsImporting[i];
|
|
const existingContact = this.contactsExisting[contactWithLabels.did];
|
|
|
|
// Only process existing contacts (not selected means no changes)
|
|
if (existingContact) {
|
|
// Get current labels for this contact
|
|
const currentLabels = await this.$getContactLabelsForDid(
|
|
contactWithLabels.did,
|
|
);
|
|
// Merge current labels with selected labels
|
|
const mergedLabels = Array.from(
|
|
new Set([...currentLabels, ...this.selectedLabels]),
|
|
);
|
|
// Update labels if there are new ones to add
|
|
if (mergedLabels.length > currentLabels.length) {
|
|
await this.$updateContactLabels(
|
|
contactWithLabels.did,
|
|
mergedLabels,
|
|
);
|
|
labelsAppliedToExistingCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set visibility if requested
|
|
if (this.makeVisible) {
|
|
const failedVisibileToContacts = [];
|
|
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) {
|
|
const visResult = await setVisibilityUtil(
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
contact,
|
|
true,
|
|
);
|
|
if (!visResult.success) {
|
|
failedVisibileToContacts.push(contact);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (failedVisibileToContacts.length > 0) {
|
|
this.notify.error(
|
|
`Failed to set visibility for ${failedVisibileToContacts.length} contact${
|
|
failedVisibileToContacts.length == 1 ? "" : "s"
|
|
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
|
|
TIMEOUTS.MODAL,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.checkingImports = false;
|
|
|
|
// Show success notification
|
|
this.notify.success(
|
|
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
|
|
(updatedCount ? ` ${updatedCount} updated.` : "") +
|
|
(labelsAppliedToExistingCount
|
|
? ` ${labelsAppliedToExistingCount} labels applied to existing contacts.`
|
|
: ""),
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
this.$router.push({ name: "contacts" });
|
|
}
|
|
|
|
private get canApplyLabelsToExisting(): boolean {
|
|
return this.applyLabelsToExisting && this.selectedLabels.length > 0;
|
|
}
|
|
|
|
private async handleApplyLabelsToExistingClick() {
|
|
if (!this.canApplyLabelsToExisting) {
|
|
this.notify.warning(
|
|
`You must choose some labels and check the "Apply" checkbox to use this.`,
|
|
TIMEOUTS.LONG,
|
|
);
|
|
return;
|
|
}
|
|
await this.importContacts();
|
|
}
|
|
}
|
|
</script>
|