forked from jsnbuchanan/crowd-funder-for-time-pwa
Add comprehensive guidance to prevent common migration oversights: - Remove unused notification imports - Replace hardcoded timeout values with constants - Remove legacy wrapper functions - Extract long class attributes to computed properties - Replace literal strings with constants Based on lessons learned from ContactQRScanShowView.vue migration. Includes validation commands and specific examples for each pattern.
901 lines
27 KiB
Vue
901 lines
27 KiB
Vue
<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="nameWarningClasses"
|
|
>
|
|
<p class="mb-2">
|
|
<b>Note:</b> your identity currently does <b>not</b> include a name.
|
|
</p>
|
|
<button
|
|
:class="setNameButtonClasses"
|
|
@click="openUserNameDialog"
|
|
>
|
|
Set Your Name
|
|
</button>
|
|
</div>
|
|
|
|
<UserNameDialog ref="userNameDialog" />
|
|
|
|
<div
|
|
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
|
|
:class="qrCodeContainerClasses"
|
|
@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="scannerContainerClasses"
|
|
>
|
|
<!-- Status Message -->
|
|
<div
|
|
:class="statusMessageClasses"
|
|
>
|
|
<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="cameraStatusIndicatorClasses"
|
|
></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 } from "../constants/app";
|
|
import { Contact } from "../db/tables/contacts";
|
|
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 { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
|
import { Account } from "@/db/tables/accounts";
|
|
import { createNotifyHelpers } from "@/utils/notify";
|
|
import {
|
|
NOTIFY_QR_INITIALIZATION_ERROR,
|
|
NOTIFY_QR_CAMERA_IN_USE,
|
|
NOTIFY_QR_CAMERA_ACCESS_REQUIRED,
|
|
NOTIFY_QR_NO_CAMERA,
|
|
NOTIFY_QR_HTTPS_REQUIRED,
|
|
NOTIFY_QR_CONTACT_EXISTS,
|
|
NOTIFY_QR_CONTACT_ERROR,
|
|
NOTIFY_QR_REGISTRATION_SUBMITTED,
|
|
NOTIFY_QR_REGISTRATION_ERROR,
|
|
NOTIFY_QR_URL_COPIED,
|
|
NOTIFY_QR_CODE_HELP,
|
|
NOTIFY_QR_DID_COPIED,
|
|
NOTIFY_QR_INVALID_QR_CODE,
|
|
NOTIFY_QR_INVALID_CONTACT_INFO,
|
|
NOTIFY_QR_MISSING_DID,
|
|
NOTIFY_QR_UNKNOWN_CONTACT_TYPE,
|
|
NOTIFY_QR_PROCESSING_ERROR,
|
|
createQRContactAddedMessage,
|
|
createQRRegistrationSuccessMessage,
|
|
QR_TIMEOUT_SHORT,
|
|
QR_TIMEOUT_MEDIUM,
|
|
QR_TIMEOUT_STANDARD,
|
|
QR_TIMEOUT_LONG,
|
|
} from "@/constants/notifications";
|
|
|
|
interface QRScanResult {
|
|
rawValue?: string;
|
|
barcode?: string;
|
|
}
|
|
|
|
interface IUserNameDialog {
|
|
open: (callback: (name: string) => void) => void;
|
|
}
|
|
|
|
@Component({
|
|
components: {
|
|
QRCodeVue3,
|
|
QuickNav,
|
|
UserNameDialog,
|
|
QrcodeStream,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class ContactQRScanShow extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$router!: Router;
|
|
|
|
// Notification helper system
|
|
private notify = createNotifyHelpers(this.$notify);
|
|
|
|
activeDid = "";
|
|
apiServer = "";
|
|
|
|
// Axios instance for API calls
|
|
get axios() {
|
|
return (this as any).$platformService.axios;
|
|
}
|
|
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;
|
|
|
|
// Computed properties for template classes
|
|
get nameWarningClasses(): string {
|
|
return "bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4";
|
|
}
|
|
|
|
get setNameButtonClasses(): string {
|
|
return "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";
|
|
}
|
|
|
|
get qrCodeContainerClasses(): string {
|
|
return "block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4";
|
|
}
|
|
|
|
get scannerContainerClasses(): string {
|
|
return "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";
|
|
}
|
|
|
|
get statusMessageClasses(): string {
|
|
return "absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10";
|
|
}
|
|
|
|
get cameraStatusIndicatorClasses(): Record<string, boolean> {
|
|
return {
|
|
'inline-block w-2 h-2 rounded-full': true,
|
|
'bg-green-500': this.cameraState === 'ready',
|
|
'bg-yellow-500': this.cameraState === 'in_use',
|
|
'bg-red-500':
|
|
this.cameraState === 'error' ||
|
|
this.cameraState === 'permission_denied' ||
|
|
this.cameraState === 'not_found',
|
|
'bg-blue-500': this.cameraState === 'off',
|
|
};
|
|
}
|
|
|
|
async created() {
|
|
try {
|
|
const settings = await this.$accountSettings();
|
|
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.error(NOTIFY_QR_INITIALIZATION_ERROR.message);
|
|
}
|
|
}
|
|
|
|
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.warning(NOTIFY_QR_CAMERA_IN_USE.message, QR_TIMEOUT_LONG);
|
|
break;
|
|
case "permission_denied":
|
|
this.error = "Camera permission denied";
|
|
this.isScanning = false;
|
|
this.notify.warning(NOTIFY_QR_CAMERA_ACCESS_REQUIRED.message, QR_TIMEOUT_LONG);
|
|
break;
|
|
case "not_found":
|
|
this.error = "No camera found";
|
|
this.isScanning = false;
|
|
this.notify.warning(NOTIFY_QR_NO_CAMERA.message, QR_TIMEOUT_LONG);
|
|
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.warning(NOTIFY_QR_HTTPS_REQUIRED.message, QR_TIMEOUT_LONG);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.error(NOTIFY_QR_INVALID_QR_CODE.message);
|
|
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.error(NOTIFY_QR_INVALID_CONTACT_INFO.message);
|
|
return;
|
|
}
|
|
|
|
const contactInfo = decodedJwt.payload.own;
|
|
const did = contactInfo.did || decodedJwt.payload.iss;
|
|
if (!did) {
|
|
logger.warn("Invalid contact info - missing DID");
|
|
this.notify.error(NOTIFY_QR_MISSING_DID.message);
|
|
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.error(NOTIFY_QR_UNKNOWN_CONTACT_TYPE.message);
|
|
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.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: NOTIFY_QR_PROCESSING_ERROR.message
|
|
);
|
|
}
|
|
}
|
|
|
|
async setVisibility(contact: Contact, visibility: boolean) {
|
|
const result = await setVisibilityUtil(
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
contact,
|
|
visibility,
|
|
);
|
|
if (result.error) {
|
|
this.notify.error(result.error as string, QR_TIMEOUT_LONG);
|
|
} 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.toast(NOTIFY_QR_REGISTRATION_SUBMITTED.message, QR_TIMEOUT_SHORT);
|
|
|
|
try {
|
|
const regResult = await register(
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
contact,
|
|
);
|
|
if (regResult.success) {
|
|
contact.registered = true;
|
|
await this.$updateContact(contact.did, { registered: true });
|
|
logger.info("Contact registration successful", { did: contact.did });
|
|
|
|
this.notify.success(
|
|
createQRRegistrationSuccessMessage(contact.name || ""),
|
|
QR_TIMEOUT_LONG,
|
|
);
|
|
} else {
|
|
this.notify.error(
|
|
(regResult.error as string) || NOTIFY_QR_REGISTRATION_ERROR.message,
|
|
QR_TIMEOUT_LONG,
|
|
);
|
|
}
|
|
} 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.error(userMessage, QR_TIMEOUT_LONG);
|
|
}
|
|
}
|
|
|
|
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.toast(NOTIFY_QR_URL_COPIED.message, QR_TIMEOUT_MEDIUM);
|
|
});
|
|
}
|
|
|
|
toastQRCodeHelp() {
|
|
this.notify.info(NOTIFY_QR_CODE_HELP.message, QR_TIMEOUT_LONG);
|
|
}
|
|
|
|
onCopyDidToClipboard() {
|
|
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
|
useClipboard()
|
|
.copy(this.activeDid)
|
|
.then(() => {
|
|
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
|
|
});
|
|
}
|
|
|
|
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 existingContact = await this.$getContact(contact.did);
|
|
|
|
if (existingContact) {
|
|
logger.info("Contact already exists", { did: contact.did });
|
|
this.notify.warning(NOTIFY_QR_CONTACT_EXISTS.message, QR_TIMEOUT_LONG);
|
|
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, []),
|
|
);
|
|
await this.$insertContact(contact);
|
|
|
|
if (this.activeDid) {
|
|
logger.info("Setting contact visibility", { did: contact.did });
|
|
await this.setVisibility(contact, true);
|
|
contact.seesMe = true;
|
|
}
|
|
|
|
this.notify.success(createQRContactAddedMessage(!!this.activeDid), QR_TIMEOUT_STANDARD);
|
|
|
|
if (
|
|
this.isRegistered &&
|
|
!this.hideRegisterPromptOnNewContact &&
|
|
!contact.registered
|
|
) {
|
|
setTimeout(() => {
|
|
this.notify.confirm(
|
|
"Register",
|
|
"Do you want to register them?",
|
|
{
|
|
onCancel: async (stopAsking?: boolean) => {
|
|
if (stopAsking) {
|
|
await this.$updateSettings({
|
|
hideRegisterPromptOnNewContact: stopAsking,
|
|
});
|
|
this.hideRegisterPromptOnNewContact = stopAsking;
|
|
}
|
|
},
|
|
onNo: async (stopAsking?: boolean) => {
|
|
if (stopAsking) {
|
|
await this.$updateSettings({
|
|
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.error(NOTIFY_QR_CONTACT_ERROR.message, QR_TIMEOUT_LONG);
|
|
}
|
|
}
|
|
|
|
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>
|