Files
crowd-funder-for-time-pwa/src/views/ContactImportView.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>