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.
 
 
 
 
 
 

1339 lines
44 KiB

<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Contacts
</h1>
<div class="flex justify-between py-2 mt-8">
<span />
<span>
<a
:href="APP_SERVER + '/help-onboarding'"
target="_blank"
class="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 -->
<ContactInputForm
v-model="contactInput"
:is-registered="isRegistered"
:on-submit="onClickNewContact"
:on-show-onboard-meeting="showOnboardMeetingDialog"
:on-registration-required="
() =>
notify.warning(
'You must get registered before you can create invites.',
)
"
:on-navigate-onboard-meeting="
() => $router.push({ name: 'onboard-meeting-list' })
"
:on-update-model-value="(value: string) => (contactInput = value)"
@qr-scan="handleQRCodeClick"
/>
<ContactListHeader
v-if="contacts.length > 0"
:show-give-numbers="showGiveNumbers"
:all-contacts-selected="allContactsSelected"
:copy-button-class="copyButtonClass"
:copy-button-disabled="copyButtonDisabled"
:give-amounts-button-text="giveAmountsButtonText"
:show-actions-button-text="showActionsButtonText"
:give-amounts-button-class="showGiveAmountsClassNames()"
@toggle-all-selection="toggleAllContactsSelection"
@copy-selected="copySelectedContacts"
@show-copy-info="showCopySelectionsInfo"
@toggle-give-totals="toggleShowGiveTotals"
@toggle-show-actions="toggleShowContactAmounts"
/>
<div v-if="showGiveNumbers" class="my-3">
<div class="w-full text-center text-sm italic text-slate-600">
Only the most recent hours are included. <br />To see more, click
<span
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-0.5 rounded"
>
<font-awesome icon="file-lines" class="text-xs fa-fw" />
</span>
<br />
</div>
</div>
<!-- Results List -->
<ul
v-if="contacts.length > 0"
id="listContacts"
class="border-t border-slate-300 my-2"
>
<ContactListItem
v-for="contact in filteredContacts"
:key="contact.did"
:contact="contact"
:active-did="activeDid"
:show-checkbox="!showGiveNumbers"
:show-actions="showGiveNumbers"
:is-selected="contactsSelected.includes(contact.did)"
:show-give-totals="showGiveTotals"
:show-give-confirmed="showGiveConfirmed"
:given-to-me-descriptions="givenToMeDescriptions"
:given-to-me-confirmed="givenToMeConfirmed"
:given-to-me-unconfirmed="givenToMeUnconfirmed"
:given-by-me-descriptions="givenByMeDescriptions"
:given-by-me-confirmed="givenByMeConfirmed"
:given-by-me-unconfirmed="givenByMeUnconfirmed"
@toggle-selection="toggleContactSelection"
@show-identicon="showLargeIdenticon = $event"
@show-gifted-dialog="confirmShowGiftedDialog"
@open-offer-dialog="openOfferDialog"
/>
</ul>
<p v-else>There are no contacts.</p>
<ContactBulkActions
v-if="contacts.length > 0"
:show-give-numbers="showGiveNumbers"
:all-contacts-selected="allContactsSelected"
:copy-button-class="copyButtonClass"
:copy-button-disabled="copyButtonDisabled"
@toggle-all-selection="toggleAllContactsSelection"
@copy-selected="copySelectedContacts"
/>
<GiftedDialog
ref="customGivenDialog"
:giver-entity-type="'person'"
:recipient-entity-type="'person'"
/>
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<LargeIdenticonModal
:contact="showLargeIdenticon"
@close="showLargeIdenticon = undefined"
/>
</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";
// Capacitor import removed - using PlatformService instead
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 ContactListItem from "../components/ContactListItem.vue";
import ContactInputForm from "../components/ContactInputForm.vue";
import ContactListHeader from "../components/ContactListHeader.vue";
import ContactBulkActions from "../components/ContactBulkActions.vue";
import LargeIdenticonModal from "../components/LargeIdenticonModal.vue";
import { AppString, NotificationIface } from "../constants/app";
// Legacy logging import removed - using PlatformServiceMixin methods
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import {
CONTACT_CSV_HEADER,
createEndorserJwtForDid,
errorStringForLog,
getHeaders,
isDid,
register,
setVisibilityUtil,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer";
import { GiveSummaryRecord } from "@/interfaces/records";
import { UserInfo } from "@/interfaces/common";
import { VerifiableCredential } from "@/interfaces/claims-result";
import * as libsUtil from "../libs/util";
import {
generateSaveAndActivateIdentity,
contactsToExportJson,
} from "../libs/util";
import { logger } from "../utils/logger";
// No longer needed - using PlatformServiceMixin methods
// import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { isDatabaseError } from "@/interfaces/common";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
import { QRNavigationService } from "@/services/QRNavigationService";
import {
NOTIFY_CONTACT_NO_INFO,
NOTIFY_CONTACTS_ADD_ERROR,
NOTIFY_CONTACT_NO_DID,
NOTIFY_CONTACT_INVALID_DID,
NOTIFY_CONTACTS_ADDED_VISIBLE,
NOTIFY_CONTACTS_ADDED,
NOTIFY_CONTACTS_ADDED_CONFIRM,
NOTIFY_CONTACT_IMPORT_ERROR,
NOTIFY_CONTACT_IMPORT_CONFLICT,
NOTIFY_CONTACT_IMPORT_CONSTRAINT,
NOTIFY_CONTACT_SETTING_SAVE_ERROR,
NOTIFY_CONTACT_INFO_COPY,
NOTIFY_CONTACTS_SELECT_TO_COPY,
NOTIFY_CONTACT_LINK_COPIED,
NOTIFY_BLANK_INVITE,
NOTIFY_INVITE_REGISTRATION_SUCCESS,
NOTIFY_CONTACTS_ADDED_CSV,
NOTIFY_CONTACT_INPUT_PARSE_ERROR,
NOTIFY_CONTACT_NO_CONTACT_FOUND,
NOTIFY_GIVES_LOAD_ERROR,
NOTIFY_MEETING_STATUS_ERROR,
NOTIFY_REGISTRATION_ERROR_FALLBACK,
NOTIFY_REGISTRATION_ERROR_GENERIC,
NOTIFY_VISIBILITY_ERROR_FALLBACK,
NOTIFY_EXPORT_DATA_PROMPT,
getRegisterPersonSuccessMessage,
getVisibilitySuccessMessage,
getGivesRetrievalErrorMessage,
} from "@/constants/notifications";
/**
* ContactsView - Main contact management interface
*
* This view provides comprehensive contact management functionality including:
* - Contact display and filtering
* - Contact creation from various input formats (DID, JWT, CSV, JSON)
* - Contact selection and bulk operations
* - Give amounts display and management
* - Contact registration and visibility settings
* - QR code scanning integration
* - Meeting onboarding functionality
*
* The component uses the Enhanced Triple Migration Pattern with:
* - PlatformServiceMixin for database operations
* - Centralized notification constants
* - Computed properties for template streamlining
* - Refactored methods for maintainability
*
* @author Matthew Raymer
*/
@Component({
name: "ContactsView",
components: {
GiftedDialog,
EntityIcon,
OfferDialog,
QuickNav,
ContactNameDialog,
TopMessage,
ContactListItem,
ContactInputForm,
ContactListHeader,
ContactBulkActions,
LargeIdenticonModal,
},
mixins: [PlatformServiceMixin],
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
/** Notification helpers */
notify!: ReturnType<typeof createNotifyHelpers>;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
contactInput = "";
contactEdit: Contact | null = null;
contactNewName = "";
contactsSelected: Array<string> = [];
// { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
givenByMeConfirmed: Record<string, number> = {};
// { "did:...": amount } entry for each contact
givenByMeUnconfirmed: Record<string, number> = {};
// { "did:...": concatenated-descriptions } entry for each contact
givenToMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
givenToMeConfirmed: Record<string, number> = {};
// { "did:...": amount } entry for each contact
givenToMeUnconfirmed: Record<string, number> = {};
hideRegisterPromptOnNewContact = false;
isRegistered = false;
showDidCopy = false;
showPubKeyCopy = false;
showPubKeyHashCopy = false;
showGiveNumbers = false;
showGiveTotals = true;
showGiveConfirmed = true;
showLargeIdenticon?: Contact;
APP_SERVER = APP_SERVER;
AppString = AppString;
libsUtil = libsUtil;
/**
* Component lifecycle hook - Initialize component state and load data
* Sets up notification helpers, loads user settings, processes URL parameters,
* and loads contacts from database
*/
public async created() {
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
// if these detect a query parameter, they can and then redirect to this URL without a query parameter
// to avoid problems when they reload or they go forward & back and it tries to reprocess
await this.processContactJwt();
await this.processInviteJwt();
this.showGiveNumbers = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
if (this.showGiveNumbers) {
this.loadGives();
}
// Replace PlatformServiceFactory and manual SQL with mixin method
this.contacts = await this.$getAllContacts();
}
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
this.$router.push({ path: "/contacts" });
}
}
private async processInviteJwt() {
// handle an invite JWT sent via URL
const importedInviteJwt = this.$route.query["inviteJwt"] as string;
if (importedInviteJwt === "") {
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.notify.error(NOTIFY_BLANK_INVITE.message, TIMEOUTS.VERY_LONG);
} else if (importedInviteJwt) {
// Identity creation should be handled by router guard, but keep as fallback for invite processing
if (!this.activeDid) {
logger.info(
"[ContactsView] No active DID found, creating identity as fallback for invite processing",
);
this.activeDid = await generateSaveAndActivateIdentity();
}
// send invite directly to server, with auth for this user
const headers = await getHeaders(this.activeDid);
try {
const response = await this.axios.post(
this.apiServer + "/api/v2/claim",
{ jwtEncoded: importedInviteJwt },
{ headers },
);
if (response.status != 201) {
throw { error: { response: response } };
}
await this.$saveUserSettings(this.activeDid, { isRegistered: true });
this.isRegistered = true;
this.notify.success(NOTIFY_INVITE_REGISTRATION_SUCCESS.message);
// wait for a second before continuing so they see the registration message
await new Promise((resolve) => setTimeout(resolve, 1000));
// now add the inviter as a contact
// (similar code is in InviteOneAcceptView.vue)
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?",
"",
async (name) => {
await this.addContact({
did: (
registration.vc.credentialSubject.agent as {
identifier: string;
}
).identifier,
name: name,
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
async () => {
// on cancel, will still add the contact
await this.addContact({
did: (
registration.vc.credentialSubject.agent as {
identifier: string;
}
).identifier,
name: "(person who invited you)",
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
);
} catch (error: unknown) {
const fullError = "Error redeeming invite: " + errorStringForLog(error);
this.$logAndConsole(fullError, true);
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.error(message, TIMEOUTS.MODAL);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
this.$router.push({ path: "/contacts" });
}
}
// Legacy danger() and warning() methods removed - now using this.notify.error() and this.notify.warning()
private showOnboardingInfo() {
this.notify.confirm(
NOTIFY_CONTACTS_ADDED_CONFIRM.message,
async () => {
this.$router.push({ name: "home" });
},
-1,
);
}
// Computed properties for template streamlining
get filteredContacts() {
return this.showGiveNumbers
? this.contactsSelected.length === 0
? this.contacts
: this.contacts.filter((contact) =>
this.contactsSelected.includes(contact.did),
)
: this.contacts;
}
get copyButtonClass() {
return this.contactsSelected.length > 0
? "text-md bg-gradient-to-b from-blue-400 to-blue-700 " +
"shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white " +
"ml-3 px-3 py-1.5 rounded-md cursor-pointer"
: "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-slate-300 " +
"ml-3 px-3 py-1.5 rounded-md cursor-not-allowed";
}
get copyButtonDisabled() {
return this.contactsSelected.length === 0;
}
get giveAmountsButtonText() {
if (this.showGiveTotals) {
return "Totals";
}
return this.showGiveConfirmed ? "Confirmed Amounts" : "Unconfirmed Amounts";
}
get showActionsButtonText() {
return this.showGiveNumbers ? "Hide Actions" : "See Actions";
}
get allContactsSelected() {
return this.contactsSelected.length === this.contacts.length;
}
// Helper methods for template interactions
toggleAllContactsSelection(): void {
if (this.allContactsSelected) {
this.contactsSelected = [];
} else {
this.contactsSelected = this.contacts.map((contact) => contact.did);
}
}
toggleContactSelection(contactDid: string): void {
if (this.contactsSelected.includes(contactDid)) {
this.contactsSelected.splice(
this.contactsSelected.indexOf(contactDid),
1,
);
} else {
this.contactsSelected.push(contactDid);
}
}
private async loadGives() {
if (!this.activeDid) {
return;
}
const handleResponse = (
resp: { status: number; data: { data: GiveSummaryRecord[] } },
descriptions: Record<string, string>,
confirmed: Record<string, number>,
unconfirmed: Record<string, number>,
useRecipient: boolean,
) => {
if (resp.status === 200) {
const allData = resp.data.data;
for (const give of allData) {
const otherDid = useRecipient ? give.recipientDid : give.agentDid;
if (give.unit === "HUR") {
if (give.amountConfirmed) {
const prevAmount = confirmed[otherDid] || 0;
confirmed[otherDid] = prevAmount + give.amount;
} else {
const prevAmount = unconfirmed[otherDid] || 0;
unconfirmed[otherDid] = prevAmount + give.amount;
}
if (!descriptions[otherDid] && give.description) {
descriptions[otherDid] = give.description;
}
}
}
} else {
logger.error(
"Got bad response status & data of",
resp.status,
resp.data,
);
this.notify.error(getGivesRetrievalErrorMessage(useRecipient));
}
};
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) {
const fullError = "Error loading gives: " + errorStringForLog(error);
this.$logAndConsole(fullError, true);
this.notify.error(NOTIFY_GIVES_LOAD_ERROR.message);
}
}
/**
* Main method to handle new contact input processing
* Routes to appropriate parsing method based on input format
*/
private async onClickNewContact(): Promise<void> {
const contactInput = this.contactInput.trim();
if (!contactInput) {
this.notify.error(NOTIFY_CONTACT_NO_INFO.message);
return;
}
// Try different parsing methods in order
if (await this.tryParseJwtContact(contactInput)) return;
if (await this.tryParseCsvContacts(contactInput)) return;
if (await this.tryParseDidContact(contactInput)) return;
if (await this.tryParseJsonContacts(contactInput)) return;
// If no parsing method succeeded
this.notify.error(NOTIFY_CONTACT_NO_CONTACT_FOUND.message);
}
/**
* Parse contact from JWT URL format
*/
private async tryParseJwtContact(contactInput: string): Promise<boolean> {
if (
contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
) {
const jwt = getContactJwtFromJwtUrl(contactInput);
if (jwt) {
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"],
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return true;
}
}
return false;
}
/**
* Parse contacts from CSV format
*/
private async tryParseCsvContacts(contactInput: string): Promise<boolean> {
if (contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = contactInput.split(/\n/);
const lineAdded = [];
for (const line of lines) {
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
continue;
}
lineAdded.push(this.addContactFromEndorserMobileLine(line));
}
try {
await Promise.all(lineAdded);
this.notify.success(NOTIFY_CONTACTS_ADDED_CSV.message);
} catch (e) {
const fullError =
"Error adding contacts from CSV: " + errorStringForLog(e);
this.$logAndConsole(fullError, true);
this.notify.error(NOTIFY_CONTACTS_ADD_ERROR.message);
}
this.contacts = await this.$getAllContacts();
return true;
}
return false;
}
/**
* Parse contact from DID format with optional parameters
*/
private async tryParseDidContact(contactInput: string): Promise<boolean> {
if (contactInput.startsWith("did:")) {
const parsedContact = this.parseDidContactString(contactInput);
await this.addContact(parsedContact);
return true;
}
return false;
}
/**
* Parse DID contact string into Contact object
*/
private parseDidContactString(contactInput: string): Contact {
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();
nextPublicKeyHashInput = contactInput.substring(commaPos3 + 1).trim();
}
}
}
// Convert hex keys to base64 if needed
const publicKeyBase64 = this.convertHexToBase64(publicKeyInput);
const nextPubKeyHashB64 = this.convertHexToBase64(nextPublicKeyHashInput);
return {
did,
name,
publicKeyBase64,
nextPubKeyHashB64,
};
}
/**
* Convert hex string to base64 if it matches hex pattern
*/
private convertHexToBase64(hexString?: string): string | undefined {
if (!hexString || !/^[0-9A-Fa-f]{66}$/i.test(hexString)) {
return hexString;
}
return Buffer.from(hexString, "hex").toString("base64");
}
/**
* Parse contacts from JSON array format
*/
private async tryParseJsonContacts(contactInput: string): Promise<boolean> {
if (contactInput.includes("[")) {
const jsonContactInput = contactInput.substring(
contactInput.indexOf("["),
contactInput.lastIndexOf("]") + 1,
);
try {
const contacts = JSON.parse(jsonContactInput);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contacts) },
});
return true;
} catch (e) {
const fullError =
"Error adding contacts from array: " + errorStringForLog(e);
this.$logAndConsole(fullError, true);
this.notify.error(NOTIFY_CONTACT_INPUT_PARSE_ERROR.message);
}
}
return false;
}
private async addContactFromEndorserMobileLine(
lineRaw: string,
): Promise<IndexableType> {
const newContact = libsUtil.csvLineToContact(lineRaw);
// Replace PlatformServiceFactory with mixin method
await this.$insertContact(newContact);
// Return the DID as the indexable type for compatibility
return newContact.did as IndexableType;
}
/**
* Add a new contact to the database and update UI
* Validates contact data, inserts into database, updates local state,
* sets visibility, and handles registration prompts
*/
private async addContact(newContact: Contact) {
// Validate contact data
if (!this.validateContactData(newContact)) {
return;
}
try {
// Insert contact into database
await this.$insertContact(newContact);
// Update local contacts list
this.updateContactsList(newContact);
// Set visibility and get success message
const addedMessage = await this.handleContactVisibility(newContact);
// Clear input field
this.contactInput = "";
// Handle registration prompt if needed
await this.handleRegistrationPrompt(newContact);
// Show success notification
this.notify.success(addedMessage);
// Show export data prompt after successful contact addition
await this.showExportDataPrompt();
} catch (err) {
this.handleContactAddError(err);
}
}
/**
* Validate contact data before insertion
*/
private validateContactData(newContact: Contact): boolean {
if (!newContact.did) {
this.notify.error(NOTIFY_CONTACT_NO_DID.message);
return false;
}
if (!isDid(newContact.did)) {
this.notify.error(NOTIFY_CONTACT_INVALID_DID.message);
return false;
}
return true;
}
/**
* Update local contacts list with new contact
*/
private updateContactsList(newContact: Contact): void {
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
}
/**
* Handle contact visibility settings and return appropriate message
*/
private async handleContactVisibility(newContact: Contact): Promise<string> {
if (this.activeDid) {
await this.setVisibility(newContact, true, false);
newContact.seesMe = true;
return NOTIFY_CONTACTS_ADDED_VISIBLE.message;
} else {
return NOTIFY_CONTACTS_ADDED.message;
}
}
/**
* Handle registration prompt for new contacts
*/
private async handleRegistrationPrompt(newContact: Contact): Promise<void> {
if (
this.isRegistered === false || // the current Identity is not registered OR
this.hideRegisterPromptOnNewContact === true || // the user has hidden the registrationprompt OR
newContact.registered === true // the new contact is already registered
) {
// if any of the above are true, we do not want to show the registration prompt
return;
}
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onNo: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 1000);
}
/**
* Handle user response to registration prompt
*/
private async handleRegistrationPromptResponse(
stopAsking?: boolean,
): Promise<void> {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
}
/**
* Handle errors during contact addition
*/
private handleContactAddError(err: unknown): void {
const fullError =
"Error when adding contact to storage: " + errorStringForLog(err);
this.$logAndConsole(fullError, true);
let message = NOTIFY_CONTACT_IMPORT_ERROR.message;
// Use type-safe error checking with our new type guards
if (isDatabaseError(err)) {
if (err.message.includes("Key already exists in the object store")) {
message = NOTIFY_CONTACT_IMPORT_CONFLICT.message;
}
if (err.name === "ConstraintError") {
message += " " + NOTIFY_CONTACT_IMPORT_CONSTRAINT.message;
}
}
this.notify.error(message, TIMEOUTS.LONG);
}
/**
* Register a contact with the endorser server
* Sends registration request and updates contact status on success
* Note: This method is also used in DIDView.vue
*/
private async register(contact: Contact) {
this.notify.sent();
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
// Replace PlatformServiceFactory with mixin method
await this.$updateContact(contact.did, { registered: true });
this.notify.success(getRegisterPersonSuccessMessage(contact.name));
} else {
this.notify.error(
(regResult.error as string) ||
NOTIFY_REGISTRATION_ERROR_FALLBACK.message,
TIMEOUTS.MODAL,
);
}
} catch (error) {
const fullError = "Error when registering: " + errorStringForLog(error);
this.$logAndConsole(fullError, true);
let userMessage = NOTIFY_REGISTRATION_ERROR_GENERIC.message;
const serverError = error as AxiosError;
if (serverError.isAxiosError) {
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"error" in serverError.response.data &&
typeof serverError.response.data.error === "object" &&
serverError.response.data.error !== null &&
"message" in serverError.response.data.error
) {
userMessage = serverError.response.data.error.message as string;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.notify.error(userMessage, TIMEOUTS.MODAL);
}
}
/**
* Set visibility for a contact on the endorser server
* Note: This method is also used in DIDView.vue
*/
private async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
visibility,
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
if (showSuccessAlert) {
this.notify.success(
getVisibilitySuccessMessage(contact.name, visibility),
);
}
return true;
} else {
logger.error(
"Got strange result from setting visibility. It can happen when setting visibility on oneself.",
result,
);
const message =
(result.error as string) || NOTIFY_VISIBILITY_ERROR_FALLBACK.message;
this.notify.error(message, TIMEOUTS.LONG);
return false;
}
}
/**
* Confirm and show gifted dialog with unconfirmed amounts check
* If there are unconfirmed amounts, prompts user to confirm them first
*/
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.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;
// 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;
};
} else {
// must be (recipientDid == this.activeDid)
callback = (amount: number) => {
const newList = R.clone(this.givenToMeUnconfirmed);
newList[giverDid] = (newList[giverDid] || 0) + amount;
this.givenToMeUnconfirmed = newList;
};
}
(this.$refs.customGivenDialog as GiftedDialog).open(
giver,
receiver,
undefined as unknown as string,
undefined as unknown as string,
undefined as unknown as string,
undefined as unknown as string,
undefined as unknown as string,
callback,
);
}
openOfferDialog(recipientDid: string, recipientName?: string) {
(this.$refs.customOfferDialog as OfferDialog).open(
recipientDid,
recipientName,
);
}
private async toggleShowContactAmounts() {
const newShowValue = !this.showGiveNumbers;
try {
await this.$saveSettings({
showContactGivesInline: newShowValue,
});
} catch (err) {
const fullError =
"Error updating contact-amounts setting: " + errorStringForLog(err);
this.$logAndConsole(fullError, true);
// Use notification helper and constant
this.notify.error(
NOTIFY_CONTACT_SETTING_SAVE_ERROR.message,
TIMEOUTS.LONG,
);
}
this.showGiveNumbers = newShowValue;
if (
newShowValue &&
Object.keys(this.givenByMeDescriptions).length === 0 &&
Object.keys(this.givenByMeConfirmed).length === 0 &&
Object.keys(this.givenByMeUnconfirmed).length === 0 &&
Object.keys(this.givenToMeDescriptions).length === 0 &&
Object.keys(this.givenToMeConfirmed).length === 0 &&
Object.keys(this.givenToMeUnconfirmed).length === 0
) {
// assume we should load it all
this.loadGives();
}
}
private toggleShowGiveTotals() {
if (this.showGiveTotals) {
this.showGiveTotals = false;
this.showGiveConfirmed = true;
} else if (this.showGiveConfirmed) {
this.showGiveTotals = false; // stays the same
this.showGiveConfirmed = false;
} else {
this.showGiveTotals = true;
this.showGiveConfirmed = true;
}
}
private showGiveAmountsClassNames() {
return {
"from-slate-400": this.showGiveTotals,
"to-slate-700": this.showGiveTotals,
"from-green-400": !this.showGiveTotals && this.showGiveConfirmed,
"to-green-700": !this.showGiveTotals && this.showGiveConfirmed,
"from-yellow-400": !this.showGiveTotals && !this.showGiveConfirmed,
"to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed,
};
}
/**
* Copy selected contacts as a shareable JWT URL
* Creates a JWT containing selected contact data and copies to clipboard
*/
private async copySelectedContacts() {
if (this.contactsSelected.length === 0) {
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACTS_SELECT_TO_COPY.message);
return;
}
const selectedContactsFull = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
const selectedContacts: Array<Contact> = selectedContactsFull.map((c) => {
const contact: Contact = {
did: c.did,
name: c.name,
};
if (c.nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
}
if (c.profileImageUrl) {
contact.profileImageUrl = c.profileImageUrl;
}
if (c.publicKeyBase64) {
contact.publicKeyBase64 = c.publicKeyBase64;
}
return contact;
});
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
// Use production URL for sharing to avoid localhost issues in development
const contactsJwtUrl = `${APP_SERVER}/deep-link/contact-import/${contactsJwt}`;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {
// Use notification helper
this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message);
});
}
private showCopySelectionsInfo() {
// Use notification helper and constant
this.notify.info(NOTIFY_CONTACT_INFO_COPY.message, TIMEOUTS.LONG);
}
/**
* Show onboarding meeting dialog based on user's meeting status
* Checks if user is in a meeting and whether they are the host
*/
private async showOnboardMeetingDialog() {
try {
// First check if they're in a meeting
const headers = await getHeaders(this.activeDid);
const memberResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
if (memberResponse.data.data) {
// They're in a meeting, check if they're the host
const hostResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers },
);
if (hostResponse.data.data) {
// They're the host, take them to setup
this.$router.push({ name: "onboard-meeting-setup" });
} else {
// They're not the host, take them to list
this.$router.push({ name: "onboard-meeting-list" });
}
} else {
// They're not in a meeting, show the dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
onYes: async () => {
this.$router.push({ name: "onboard-meeting-setup" });
},
yesText: "Start New Meeting",
onNo: async () => {
this.$router.push({ name: "onboard-meeting-list" });
},
noText: "Join Existing Meeting",
},
-1,
);
}
} catch (error) {
this.$logAndConsole(
"Error checking meeting status:" + errorStringForLog(error),
);
// Use notification helper
this.notify.error(NOTIFY_MEETING_STATUS_ERROR.message);
}
}
/**
* Handle QR code button click - route to appropriate scanner
* Uses QRNavigationService to determine scanner type and route
*/
public handleQRCodeClick() {
this.$logAndConsole(
"[ContactsView] handleQRCodeClick method called",
false,
);
const qrNavigationService = QRNavigationService.getInstance();
const route = qrNavigationService.getQRScannerRoute();
this.$router.push(route);
}
/**
* Show export data prompt after adding a contact
* Prompts user to export their contact data as a backup
*/
private async showExportDataPrompt(): Promise<void> {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_EXPORT_DATA_PROMPT.title,
text: NOTIFY_EXPORT_DATA_PROMPT.message,
onYes: async () => {
await this.exportContactData();
},
yesText: "Export Data",
onNo: async () => {
// User chose not to export - no action needed
},
noText: "Not Now",
},
-1,
);
}, 1000); // Small delay to ensure success notification is shown first
}
/**
* Export contact data to JSON file
* Uses platform service to handle platform-specific export logic
*/
private async exportContactData(): Promise<void> {
// Note that similar code is in DataExportSection.vue exportDatabase()
try {
// Fetch all contacts from database
const allContacts = await this.$contacts();
// Convert contacts to export format
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Generate filename with current date
const today = new Date();
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format
const fileName = `timesafari-backup-contacts-${dateString}.json`;
// Use platform service to handle export
await this.platformService.writeAndShareFile(fileName, jsonStr);
this.notify.success(
"Contact export completed successfully. Check your downloads or share dialog.",
);
} catch (error) {
logger.error("Export Error:", error);
this.notify.error(
`There was an error exporting the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
}
</script>