<template> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <div class="mb-2"> <h1 class="text-2xl text-center font-semibold relative px-7"> <!-- Back --> <a class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="handleBack" > <font-awesome icon="chevron-left" class="fa-fw" /> </a> <!-- Quick Help --> <a class="text-2xl text-center text-blue-500 px-2 py-1 absolute -right-2 -top-1" @click="toastQRCodeHelp()" > <font-awesome icon="circle-question" class="fa-fw" /> </a> Share Contact Info </h1> </div> <div v-if="!givenName" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4" > <p class="mb-2"> <b>Note:</b> your identity currently does <b>not</b> include a name. </p> <button class="inline-block text-md 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-4 py-2 rounded-md" @click="openUserNameDialog" > Set Your Name </button> </div> <UserNameDialog ref="userNameDialog" /> <div v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)" class="block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4" @click="onCopyUrlToClipboard()" > <!-- Play with display options: https://qr-code-styling.com/ See docs: https://www.npmjs.com/package/qr-code-generator-vue3 --> <QRCodeVue3 :value="qrValue" :width="606" :height="606" :corners-square-options="{ type: 'square' }" :dots-options="{ type: 'square', color: '#000' }" /> </div> <div v-else-if="activeDid" class="text-center my-4"> <!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) --> <span class="text-blue-500" @click="onCopyDidToClipboard()"> Click here to copy your DID to your clipboard. </span> <span> Then give it to them so they can paste it in their list of People. </span> </div> <div v-else class="text-center my-4"> You have no identitifiers yet, so <router-link :to="{ name: 'start' }" class="bg-blue-500 text-white px-1.5 py-1 rounded-md" > create your identifier. </router-link> <br /> If you don't do that first, these contacts won't see your activity. </div> <div class="text-center mt-6"> <div v-if="isScanning" class="relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto" > <!-- Status Message --> <div class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10" > <div v-if="cameraState === 'initializing'" class="flex items-center justify-center space-x-2" > <svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" > <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" ></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" ></path> </svg> <span>{{ cameraStateMessage || "Initializing camera..." }}</span> </div> <p v-else-if="cameraState === 'active'" class="flex items-center justify-center space-x-2" > <span class="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse" ></span> <span>Position QR code in the frame</span> </p> <p v-else-if="error" class="text-red-400">Error: {{ error }}</p> <p v-else class="flex items-center justify-center space-x-2"> <span :class="{ 'inline-block w-2 h-2 rounded-full': true, 'bg-green-500': cameraState === 'ready', 'bg-yellow-500': cameraState === 'in_use', 'bg-red-500': cameraState === 'error' || cameraState === 'permission_denied' || cameraState === 'not_found', 'bg-blue-500': cameraState === 'off', }" ></span> <span>{{ cameraStateMessage || "Ready to scan" }}</span> </p> </div> <qrcode-stream v-if="useQRReader" :camera="preferredCamera" class="qr-scanner" @decode="onDecode" @init="onInit" @detect="onDetect" @error="onError" @camera-on="onCameraOn" @camera-off="onCameraOff" /> </div> </div> </section> </template> <script lang="ts"> import { AxiosError } from "axios"; import { Buffer } from "buffer/"; import QRCodeVue3 from "qr-code-generator-vue3"; import { Component, Vue } from "vue-facing-decorator"; import { useClipboard } from "@vueuse/core"; import { QrcodeStream } from "vue-qrcode-reader"; import QuickNav from "../components/QuickNav.vue"; import UserNameDialog from "../components/UserNameDialog.vue"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { Contact } from "../db/tables/contacts"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import * as databaseUtil from "../db/databaseUtil"; import { parseJsonField } from "../db/databaseUtil"; import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { CONTACT_CSV_HEADER, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, generateEndorserJwtUrlForAccount, register, setVisibilityUtil, } from "../libs/endorserServer"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import * as libsUtil from "../libs/util"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { CameraState } from "@/services/QRScanner/types"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { Account } from "@/db/tables/accounts"; interface QRScanResult { rawValue?: string; barcode?: string; } interface IUserNameDialog { open: (callback: (name: string) => void) => void; } @Component({ components: { QRCodeVue3, QuickNav, UserNameDialog, QrcodeStream, }, }) export default class ContactQRScanShow extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; $router!: Router; activeDid = ""; apiServer = ""; givenName = ""; hideRegisterPromptOnNewContact = false; isRegistered = false; qrValue = ""; isScanning = false; profileImageUrl = ""; error: string | null = null; // QR Scanner properties isInitializing = true; initializationStatus = "Initializing camera..."; useQRReader = __USE_QR_READER__; preferredCamera: "user" | "environment" = "environment"; cameraState: CameraState = "off"; cameraStateMessage?: string; ETHR_DID_PREFIX = ETHR_DID_PREFIX; // Add new properties to track scanning state private lastScannedValue: string = ""; private lastScanTime: number = 0; private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds // Add cleanup tracking private isCleaningUp = false; private isMounted = false; // Add property to track if we're on desktop private isDesktop = false; private isFrontCamera = false; async created() { try { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.givenName = settings.firstName || ""; this.hideRegisterPromptOnNewContact = !!settings.hideRegisterPromptOnNewContact; this.isRegistered = !!settings.isRegistered; this.profileImageUrl = settings.profileImageUrl || ""; const account = await libsUtil.retrieveAccountMetadata(this.activeDid); if (account) { const name = (settings.firstName || "") + (settings.lastName ? ` ${settings.lastName}` : ""); const publicKeyBase64 = Buffer.from( account.publicKeyHex, "hex", ).toString("base64"); this.qrValue = CONTACT_CSV_HEADER + "\n" + `"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`; } } catch (error) { logger.error("Error initializing component:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); this.$notify({ group: "alert", type: "danger", title: "Initialization Error", text: "Failed to initialize QR renderer or scanner. Please try again.", }); } } async handleBack(): Promise<void> { await this.cleanupScanner(); this.$router.back(); } async startScanning() { if (this.isCleaningUp) { logger.debug("Cannot start scanning during cleanup"); return; } try { this.error = null; this.isScanning = true; this.lastScannedValue = ""; this.lastScanTime = 0; const scanner = QRScannerFactory.getInstance(); // Add camera state listener scanner.addCameraStateListener({ onStateChange: (state, message) => { this.cameraState = state; this.cameraStateMessage = message; // Update UI based on camera state switch (state) { case "in_use": this.error = "Camera is in use by another application"; this.isScanning = false; this.$notify( { group: "alert", type: "warning", title: "Camera in Use", text: "Please close other applications using the camera and try again", }, 5000, ); break; case "permission_denied": this.error = "Camera permission denied"; this.isScanning = false; this.$notify( { group: "alert", type: "warning", title: "Camera Access Required", text: "Please grant camera permission to scan QR codes", }, 5000, ); break; case "not_found": this.error = "No camera found"; this.isScanning = false; this.$notify( { group: "alert", type: "warning", title: "No Camera", text: "No camera was found on this device", }, 5000, ); break; case "error": this.error = this.cameraStateMessage || "Camera error"; this.isScanning = false; break; } }, }); // Check if scanning is supported first if (!(await scanner.isSupported())) { this.error = "Camera access requires HTTPS. Please use a secure connection."; this.isScanning = false; this.$notify( { group: "alert", type: "warning", title: "HTTPS Required", text: "Camera access requires a secure (HTTPS) connection", }, 5000, ); return; } // Start scanning await scanner.startScan(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); this.isScanning = false; logger.error("Error starting scan:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } async stopScanning() { try { const scanner = QRScannerFactory.getInstance(); await scanner.stopScan(); } catch (error) { logger.error("Error stopping scan:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } finally { this.isScanning = false; this.lastScannedValue = ""; this.lastScanTime = 0; } } async cleanupScanner() { if (this.isCleaningUp) { return; } this.isCleaningUp = true; try { logger.info("Cleaning up QR scanner resources"); await this.stopScanning(); await QRScannerFactory.cleanup(); } catch (error) { logger.error("Error during scanner cleanup:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } finally { this.isCleaningUp = false; } } danger(message: string, title: string = "Error", timeout = 5000) { this.$notify( { group: "alert", type: "danger", title: title, text: message, }, timeout, ); } /** * Handle QR code scan result with debouncing to prevent duplicate scans */ async onScanDetect(result: string | QRScanResult) { try { // Extract raw value from different possible formats const rawValue = typeof result === "string" ? result : result?.rawValue || result?.barcode; if (!rawValue) { logger.warn("Invalid scan result - no value found:", result); return; } // Debounce duplicate scans const now = Date.now(); if ( rawValue === this.lastScannedValue && now - this.lastScanTime < this.SCAN_DEBOUNCE_MS ) { logger.info("Ignoring duplicate scan:", rawValue); return; } // Update scan tracking this.lastScannedValue = rawValue; this.lastScanTime = now; logger.info("Processing QR code scan result:", rawValue); let contact: Contact; if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) { const jwt = getContactJwtFromJwtUrl(rawValue); if (!jwt) { logger.warn("Invalid QR code format - no JWT found in URL"); this.$notify({ group: "alert", type: "danger", title: "Invalid QR Code", text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.", }); return; } logger.info("Decoding JWT payload from QR code"); const decodedJwt = await decodeEndorserJwt(jwt); // Process JWT and contact info if (!decodedJwt?.payload?.own) { logger.warn("Invalid JWT payload - missing 'own' field"); this.$notify({ group: "alert", type: "danger", title: "Invalid Contact Info", text: "The contact information is incomplete or invalid.", }); return; } const contactInfo = decodedJwt.payload.own; const did = contactInfo.did || decodedJwt.payload.iss; if (!did) { logger.warn("Invalid contact info - missing DID"); this.$notify({ group: "alert", type: "danger", title: "Invalid Contact", text: "The contact DID is missing.", }); return; } // Create contact object contact = { did: did, name: contactInfo.name || "", publicKeyBase64: contactInfo.publicKeyBase64 || "", seesMe: contactInfo.seesMe || false, registered: contactInfo.registered || false, }; } else if (rawValue.startsWith(CONTACT_CSV_HEADER)) { const lines = rawValue.split(/\n/); contact = libsUtil.csvLineToContact(lines[1]); } else { this.$notify({ group: "alert", type: "danger", title: "Error", text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.", }); return; } // Add contact but keep scanning logger.info("Adding new contact to database:", { did: contact.did, name: contact.name, }); await this.addNewContact(contact); } catch (error) { logger.error("Error processing contact QR code:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); this.$notify({ group: "alert", type: "danger", title: "Error", text: error instanceof Error ? error.message : "Could not process QR code. Please try again.", }); } } async setVisibility(contact: Contact, visibility: boolean) { const result = await setVisibilityUtil( this.activeDid, this.apiServer, this.axios, db, contact, visibility, ); if (result.error) { this.danger(result.error as string, "Error Setting Visibility"); } else if (!result.success) { logger.warn("Unexpected result from setting visibility:", result); } } async register(contact: Contact) { logger.info("Submitting contact registration", { did: contact.did, name: contact.name, }); this.$notify( { group: "alert", type: "toast", text: "", title: "Registration submitted...", }, 1000, ); try { const regResult = await register( this.activeDid, this.apiServer, this.axios, contact, ); if (regResult.success) { contact.registered = true; const platformService = PlatformServiceFactory.getInstance(); await platformService.dbExec( "UPDATE contacts SET registered = ? WHERE did = ?", [true, contact.did], ); if (USE_DEXIE_DB) { await db.contacts.update(contact.did, { registered: true }); } logger.info("Contact registration successful", { did: contact.did }); this.$notify( { group: "alert", type: "success", title: "Registration Success", text: (contact.name || "That unnamed person") + " has been registered.", }, 5000, ); } else { this.$notify( { group: "alert", type: "danger", title: "Registration Error", text: (regResult.error as string) || "Something went wrong during registration.", }, 5000, ); } } catch (error) { logger.error("Error registering contact:", { did: contact.did, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); let userMessage = "There was an error."; const serverError = error as AxiosError; if (serverError) { if ( serverError.response?.data && typeof serverError.response.data === "object" && "message" in serverError.response.data ) { userMessage = (serverError.response.data as { message: string }) .message; } else if (serverError.message) { userMessage = serverError.message; // Info for the user } else { userMessage = JSON.stringify(serverError.toJSON()); } } else { userMessage = error as string; } // Now set that error for the user to see. this.$notify( { group: "alert", type: "danger", title: "Registration Error", text: userMessage, }, 5000, ); } } onScanError(error: Error) { this.error = error.message; logger.error("QR code scan error:", { error: error.message, stack: error.stack, }); } async onCopyUrlToClipboard() { const account = await libsUtil.retrieveFullyDecryptedAccount(this.activeDid) as Account; const jwtUrl = await generateEndorserJwtUrlForAccount( account, this.isRegistered, this.givenName, this.profileImageUrl, true, ); useClipboard() .copy(jwtUrl) .then(() => { this.$notify( { group: "alert", type: "toast", title: "Copied", text: "Contact URL was copied to clipboard.", }, 2000, ); }); } toastQRCodeHelp() { this.$notify( { group: "alert", type: "info", title: "QR Code Help", text: "Click the QR code to copy your contact info to your clipboard.", }, 5000, ); } onCopyDidToClipboard() { //this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing useClipboard() .copy(this.activeDid) .then(() => { this.$notify( { group: "alert", type: "info", title: "Copied", text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.", }, 5000, ); }); } openUserNameDialog() { (this.$refs.userNameDialog as IUserNameDialog).open((name: string) => { this.givenName = name; }); } // Lifecycle hooks mounted() { this.isMounted = true; this.isDesktop = this.detectDesktopBrowser(); document.addEventListener("pause", this.handleAppPause); document.addEventListener("resume", this.handleAppResume); // Start scanning automatically when view is loaded this.startScanning(); // Apply mirroring after a short delay to ensure video element is ready setTimeout(() => { const videoElement = document.querySelector( ".qr-scanner video", ) as HTMLVideoElement; if (videoElement) { videoElement.style.transform = "scaleX(-1)"; } }, 1000); } beforeDestroy() { this.isMounted = false; document.removeEventListener("pause", this.handleAppPause); document.removeEventListener("resume", this.handleAppResume); this.cleanupScanner(); } async handleAppPause() { if (!this.isMounted) return; logger.info("App paused, stopping scanner"); await this.stopScanning(); } handleAppResume() { if (!this.isMounted) return; logger.info("App resumed, scanner can be restarted by user"); this.isScanning = false; } async addNewContact(contact: Contact) { try { logger.info("Opening database connection for new contact"); // Check if contact already exists const platformService = PlatformServiceFactory.getInstance(); const dbAllContacts = await platformService.dbQuery( "SELECT * FROM contacts WHERE did = ?", [contact.did], ); const existingContacts = databaseUtil.mapQueryResultToValues( dbAllContacts, ) as unknown as Contact[]; let existingContact: Contact | undefined = existingContacts[0]; if (USE_DEXIE_DB) { await db.open(); const existingContacts = await db.contacts.toArray(); existingContact = existingContacts.find((c) => c.did === contact.did); } if (existingContact) { logger.info("Contact already exists", { did: contact.did }); this.$notify( { group: "alert", type: "warning", title: "Contact Exists", text: "This contact has already been added to your list.", }, 5000, ); return; } // Add new contact // @ts-expect-error because we're just using the value to store to the DB contact.contactMethods = JSON.stringify( parseJsonField(contact.contactMethods, []), ); const { sql, params } = databaseUtil.generateInsertStatement( contact as unknown as Record<string, unknown>, "contacts", ); await platformService.dbExec(sql, params); if (USE_DEXIE_DB) { await db.contacts.add(contact); } if (this.activeDid) { logger.info("Setting contact visibility", { did: contact.did }); await this.setVisibility(contact, true); contact.seesMe = true; } this.$notify( { group: "alert", type: "success", title: "Contact Added", text: this.activeDid ? "They were added, and your activity is visible to them." : "They were added.", }, 3000, ); if ( this.isRegistered && !this.hideRegisterPromptOnNewContact && !contact.registered ) { setTimeout(() => { this.$notify( { group: "modal", type: "confirm", title: "Register", text: "Do you want to register them?", onCancel: async (stopAsking?: boolean) => { if (stopAsking) { const platformService = PlatformServiceFactory.getInstance(); await platformService.dbExec( "UPDATE settings SET hideRegisterPromptOnNewContact = ? WHERE id = ?", [stopAsking, MASTER_SETTINGS_KEY], ); if (USE_DEXIE_DB) { await db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: stopAsking, }); } this.hideRegisterPromptOnNewContact = stopAsking; } }, onNo: async (stopAsking?: boolean) => { if (stopAsking) { const platformService = PlatformServiceFactory.getInstance(); await platformService.dbExec( "UPDATE settings SET hideRegisterPromptOnNewContact = ? WHERE id = ?", [stopAsking, MASTER_SETTINGS_KEY], ); if (USE_DEXIE_DB) { await db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: stopAsking, }); } this.hideRegisterPromptOnNewContact = stopAsking; } }, onYes: async () => { await this.register(contact); }, promptToStopAsking: true, }, -1, ); }, 500); } } catch (error) { logger.error("Error saving contact to database:", { did: contact.did, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); this.$notify( { group: "alert", type: "danger", title: "Contact Error", text: "Could not save contact. Check if it already exists.", }, 5000, ); } } async onInit(promise: Promise<void>): Promise<void> { logger.log("[QRScanner] onInit called"); try { await promise; this.isInitializing = false; this.cameraState = "ready"; } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); this.error = wrappedError.message; this.cameraState = "error"; this.isInitializing = false; logger.error("Error during QR scanner initialization:", { error: wrappedError.message, stack: wrappedError.stack, }); } } onCameraOn(): void { this.cameraState = "active"; this.isInitializing = false; this.isFrontCamera = this.preferredCamera === "user"; this.applyCameraMirroring(); } onCameraOff(): void { this.cameraState = "off"; } onDetect(result: unknown): void { this.isScanning = true; this.cameraState = "active"; try { let rawValue: string | undefined; if ( Array.isArray(result) && result.length > 0 && "rawValue" in result[0] ) { rawValue = result[0].rawValue; } else if (result && typeof result === "object" && "rawValue" in result) { rawValue = (result as { rawValue: string }).rawValue; } if (rawValue) { this.isInitializing = false; this.initializationStatus = "QR code captured!"; this.onScanDetect(rawValue); } } catch (error) { this.handleError(error); } finally { this.cameraState = "active"; } } onDecode(result: string): void { try { this.isInitializing = false; this.initializationStatus = "QR code captured!"; this.onScanDetect(result); } catch (error) { this.handleError(error); } } toggleCamera(): void { this.preferredCamera = this.preferredCamera === "user" ? "environment" : "user"; this.isFrontCamera = this.preferredCamera === "user"; this.applyCameraMirroring(); } private handleError(error: unknown): void { const wrappedError = error instanceof Error ? error : new Error(String(error)); this.error = wrappedError.message; this.cameraState = "error"; } onError(error: Error): void { this.error = error.message; this.cameraState = "error"; logger.error("QR code scan error:", { error: error.message, stack: error.stack, }); } // Add method to detect desktop browser private detectDesktopBrowser(): boolean { const userAgent = navigator.userAgent.toLowerCase(); return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test( userAgent, ); } // Add method to apply camera mirroring private applyCameraMirroring(): void { const videoElement = document.querySelector( ".qr-scanner video", ) as HTMLVideoElement; if (videoElement) { // Mirror if it's desktop or front camera on mobile const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop); videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none"; } } } </script> <style scoped> .aspect-square { aspect-ratio: 1 / 1; } /* Update styles for camera mirroring */ :deep(.qr-scanner) { position: relative; } /* Remove the default mirroring from CSS since we're handling it in JavaScript */ :deep(.qr-scanner video) { transform: none; } /* Ensure the canvas for QR detection is not mirrored */ :deep(.qr-scanner canvas) { transform: none; } </style>