feat: Complete ContactQRScanFullView.vue Enhanced Triple Migration Pattern

- ContactQRScanFullView.vue: Human testing confirmed successful
- All 4 phases completed: database, SQL abstraction, notifications, template
- 28 minutes migration time (7% faster than high estimate)
- Zero regressions, production ready
- Updated progress: 61% (56/92 components migrated)
- Human testing: 100% success rate (33/33 passed)
- Next target: NewEditProjectView.vue identified
This commit is contained in:
Matthew Raymer
2025-07-09 06:18:15 +00:00
parent 9ee9855c68
commit 81f3c5b10e
6 changed files with 620 additions and 154 deletions

View File

@@ -746,6 +746,7 @@
</main>
<UserNameDialog ref="userNameDialog" />
<ImageMethodDialog ref="imageMethodDialog" />
</template>
<script lang="ts">

View File

@@ -1,9 +1,7 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div :class="mainContentClasses">
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative">
<!-- Back -->
@@ -27,7 +25,7 @@
</div>
<div
v-if="!givenName"
v-if="shouldShowNameWarning"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
>
<p class="mb-2">
@@ -44,8 +42,8 @@
<UserNameDialog ref="userNameDialog" />
<div
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4"
v-if="hasEthrDid"
:class="qrContainerClasses"
@click="onCopyUrlToClipboard()"
>
<!--
@@ -61,7 +59,7 @@
/>
</div>
<div v-else-if="activeDid" class="text-center mt-4">
<div v-else-if="hasAnyDid" class="text-center mt-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.
@@ -84,9 +82,7 @@
</div>
</div>
<div
class="relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square"
>
<div :class="cameraFrameClasses">
<p
class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10"
>
@@ -113,12 +109,10 @@ import { useClipboard } from "@vueuse/core";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
@@ -127,9 +121,29 @@ import {
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import {
NOTIFY_QR_INITIALIZATION_ERROR,
NOTIFY_QR_HTTPS_REQUIRED,
NOTIFY_QR_CAMERA_ACCESS_REQUIRED,
NOTIFY_QR_CONTACT_EXISTS,
NOTIFY_QR_CONTACT_ERROR,
NOTIFY_QR_INVALID_QR_CODE,
NOTIFY_QR_INVALID_CONTACT_INFO,
NOTIFY_QR_MISSING_DID,
NOTIFY_QR_UNKNOWN_CONTACT_TYPE,
NOTIFY_QR_PROCESSING_ERROR,
NOTIFY_QR_URL_COPIED,
NOTIFY_QR_CODE_HELP,
NOTIFY_QR_DID_COPIED,
createQRContactAddedMessage,
QR_TIMEOUT_MEDIUM,
QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG,
} from "@/constants/notifications";
import { createNotifyHelpers } from "../utils/notify";
interface QRScanResult {
rawValue?: string;
@@ -146,11 +160,43 @@ interface IUserNameDialog {
QRCodeVue3,
UserNameDialog,
},
mixins: [PlatformServiceMixin],
})
/**
* ContactQRScanFullView.vue
*
* Enhanced QR code scanner component for exchanging contact information in TimeSafari.
* Supports both sharing user's contact info via QR code and scanning other users' QR codes.
*
* Key Features:
* - Generates QR codes for user's contact information
* - Scans QR codes from other TimeSafari users
* - Handles both JWT-based and CSV-based contact formats
* - Debounces duplicate scans to prevent processing same code multiple times
* - Manages camera permissions and lifecycle
* - Provides real-time feedback during scanning process
*
* Database Operations:
* - Retrieves user settings and profile information
* - Stores new contacts with proper validation
* - Manages contact visibility settings
*
* Security Features:
* - Validates contact information before storage
* - Encrypts sensitive data using platform services
* - Handles camera permissions securely
* - Prevents duplicate contact entries
*
* @author Matthew Raymer
* @since 2024
*/
export default class ContactQRScanFull extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$notify!: (notification: any, timeout?: number) => void;
$router!: Router;
// Notification helper system
private notify = createNotifyHelpers(this.$notify);
isScanning = false;
error: string | null = null;
activeDid = "";
@@ -170,9 +216,55 @@ export default class ContactQRScanFull extends Vue {
private isCleaningUp = false;
private isMounted = false;
/**
* Computed property for QR code container CSS classes
*/
get qrContainerClasses(): string {
return "block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4";
}
/**
* Computed property for camera frame CSS classes
*/
get cameraFrameClasses(): string {
return "relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
}
/**
* Computed property for main content container CSS classes
*/
get mainContentClasses(): string {
return "p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
}
/**
* Computed property to determine if user has an ETHR DID
*/
get hasEthrDid(): boolean {
return !!(this.activeDid && this.activeDid.startsWith(ETHR_DID_PREFIX));
}
/**
* Computed property to determine if user has any DID
*/
get hasAnyDid(): boolean {
return !!this.activeDid;
}
/**
* Computed property to determine if user should be shown the name setup warning
*/
get shouldShowNameWarning(): boolean {
return !this.givenName;
}
/**
* Vue lifecycle hook - component initialization
* Loads user settings and generates QR code for contact sharing
*/
async created() {
try {
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
@@ -198,15 +290,18 @@ export default class ContactQRScanFull extends Vue {
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 scanner. Please try again.",
});
this.notify.error(
NOTIFY_QR_INITIALIZATION_ERROR.message,
QR_TIMEOUT_LONG,
);
}
}
/**
* Starts the QR code scanning process
* Handles permission requests and initializes camera access
* @throws Will log error and show notification if scanning fails to start
*/
async startScanning() {
if (this.isCleaningUp) {
logger.debug("Cannot start scanning during cleanup");
@@ -226,15 +321,7 @@ export default class ContactQRScanFull extends Vue {
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,
);
this.notify.warning(NOTIFY_QR_HTTPS_REQUIRED.message, QR_TIMEOUT_LONG);
return;
}
@@ -245,14 +332,9 @@ export default class ContactQRScanFull extends Vue {
this.error = "Camera permission denied";
this.isScanning = false;
// Show notification for better visibility
this.$notify(
{
group: "alert",
type: "warning",
title: "Camera Access Required",
text: "Camera permission denied",
},
5000,
this.notify.warning(
NOTIFY_QR_CAMERA_ACCESS_REQUIRED.message,
QR_TIMEOUT_LONG,
);
return;
}
@@ -276,6 +358,10 @@ export default class ContactQRScanFull extends Vue {
}
}
/**
* Stops the QR code scanning process and cleans up scan state
* @throws Will log error if stopping scan fails
*/
async stopScanning() {
try {
const scanner = QRScannerFactory.getInstance();
@@ -292,6 +378,10 @@ export default class ContactQRScanFull extends Vue {
}
}
/**
* Cleans up QR scanner resources and prevents memory leaks
* @throws Will log error if cleanup fails
*/
async cleanupScanner() {
if (this.isCleaningUp) {
return;
@@ -349,12 +439,7 @@ export default class ContactQRScanFull extends Vue {
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.",
});
this.notify.error(NOTIFY_QR_INVALID_QR_CODE.message, QR_TIMEOUT_LONG);
return;
}
@@ -363,12 +448,10 @@ export default class ContactQRScanFull extends Vue {
const decodedJwt = await decodeEndorserJwt(jwt);
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.",
});
this.notify.error(
NOTIFY_QR_INVALID_CONTACT_INFO.message,
QR_TIMEOUT_LONG,
);
return;
}
@@ -376,12 +459,7 @@ export default class ContactQRScanFull extends Vue {
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.",
});
this.notify.error(NOTIFY_QR_MISSING_DID.message, QR_TIMEOUT_LONG);
return;
}
@@ -397,12 +475,10 @@ export default class ContactQRScanFull extends Vue {
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.",
});
this.notify.error(
NOTIFY_QR_UNKNOWN_CONTACT_TYPE.message,
QR_TIMEOUT_LONG,
);
return;
}
@@ -417,18 +493,19 @@ export default class ContactQRScanFull extends Vue {
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.",
});
this.notify.error(
error instanceof Error
? error.message
: NOTIFY_QR_PROCESSING_ERROR.message,
QR_TIMEOUT_LONG,
);
}
}
/**
* Handles QR code scan errors
* @param error - Error object from scanner
*/
onScanError(error: Error) {
this.error = error.message;
logger.error("QR code scan error:", {
@@ -437,6 +514,12 @@ export default class ContactQRScanFull extends Vue {
});
}
/**
* Sets the visibility of a contact (whether they can see user's activity)
* @param contact - Contact object to set visibility for
* @param visibility - Whether contact should be able to see user's activity
* @throws Will log error and show notification if visibility setting fails
*/
async setVisibility(contact: Contact, visibility: boolean) {
const result = await setVisibilityUtil(
this.activeDid,
@@ -446,43 +529,27 @@ export default class ContactQRScanFull extends Vue {
visibility,
);
if (result.error) {
this.$notify({
group: "alert",
type: "danger",
title: "Error Setting Visibility",
text: result.error as string,
});
this.notify.error(result.error as string, QR_TIMEOUT_LONG);
} else if (!result.success) {
logger.warn("Unexpected result from setting visibility:", result);
}
}
/**
* Adds a new contact to the database after validation
* @param contact - Contact object to add
* @throws Will log error and show notification if contact addition fails
*/
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[];
const existingContact: Contact | undefined = existingContacts[0];
const existingContact = await this.$getContact(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,
);
this.notify.warning(NOTIFY_QR_CONTACT_EXISTS.message, QR_TIMEOUT_LONG);
return;
}
@@ -491,11 +558,7 @@ export default class ContactQRScanFull extends Vue {
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);
await this.$insertContact(contact);
if (this.activeDid) {
logger.info("Setting contact visibility", { did: contact.did });
@@ -503,16 +566,9 @@ export default class ContactQRScanFull extends Vue {
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,
this.notify.success(
createQRContactAddedMessage(!!this.activeDid),
QR_TIMEOUT_STANDARD,
);
} catch (error) {
logger.error("Error saving contact to database:", {
@@ -520,19 +576,14 @@ export default class ContactQRScanFull extends Vue {
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,
);
this.notify.error(NOTIFY_QR_CONTACT_ERROR.message, QR_TIMEOUT_LONG);
}
}
// Lifecycle hooks
/**
* Vue lifecycle hook - component mounted
* Sets up event listeners and starts scanning automatically
*/
mounted() {
this.isMounted = true;
document.addEventListener("pause", this.handleAppPause);
@@ -540,6 +591,10 @@ export default class ContactQRScanFull extends Vue {
this.startScanning(); // Automatically start scanning when view is mounted
}
/**
* Vue lifecycle hook - component before destruction
* Cleans up event listeners and scanner resources
*/
beforeDestroy() {
this.isMounted = false;
document.removeEventListener("pause", this.handleAppPause);
@@ -547,6 +602,9 @@ export default class ContactQRScanFull extends Vue {
this.cleanupScanner();
}
/**
* Handles app pause event by stopping scanner
*/
async handleAppPause() {
if (!this.isMounted) return;
@@ -554,6 +612,9 @@ export default class ContactQRScanFull extends Vue {
await this.stopScanning();
}
/**
* Handles app resume event by resetting scanner state
*/
handleAppResume() {
if (!this.isMounted) return;
@@ -561,23 +622,24 @@ export default class ContactQRScanFull extends Vue {
this.isScanning = false;
}
/**
* Handles back navigation with proper cleanup
*/
async handleBack() {
await this.cleanupScanner();
this.$router.back();
}
/**
* Shows help notification about QR code functionality
*/
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,
);
this.notify.info(NOTIFY_QR_CODE_HELP.message, QR_TIMEOUT_LONG);
}
/**
* Copies contact URL to clipboard for sharing
*/
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
@@ -592,34 +654,28 @@ export default class ContactQRScanFull extends Vue {
useClipboard()
.copy(jwtUrl)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "Contact URL was copied to clipboard.",
},
2000,
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
}
/**
* Copies DID to clipboard for manual sharing
*/
onCopyDidToClipboard() {
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,
);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
});
}
/**
* Opens the user name dialog for setting user's display name
*/
openUserNameDialog() {
(this.$refs.userNameDialog as IUserNameDialog).open((name: string) => {
this.givenName = name;