Fix duplicate export declarations and migrate ContactsView with sub-components

- Remove duplicate NOTIFY_INVITE_MISSING and NOTIFY_INVITE_PROCESSING_ERROR exports
- Update InviteOneAcceptView.vue to use correct NOTIFY_INVITE_TRUNCATED_DATA constant
- Migrate ContactsView to PlatformServiceMixin and extract into modular sub-components
- Resolves TypeScript compilation errors preventing web build
This commit is contained in:
Matthew Raymer
2025-07-16 08:03:26 +00:00
parent 6ef4e314ec
commit ad8e9f823b
45 changed files with 3216 additions and 1752 deletions

View File

@@ -347,7 +347,7 @@ interface Settings {
@Component({
components: {
PWAInstallPrompt
PWAInstallPrompt,
},
mixins: [PlatformServiceMixin],
})

View File

@@ -0,0 +1,42 @@
<template>
<div class="mt-2 w-full text-left">
<input
v-if="!showGiveNumbers"
type="checkbox"
:checked="allContactsSelected"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllBottom"
@click="$emit('toggle-all-selection')"
/>
<button
v-if="!showGiveNumbers"
:class="copyButtonClass"
:disabled="copyButtonDisabled"
@click="$emit('copy-selected')"
>
Copy
</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
/**
* ContactBulkActions - Contact bulk actions component
*
* Provides bulk selection controls at the bottom of the contact list.
* Handles copy operations for selected contacts.
*
* @author Matthew Raymer
*/
@Component({
name: "ContactBulkActions",
})
export default class ContactBulkActions extends Vue {
@Prop({ required: true }) showGiveNumbers!: boolean;
@Prop({ required: true }) allContactsSelected!: boolean;
@Prop({ required: true }) copyButtonClass!: string;
@Prop({ required: true }) copyButtonDisabled!: boolean;
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<!-- Action Buttons -->
<span v-if="isRegistered" class="flex">
<router-link
:to="{ name: 'invite-one' }"
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<font-awesome icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<button
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@click="$emit('show-onboard-meeting')"
>
<font-awesome icon="chair" class="fa-fw text-2xl" />
</button>
</span>
<span v-else class="flex">
<span
class="flex items-center 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.5 py-1 mr-1 rounded-md"
>
<font-awesome
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="$emit('registration-required')"
/>
</span>
<span
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<font-awesome
icon="chair"
class="fa-fw text-2xl"
@click="$emit('navigate-onboard-meeting')"
/>
</span>
</span>
<!-- QR Code Button -->
<button
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@click="$emit('qr-scan')"
>
<font-awesome icon="qrcode" class="fa-fw text-2xl" />
</button>
<!-- Contact Input -->
<textarea
v-model="inputValue"
type="text"
placeholder="New URL or DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
/>
<!-- Add Button -->
<button
class="px-4 rounded-r bg-green-200 border border-green-400"
@click="$emit('submit', inputValue)"
>
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Model } from "vue-facing-decorator";
/**
* ContactInputForm - Contact input form component
*
* Provides a form for adding new contacts with various input formats.
* Includes action buttons for invites, meetings, and QR scanning.
*
* @author Matthew Raymer
*/
@Component({
name: "ContactInputForm",
})
export default class ContactInputForm extends Vue {
@Prop({ required: true }) isRegistered!: boolean;
@Model("input", { type: String, default: "" })
inputValue!: string;
/**
* Update the input value and emit change event
*/
set input(value: string) {
this.inputValue = value;
this.$emit("input", value);
}
get input(): string {
return this.inputValue;
}
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex justify-between">
<!-- Left side - Bulk selection controls -->
<div class="">
<div v-if="!showGiveNumbers" class="flex items-center">
<input
type="checkbox"
:checked="allContactsSelected"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
@click="$emit('toggle-all-selection')"
/>
<button
v-if="!showGiveNumbers"
:class="copyButtonClass"
:disabled="copyButtonDisabled"
data-testId="copySelectedContactsButtonTop"
@click="$emit('copy-selected')"
>
Copy
</button>
<font-awesome
icon="circle-info"
class="text-2xl text-blue-500 ml-2"
@click="$emit('show-copy-info')"
/>
</div>
</div>
<!-- Right side - Action buttons -->
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="giveAmountsButtonClass"
@click="$emit('toggle-give-totals')"
>
{{ giveAmountsButtonText }}
<font-awesome icon="left-right" class="fa-fw" />
</button>
<button
class="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-white px-3 py-1.5 rounded-md"
@click="$emit('toggle-show-actions')"
>
{{ showActionsButtonText }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
/**
* ContactListHeader - Contact list header component
*
* Provides bulk selection controls and action buttons for the contact list.
* Handles copy operations and give amounts display toggles.
*
* @author Matthew Raymer
*/
@Component({
name: "ContactListHeader",
})
export default class ContactListHeader extends Vue {
@Prop({ required: true }) showGiveNumbers!: boolean;
@Prop({ required: true }) allContactsSelected!: boolean;
@Prop({ required: true }) copyButtonClass!: string;
@Prop({ required: true }) copyButtonDisabled!: boolean;
@Prop({ required: true }) giveAmountsButtonText!: string;
@Prop({ required: true }) showActionsButtonText!: string;
@Prop({ required: true }) giveAmountsButtonClass!: Record<string, boolean>;
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<li class="border-b border-slate-300 pt-1 pb-1" data-testId="contactListItem">
<div class="flex items-center justify-between gap-3">
<!-- Contact Info Section -->
<div class="flex overflow-hidden min-w-0 items-center gap-3">
<input
v-if="showCheckbox"
type="checkbox"
:checked="isSelected"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
@click="$emit('toggle-selection', contact.did)"
/>
<EntityIcon
:contact="contact"
:icon-size="48"
class="shrink-0 align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden"
@click="$emit('show-identicon', contact)"
/>
<div class="overflow-hidden">
<h2 class="text-base font-semibold truncate">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
{{ contactNameNonBreakingSpace(contact.name) }}
</router-link>
</h2>
<div class="flex gap-1.5 items-center overflow-hidden">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500"
/>
</router-link>
<span class="text-xs truncate">{{ contact.did }}</span>
</div>
<div class="text-sm">
{{ contact.notes }}
</div>
</div>
</div>
<!-- Contact Actions Section -->
<div
v-if="showActions && contact.did !== activeDid"
class="flex gap-1.5 items-end"
>
<div class="text-center">
<div class="text-xs leading-none mb-1">From/To</div>
<div class="flex items-center">
<button
class="text-sm 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-2.5 py-1.5 rounded-l-md"
:title="getGiveDescriptionForContact(contact.did, true)"
@click="$emit('show-gifted-dialog', contact.did, activeDid)"
>
{{ getGiveAmountForContact(contact.did, true) }}
</button>
<button
class="text-sm 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-2.5 py-1.5 rounded-r-md border-l"
:title="getGiveDescriptionForContact(contact.did, false)"
@click="$emit('show-gifted-dialog', activeDid, contact.did)"
>
{{ getGiveAmountForContact(contact.did, false) }}
</button>
</div>
</div>
<button
class="text-sm 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-2 py-1.5 rounded-md"
data-testId="offerButton"
@click="$emit('open-offer-dialog', contact.did, contact.name)"
>
Offer
</button>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="text-sm 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-2 py-1.5 rounded-md"
title="See more given activity"
>
<font-awesome icon="file-lines" class="fa-fw" />
</router-link>
</div>
</div>
</li>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import EntityIcon from "./EntityIcon.vue";
import { Contact } from "../db/tables/contacts";
import { AppString } from "../constants/app";
/**
* ContactListItem - Individual contact display component
*
* Displays a single contact with their information, selection checkbox,
* and action buttons. Handles contact interactions and emits events
* for parent component handling.
*
* @author Matthew Raymer
*/
@Component({
name: "ContactListItem",
components: {
EntityIcon,
},
})
export default class ContactListItem extends Vue {
@Prop({ required: true }) contact!: Contact;
@Prop({ required: true }) activeDid!: string;
@Prop({ default: false }) showCheckbox!: boolean;
@Prop({ default: false }) showActions!: boolean;
@Prop({ default: false }) isSelected!: boolean;
@Prop({ required: true }) showGiveTotals!: boolean;
@Prop({ required: true }) showGiveConfirmed!: boolean;
@Prop({ required: true }) givenToMeDescriptions!: Record<string, string>;
@Prop({ required: true }) givenToMeConfirmed!: Record<string, number>;
@Prop({ required: true }) givenToMeUnconfirmed!: Record<string, number>;
@Prop({ required: true }) givenByMeDescriptions!: Record<string, string>;
@Prop({ required: true }) givenByMeConfirmed!: Record<string, number>;
@Prop({ required: true }) givenByMeUnconfirmed!: Record<string, number>;
// Constants
AppString = AppString;
/**
* Format contact name with non-breaking spaces
*/
private contactNameNonBreakingSpace(contactName?: string): string {
return (contactName || AppString.NO_CONTACT_NAME).replace(/\s/g, "\u00A0");
}
/**
* Get give amount for a specific contact and direction
*/
private getGiveAmountForContact(contactDid: string, isGivenToMe: boolean): number {
if (this.showGiveTotals) {
if (isGivenToMe) {
return (this.givenToMeConfirmed[contactDid] || 0) +
(this.givenToMeUnconfirmed[contactDid] || 0);
} else {
return (this.givenByMeConfirmed[contactDid] || 0) +
(this.givenByMeUnconfirmed[contactDid] || 0);
}
} else if (this.showGiveConfirmed) {
return isGivenToMe
? (this.givenToMeConfirmed[contactDid] || 0)
: (this.givenByMeConfirmed[contactDid] || 0);
} else {
return isGivenToMe
? (this.givenToMeUnconfirmed[contactDid] || 0)
: (this.givenByMeUnconfirmed[contactDid] || 0);
}
}
/**
* Get give description for a specific contact and direction
*/
private getGiveDescriptionForContact(contactDid: string, isGivenToMe: boolean): string {
return isGivenToMe
? (this.givenToMeDescriptions[contactDid] || '')
: (this.givenByMeDescriptions[contactDid] || '');
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div v-if="contact" class="fixed z-[100] top-0 inset-x-0 w-full">
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:contact="contact"
:icon-size="512"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="$emit('close')"
/>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import EntityIcon from "./EntityIcon.vue";
import { Contact } from "../db/tables/contacts";
/**
* LargeIdenticonModal - Large identicon display modal
*
* Displays a contact's identicon in a large modal overlay.
* Clicking the identicon closes the modal.
*
* @author Matthew Raymer
*/
@Component({
name: "LargeIdenticonModal",
components: {
EntityIcon,
},
})
export default class LargeIdenticonModal extends Vue {
@Prop({ required: true }) contact!: Contact | undefined;
}
</script>

View File

@@ -6,33 +6,30 @@
<!-- Main content with lazy-loaded components -->
<div class="content">
<h1>Lazy Loading Example</h1>
<!-- Lazy-loaded heavy component -->
<LazyHeavyComponent
<LazyHeavyComponent
v-if="showHeavyComponent"
:data="heavyComponentData"
@data-processed="handleDataProcessed"
/>
<!-- Conditionally loaded components -->
<LazyQRScanner
v-if="showQRScanner"
@qr-detected="handleQRDetected"
/>
<LazyThreeJSViewer
<LazyQRScanner v-if="showQRScanner" @qr-detected="handleQRDetected" />
<LazyThreeJSViewer
v-if="showThreeJS"
:model-url="threeJSModelUrl"
@model-loaded="handleModelLoaded"
/>
<!-- Route-based lazy loading -->
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</template>
<!-- Loading fallback -->
<template #fallback>
<div class="loading-fallback">
@@ -41,98 +38,101 @@
</div>
</template>
</Suspense>
<!-- Control buttons -->
<div class="controls">
<button @click="toggleHeavyComponent">
{{ showHeavyComponent ? 'Hide' : 'Show' }} Heavy Component
{{ showHeavyComponent ? "Hide" : "Show" }} Heavy Component
</button>
<button @click="toggleQRScanner">
{{ showQRScanner ? 'Hide' : 'Show' }} QR Scanner
{{ showQRScanner ? "Hide" : "Show" }} QR Scanner
</button>
<button @click="toggleThreeJS">
{{ showThreeJS ? 'Hide' : 'Show' }} 3D Viewer
{{ showThreeJS ? "Hide" : "Show" }} 3D Viewer
</button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-facing-decorator';
import { defineAsyncComponent } from 'vue';
import { Component, Vue, Prop, Watch } from "vue-facing-decorator";
import { defineAsyncComponent } from "vue";
/**
* Lazy Loading Example Component
*
*
* Demonstrates various lazy loading patterns with vue-facing-decorator:
* - defineAsyncComponent for heavy components
* - Conditional loading based on user interaction
* - Suspense for loading states
* - Route-based lazy loading
*
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: 'LazyLoadingExample',
name: "LazyLoadingExample",
components: {
// Lazy-loaded components with loading and error states
LazyHeavyComponent: defineAsyncComponent({
loader: () => import('./sub-components/HeavyComponent.vue'),
loader: () => import("./sub-components/HeavyComponent.vue"),
loadingComponent: {
template: '<div class="loading">Loading heavy component...</div>'
template: '<div class="loading">Loading heavy component...</div>',
},
errorComponent: {
template: '<div class="error">Failed to load heavy component</div>'
template: '<div class="error">Failed to load heavy component</div>',
},
delay: 200, // Show loading component after 200ms
timeout: 10000 // Timeout after 10 seconds
timeout: 10000, // Timeout after 10 seconds
}),
LazyQRScanner: defineAsyncComponent({
loader: () => import('./sub-components/QRScannerComponent.vue'),
loader: () => import("./sub-components/QRScannerComponent.vue"),
loadingComponent: {
template: '<div class="loading">Initializing QR scanner...</div>'
template: '<div class="loading">Initializing QR scanner...</div>',
},
errorComponent: {
template: '<div class="error">QR scanner not available</div>'
}
template: '<div class="error">QR scanner not available</div>',
},
}),
LazyThreeJSViewer: defineAsyncComponent({
loader: () => import('./sub-components/ThreeJSViewer.vue'),
loader: () => import("./sub-components/ThreeJSViewer.vue"),
loadingComponent: {
template: '<div class="loading">Loading 3D viewer...</div>'
template: '<div class="loading">Loading 3D viewer...</div>',
},
errorComponent: {
template: '<div class="error">3D viewer failed to load</div>'
}
})
}
template: '<div class="error">3D viewer failed to load</div>',
},
}),
},
})
export default class LazyLoadingExample extends Vue {
// Component state
@Prop({ default: false }) readonly initialLoadHeavy!: boolean;
// Reactive properties
showHeavyComponent = false;
showQRScanner = false;
showThreeJS = false;
// Component data
heavyComponentData = {
items: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}` })),
filters: { category: 'all', status: 'active' },
sortBy: 'name'
items: Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
})),
filters: { category: "all", status: "active" },
sortBy: "name",
};
threeJSModelUrl = '/models/lupine_plant/scene.gltf';
threeJSModelUrl = "/models/lupine_plant/scene.gltf";
// Computed properties
get isLoadingAnyComponent(): boolean {
return this.showHeavyComponent || this.showQRScanner || this.showThreeJS;
}
get componentCount(): number {
let count = 0;
if (this.showHeavyComponent) count++;
@@ -140,83 +140,91 @@ export default class LazyLoadingExample extends Vue {
if (this.showThreeJS) count++;
return count;
}
// Lifecycle hooks
mounted(): void {
console.log('[LazyLoadingExample] Component mounted');
console.log("[LazyLoadingExample] Component mounted");
// Initialize based on props
if (this.initialLoadHeavy) {
this.showHeavyComponent = true;
}
// Preload critical components
this.preloadCriticalComponents();
}
// Methods
toggleHeavyComponent(): void {
this.showHeavyComponent = !this.showHeavyComponent;
console.log('[LazyLoadingExample] Heavy component toggled:', this.showHeavyComponent);
console.log(
"[LazyLoadingExample] Heavy component toggled:",
this.showHeavyComponent,
);
}
toggleQRScanner(): void {
this.showQRScanner = !this.showQRScanner;
console.log('[LazyLoadingExample] QR scanner toggled:', this.showQRScanner);
console.log("[LazyLoadingExample] QR scanner toggled:", this.showQRScanner);
}
toggleThreeJS(): void {
this.showThreeJS = !this.showThreeJS;
console.log('[LazyLoadingExample] ThreeJS viewer toggled:', this.showThreeJS);
console.log(
"[LazyLoadingExample] ThreeJS viewer toggled:",
this.showThreeJS,
);
}
handleDataProcessed(data: any): void {
console.log('[LazyLoadingExample] Data processed:', data);
console.log("[LazyLoadingExample] Data processed:", data);
// Handle processed data from heavy component
}
handleQRDetected(qrData: string): void {
console.log('[LazyLoadingExample] QR code detected:', qrData);
console.log("[LazyLoadingExample] QR code detected:", qrData);
// Handle QR code data
}
handleModelLoaded(modelInfo: any): void {
console.log('[LazyLoadingExample] 3D model loaded:', modelInfo);
console.log("[LazyLoadingExample] 3D model loaded:", modelInfo);
// Handle 3D model loaded event
}
/**
* Preload critical components for better UX
*/
private preloadCriticalComponents(): void {
// Preload components that are likely to be used
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === "production") {
// In production, preload based on user behavior patterns
this.preloadComponent(() => import('./sub-components/HeavyComponent.vue'));
this.preloadComponent(
() => import("./sub-components/HeavyComponent.vue"),
);
}
}
/**
* Preload a component without rendering it
*/
private preloadComponent(componentLoader: () => Promise<any>): void {
componentLoader().catch(error => {
console.warn('[LazyLoadingExample] Preload failed:', error);
componentLoader().catch((error) => {
console.warn("[LazyLoadingExample] Preload failed:", error);
});
}
// Watchers
@Watch('showHeavyComponent')
@Watch("showHeavyComponent")
onHeavyComponentToggle(newValue: boolean): void {
if (newValue) {
// Component is being shown - could trigger analytics
console.log('[LazyLoadingExample] Heavy component shown');
console.log("[LazyLoadingExample] Heavy component shown");
}
}
@Watch('componentCount')
@Watch("componentCount")
onComponentCountChange(newCount: number): void {
console.log('[LazyLoadingExample] Active component count:', newCount);
console.log("[LazyLoadingExample] Active component count:", newCount);
}
}
</script>
@@ -272,8 +280,12 @@ export default class LazyLoadingExample extends Vue {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading {
@@ -290,4 +302,4 @@ export default class LazyLoadingExample extends Vue {
border: 1px solid #f5c6cb;
border-radius: 4px;
}
</style>
</style>

View File

@@ -14,8 +14,8 @@
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<font-awesome
icon="download"
<font-awesome
icon="download"
class="h-6 w-6 text-blue-600"
title="Install App"
/>
@@ -29,14 +29,14 @@
</p>
<div class="mt-4 flex space-x-3">
<button
@click="installPWA"
class="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="installPWA"
>
Install
</button>
<button
@click="dismissPrompt"
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium px-3 py-2 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
@click="dismissPrompt"
>
Later
</button>
@@ -44,8 +44,8 @@
</div>
<div class="ml-4 flex-shrink-0">
<button
@click="dismissPrompt"
class="text-gray-400 hover:text-gray-600 focus:outline-none"
@click="dismissPrompt"
>
<font-awesome icon="times" class="h-4 w-4" />
</button>
@@ -63,7 +63,7 @@ import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
@Component({ name: "PWAInstallPrompt" })
@@ -86,48 +86,51 @@ export default class PWAInstallPrompt extends Vue {
}
// Listen for the beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e) => {
window.addEventListener("beforeinstallprompt", (e) => {
logger.debug("[PWA] beforeinstallprompt event fired");
// Stash the event so it can be triggered later
this.deferredPrompt = e as BeforeInstallPromptEvent;
// Show the install prompt
this.showInstallPrompt = true;
});
// Listen for successful installation
window.addEventListener('appinstalled', () => {
window.addEventListener("appinstalled", () => {
logger.debug("[PWA] App installed successfully");
this.showInstallPrompt = false;
this.deferredPrompt = null;
// Show success notification
this.$notify({
group: "alert",
type: "success",
title: "App Installed!",
text: "Time Safari has been installed on your device.",
}, 5000);
this.$notify(
{
group: "alert",
type: "success",
title: "App Installed!",
text: "Time Safari has been installed on your device.",
},
5000,
);
});
}
private isPWAInstalled(): boolean {
// Check if running in standalone mode (installed PWA)
if (window.matchMedia('(display-mode: standalone)').matches) {
if (window.matchMedia("(display-mode: standalone)").matches) {
return true;
}
// Check if running in fullscreen mode (installed PWA)
if (window.matchMedia('(display-mode: fullscreen)').matches) {
if (window.matchMedia("(display-mode: fullscreen)").matches) {
return true;
}
// Check if running in minimal-ui mode (installed PWA)
if (window.matchMedia('(display-mode: minimal-ui)').matches) {
if (window.matchMedia("(display-mode: minimal-ui)").matches) {
return true;
}
return false;
}
@@ -140,13 +143,13 @@ export default class PWAInstallPrompt extends Vue {
try {
// Show the install prompt
this.deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await this.deferredPrompt.userChoice;
logger.debug(`[PWA] User response to install prompt: ${outcome}`);
if (outcome === 'accepted') {
if (outcome === "accepted") {
logger.debug("[PWA] User accepted the install prompt");
this.showInstallPrompt = false;
} else {
@@ -154,10 +157,9 @@ export default class PWAInstallPrompt extends Vue {
this.dismissed = true;
this.showInstallPrompt = false;
}
// Clear the deferred prompt
this.deferredPrompt = null;
} catch (error) {
logger.error("[PWA] Error during install prompt:", error);
this.showInstallPrompt = false;
@@ -167,9 +169,9 @@ export default class PWAInstallPrompt extends Vue {
private dismissPrompt() {
this.dismissed = true;
this.showInstallPrompt = false;
// Don't show again for this session
sessionStorage.setItem('pwa-install-dismissed', 'true');
sessionStorage.setItem("pwa-install-dismissed", "true");
}
}
</script>
</script>

View File

@@ -1,17 +1,17 @@
<template>
<div class="heavy-component">
<h2>Heavy Data Processing Component</h2>
<!-- Data processing controls -->
<div class="controls">
<button @click="processData" :disabled="isProcessing">
{{ isProcessing ? 'Processing...' : 'Process Data' }}
<button :disabled="isProcessing" @click="processData">
{{ isProcessing ? "Processing..." : "Process Data" }}
</button>
<button @click="clearResults" :disabled="isProcessing">
<button :disabled="isProcessing" @click="clearResults">
Clear Results
</button>
</div>
<!-- Processing status -->
<div v-if="isProcessing" class="processing-status">
<div class="progress-bar">
@@ -19,15 +19,15 @@
</div>
<p>Processing {{ processedCount }} of {{ totalItems }} items...</p>
</div>
<!-- Results display -->
<div v-if="processedData.length > 0" class="results">
<h3>Processed Results ({{ processedData.length }} items)</h3>
<!-- Filter controls -->
<div class="filters">
<input
v-model="searchTerm"
<input
v-model="searchTerm"
placeholder="Search items..."
class="search-input"
/>
@@ -37,11 +37,11 @@
<option value="processed">Sort by Processed Date</option>
</select>
</div>
<!-- Results list -->
<div class="results-list">
<div
v-for="item in filteredAndSortedData"
<div
v-for="item in filteredAndSortedData"
:key="item.id"
class="result-item"
>
@@ -59,29 +59,29 @@
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="pagination">
<button
@click="previousPage"
<button
:disabled="currentPage === 1"
class="page-btn"
@click="previousPage"
>
Previous
</button>
<span class="page-info">
Page {{ currentPage }} of {{ totalPages }}
</span>
<button
@click="nextPage"
<button
:disabled="currentPage === totalPages"
class="page-btn"
@click="nextPage"
>
Next
</button>
</div>
</div>
<!-- Performance metrics -->
<div v-if="performanceMetrics" class="performance-metrics">
<h4>Performance Metrics</h4>
@@ -92,11 +92,15 @@
</div>
<div class="metric">
<span class="metric-label">Average per Item:</span>
<span class="metric-value">{{ performanceMetrics.averageTime }}ms</span>
<span class="metric-value"
>{{ performanceMetrics.averageTime }}ms</span
>
</div>
<div class="metric">
<span class="metric-label">Memory Usage:</span>
<span class="metric-value">{{ performanceMetrics.memoryUsage }}MB</span>
<span class="metric-value"
>{{ performanceMetrics.memoryUsage }}MB</span
>
</div>
</div>
</div>
@@ -104,7 +108,7 @@
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from 'vue-facing-decorator';
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
interface ProcessedItem {
id: number;
@@ -122,15 +126,15 @@ interface PerformanceMetrics {
/**
* Heavy Component for Data Processing
*
*
* Demonstrates a component that performs intensive data processing
* and would benefit from lazy loading to avoid blocking the main thread.
*
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: 'HeavyComponent'
name: "HeavyComponent",
})
export default class HeavyComponent extends Vue {
@Prop({ required: true }) readonly data!: {
@@ -138,233 +142,241 @@ export default class HeavyComponent extends Vue {
filters: Record<string, any>;
sortBy: string;
};
// Component state
isProcessing = false;
processedData: ProcessedItem[] = [];
progress = 0;
processedCount = 0;
totalItems = 0;
// UI state
searchTerm = '';
sortBy = 'name';
searchTerm = "";
sortBy = "name";
currentPage = 1;
itemsPerPage = 50;
// Performance tracking
performanceMetrics: PerformanceMetrics | null = null;
startTime = 0;
// Computed properties
get filteredAndSortedData(): ProcessedItem[] {
let filtered = this.processedData;
// Apply search filter
if (this.searchTerm) {
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(this.searchTerm.toLowerCase())
filtered = filtered.filter((item) =>
item.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
}
// Apply sorting
filtered.sort((a, b) => {
switch (this.sortBy) {
case 'name':
case "name":
return a.name.localeCompare(b.name);
case 'id':
case "id":
return a.id - b.id;
case 'processed':
case "processed":
return b.processedAt.getTime() - a.processedAt.getTime();
default:
return 0;
}
});
return filtered;
}
get paginatedData(): ProcessedItem[] {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.filteredAndSortedData.slice(start, end);
}
get totalPages(): number {
return Math.ceil(this.filteredAndSortedData.length / this.itemsPerPage);
}
// Lifecycle hooks
mounted(): void {
console.log('[HeavyComponent] Component mounted with', this.data.items.length, 'items');
console.log(
"[HeavyComponent] Component mounted with",
this.data.items.length,
"items",
);
this.totalItems = this.data.items.length;
}
// Methods
async processData(): Promise<void> {
if (this.isProcessing) return;
this.isProcessing = true;
this.progress = 0;
this.processedCount = 0;
this.processedData = [];
this.startTime = performance.now();
console.log('[HeavyComponent] Starting data processing...');
console.log("[HeavyComponent] Starting data processing...");
try {
// Process items in batches to avoid blocking the UI
const batchSize = 10;
const items = this.data.items;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
// Process batch
await this.processBatch(batch);
// Update progress
this.processedCount = Math.min(i + batchSize, items.length);
this.progress = (this.processedCount / items.length) * 100;
// Allow UI to update
await this.$nextTick();
// Small delay to prevent overwhelming the UI
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Calculate performance metrics
this.calculatePerformanceMetrics();
// Emit completion event
this.$emit('data-processed', {
this.$emit("data-processed", {
totalItems: this.processedData.length,
processingTime: performance.now() - this.startTime,
metrics: this.performanceMetrics
metrics: this.performanceMetrics,
});
console.log('[HeavyComponent] Data processing completed');
console.log("[HeavyComponent] Data processing completed");
} catch (error) {
console.error('[HeavyComponent] Processing error:', error);
this.$emit('processing-error', error);
console.error("[HeavyComponent] Processing error:", error);
this.$emit("processing-error", error);
} finally {
this.isProcessing = false;
}
}
private async processBatch(batch: Array<{ id: number; name: string }>): Promise<void> {
private async processBatch(
batch: Array<{ id: number; name: string }>,
): Promise<void> {
const processedBatch = await Promise.all(
batch.map(async (item) => {
const itemStartTime = performance.now();
// Simulate heavy processing
await this.simulateHeavyProcessing(item);
const processingTime = performance.now() - itemStartTime;
return {
id: item.id,
name: item.name,
processedAt: new Date(),
processingTime: Math.round(processingTime),
result: this.generateResult(item)
result: this.generateResult(item),
};
})
}),
);
this.processedData.push(...processedBatch);
}
private async simulateHeavyProcessing(item: { id: number; name: string }): Promise<void> {
private async simulateHeavyProcessing(item: {
id: number;
name: string;
}): Promise<void> {
// Simulate CPU-intensive work
const complexity = item.name.length * item.id;
const iterations = Math.min(complexity, 1000); // Cap at 1000 iterations
for (let i = 0; i < iterations; i++) {
// Simulate work
Math.sqrt(i) * Math.random();
}
// Simulate async work
await new Promise(resolve => setTimeout(resolve, Math.random() * 10));
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
}
private generateResult(item: { id: number; name: string }): any {
return {
hash: this.generateHash(item.name + item.id),
category: this.categorizeItem(item),
score: Math.random() * 100,
tags: this.generateTags(item)
tags: this.generateTags(item),
};
}
private generateHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(16);
}
private categorizeItem(item: { id: number; name: string }): string {
const categories = ['A', 'B', 'C', 'D', 'E'];
const categories = ["A", "B", "C", "D", "E"];
return categories[item.id % categories.length];
}
private generateTags(item: { id: number; name: string }): string[] {
const tags = ['important', 'urgent', 'review', 'archive', 'featured'];
const tags = ["important", "urgent", "review", "archive", "featured"];
return tags.filter((_, index) => (item.id + index) % 3 === 0);
}
private calculatePerformanceMetrics(): void {
const totalTime = performance.now() - this.startTime;
const averageTime = totalTime / this.processedData.length;
// Simulate memory usage calculation
const memoryUsage = this.processedData.length * 0.1; // 0.1MB per item
this.performanceMetrics = {
totalTime: Math.round(totalTime),
averageTime: Math.round(averageTime),
memoryUsage: Math.round(memoryUsage * 100) / 100
memoryUsage: Math.round(memoryUsage * 100) / 100,
};
}
clearResults(): void {
this.processedData = [];
this.performanceMetrics = null;
this.searchTerm = '';
this.searchTerm = "";
this.currentPage = 1;
console.log('[HeavyComponent] Results cleared');
console.log("[HeavyComponent] Results cleared");
}
previousPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
}
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;
}
}
formatDate(date: Date): string {
return date.toLocaleString();
}
// Event emitters
@Emit('data-processed')
@Emit("data-processed")
emitDataProcessed(data: any): any {
return data;
}
@Emit('processing-error')
@Emit("processing-error")
emitProcessingError(error: Error): Error {
return error;
}
@@ -539,4 +551,4 @@ export default class HeavyComponent extends Vue {
color: #007bff;
font-weight: bold;
}
</style>
</style>

View File

@@ -1,62 +1,69 @@
<template>
<div class="qr-scanner-component">
<h2>QR Code Scanner</h2>
<!-- Camera controls -->
<div class="camera-controls">
<button @click="startScanning" :disabled="isScanning || !hasCamera">
{{ isScanning ? 'Scanning...' : 'Start Scanning' }}
<button :disabled="isScanning || !hasCamera" @click="startScanning">
{{ isScanning ? "Scanning..." : "Start Scanning" }}
</button>
<button @click="stopScanning" :disabled="!isScanning">
<button :disabled="!isScanning" @click="stopScanning">
Stop Scanning
</button>
<button @click="switchCamera" :disabled="!isScanning || cameras.length <= 1">
<button
:disabled="!isScanning || cameras.length <= 1"
@click="switchCamera"
>
Switch Camera
</button>
</div>
<!-- Camera status -->
<div class="camera-status">
<div v-if="!hasCamera" class="status-error">
<p>Camera not available</p>
<p class="status-detail">This device doesn't have a camera or camera access is denied.</p>
<p class="status-detail">
This device doesn't have a camera or camera access is denied.
</p>
</div>
<div v-else-if="!isScanning" class="status-info">
<p>Camera ready</p>
<p class="status-detail">Click "Start Scanning" to begin QR code detection.</p>
<p class="status-detail">
Click "Start Scanning" to begin QR code detection.
</p>
</div>
<div v-else class="status-scanning">
<p>Scanning for QR codes...</p>
<p class="status-detail">Point camera at a QR code to scan.</p>
</div>
</div>
<!-- Camera view -->
<div v-if="isScanning && hasCamera" class="camera-container">
<video
<video
ref="videoElement"
class="camera-video"
autoplay
playsinline
muted
></video>
<!-- Scanning overlay -->
<div class="scanning-overlay">
<div class="scan-frame"></div>
<div class="scan-line"></div>
</div>
</div>
<!-- Scan results -->
<div v-if="scanResults.length > 0" class="scan-results">
<h3>Scan Results ({{ scanResults.length }})</h3>
<div class="results-list">
<div
v-for="(result, index) in scanResults"
<div
v-for="(result, index) in scanResults"
:key="index"
class="result-item"
>
@@ -65,82 +72,69 @@
<span class="result-time">{{ formatTime(result.timestamp) }}</span>
</div>
<div class="result-content">
<div class="qr-data">
<strong>Data:</strong> {{ result.data }}
</div>
<div class="qr-data"><strong>Data:</strong> {{ result.data }}</div>
<div class="qr-format">
<strong>Format:</strong> {{ result.format }}
</div>
</div>
<div class="result-actions">
<button @click="copyToClipboard(result.data)" class="copy-btn">
<button class="copy-btn" @click="copyToClipboard(result.data)">
Copy
</button>
<button @click="removeResult(index)" class="remove-btn">
<button class="remove-btn" @click="removeResult(index)">
Remove
</button>
</div>
</div>
</div>
<div class="results-actions">
<button @click="clearResults" class="clear-btn">
<button class="clear-btn" @click="clearResults">
Clear All Results
</button>
<button @click="exportResults" class="export-btn">
<button class="export-btn" @click="exportResults">
Export Results
</button>
</div>
</div>
<!-- Settings panel -->
<div class="settings-panel">
<h3>Scanner Settings</h3>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.continuousScanning"
/>
<input v-model="settings.continuousScanning" type="checkbox" />
Continuous Scanning
</label>
<p class="setting-description">
Automatically scan multiple QR codes without stopping
</p>
</div>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.audioFeedback"
/>
<input v-model="settings.audioFeedback" type="checkbox" />
Audio Feedback
</label>
<p class="setting-description">
Play sound when QR code is detected
</p>
<p class="setting-description">Play sound when QR code is detected</p>
</div>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.vibrateOnScan"
/>
<input v-model="settings.vibrateOnScan" type="checkbox" />
Vibration Feedback
</label>
<p class="setting-description">
Vibrate device when QR code is detected
</p>
</div>
<div class="setting-group">
<label>Scan Interval (ms):</label>
<input
type="number"
<input
v-model.number="settings.scanInterval"
type="number"
min="100"
max="5000"
step="100"
@@ -154,7 +148,7 @@
</template>
<script lang="ts">
import { Component, Vue, Emit } from 'vue-facing-decorator';
import { Component, Vue, Emit } from "vue-facing-decorator";
interface ScanResult {
data: string;
@@ -171,16 +165,16 @@ interface ScannerSettings {
/**
* QR Scanner Component
*
*
* Demonstrates lazy loading for camera-dependent features.
* This component would benefit from lazy loading as it requires
* camera permissions and heavy camera processing libraries.
*
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: 'QRScannerComponent'
name: "QRScannerComponent",
})
export default class QRScannerComponent extends Vue {
// Component state
@@ -188,275 +182,290 @@ export default class QRScannerComponent extends Vue {
hasCamera = false;
cameras: MediaDeviceInfo[] = [];
currentCameraIndex = 0;
// Video element reference
videoElement: HTMLVideoElement | null = null;
// Scan results
scanResults: ScanResult[] = [];
// Scanner settings
settings: ScannerSettings = {
continuousScanning: true,
audioFeedback: true,
vibrateOnScan: true,
scanInterval: 500
scanInterval: 500,
};
// Internal state
private stream: MediaStream | null = null;
private scanInterval: number | null = null;
private lastScanTime = 0;
// Lifecycle hooks
async mounted(): Promise<void> {
console.log('[QRScannerComponent] Component mounted');
console.log("[QRScannerComponent] Component mounted");
await this.initializeCamera();
}
beforeUnmount(): void {
this.stopScanning();
console.log('[QRScannerComponent] Component unmounting');
console.log("[QRScannerComponent] Component unmounting");
}
// Methods
async initializeCamera(): Promise<void> {
try {
// Check if camera is available
const devices = await navigator.mediaDevices.enumerateDevices();
this.cameras = devices.filter(device => device.kind === 'videoinput');
this.cameras = devices.filter((device) => device.kind === "videoinput");
this.hasCamera = this.cameras.length > 0;
if (this.hasCamera) {
console.log('[QRScannerComponent] Camera available:', this.cameras.length, 'devices');
console.log(
"[QRScannerComponent] Camera available:",
this.cameras.length,
"devices",
);
} else {
console.warn('[QRScannerComponent] No camera devices found');
console.warn("[QRScannerComponent] No camera devices found");
}
} catch (error) {
console.error('[QRScannerComponent] Camera initialization error:', error);
console.error("[QRScannerComponent] Camera initialization error:", error);
this.hasCamera = false;
}
}
async startScanning(): Promise<void> {
if (!this.hasCamera || this.isScanning) return;
try {
console.log('[QRScannerComponent] Starting QR scanning...');
console.log("[QRScannerComponent] Starting QR scanning...");
// Get camera stream
const constraints = {
video: {
deviceId: this.cameras[this.currentCameraIndex]?.deviceId
}
deviceId: this.cameras[this.currentCameraIndex]?.deviceId,
},
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
// Set up video element
this.videoElement = this.$refs.videoElement as HTMLVideoElement;
if (this.videoElement) {
this.videoElement.srcObject = this.stream;
await this.videoElement.play();
}
this.isScanning = true;
// Start QR code detection
this.startQRDetection();
console.log('[QRScannerComponent] QR scanning started');
console.log("[QRScannerComponent] QR scanning started");
} catch (error) {
console.error('[QRScannerComponent] Failed to start scanning:', error);
console.error("[QRScannerComponent] Failed to start scanning:", error);
this.hasCamera = false;
}
}
stopScanning(): void {
if (!this.isScanning) return;
console.log('[QRScannerComponent] Stopping QR scanning...');
console.log("[QRScannerComponent] Stopping QR scanning...");
// Stop QR detection
this.stopQRDetection();
// Stop camera stream
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
// Clear video element
if (this.videoElement) {
this.videoElement.srcObject = null;
this.videoElement = null;
}
this.isScanning = false;
console.log('[QRScannerComponent] QR scanning stopped');
console.log("[QRScannerComponent] QR scanning stopped");
}
async switchCamera(): Promise<void> {
if (this.cameras.length <= 1) return;
// Stop current scanning
this.stopScanning();
// Switch to next camera
this.currentCameraIndex = (this.currentCameraIndex + 1) % this.cameras.length;
this.currentCameraIndex =
(this.currentCameraIndex + 1) % this.cameras.length;
// Restart scanning with new camera
await this.startScanning();
console.log('[QRScannerComponent] Switched to camera:', this.currentCameraIndex);
console.log(
"[QRScannerComponent] Switched to camera:",
this.currentCameraIndex,
);
}
private startQRDetection(): void {
if (!this.settings.continuousScanning) return;
this.scanInterval = window.setInterval(() => {
this.detectQRCode();
}, this.settings.scanInterval);
}
private stopQRDetection(): void {
if (this.scanInterval) {
clearInterval(this.scanInterval);
this.scanInterval = null;
}
}
private async detectQRCode(): Promise<void> {
if (!this.videoElement || !this.isScanning) return;
const now = Date.now();
if (now - this.lastScanTime < this.settings.scanInterval) return;
try {
// Simulate QR code detection
// In a real implementation, you would use a QR code library like jsQR
const detectedQR = await this.simulateQRDetection();
if (detectedQR) {
this.addScanResult(detectedQR);
this.lastScanTime = now;
}
} catch (error) {
console.error('[QRScannerComponent] QR detection error:', error);
console.error("[QRScannerComponent] QR detection error:", error);
}
}
private async simulateQRDetection(): Promise<ScanResult | null> {
// Simulate QR code detection with random chance
if (Math.random() < 0.1) { // 10% chance of detection
if (Math.random() < 0.1) {
// 10% chance of detection
const sampleData = [
'https://example.com/qr1',
'WIFI:S:MyNetwork;T:WPA;P:password123;;',
'BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nTEL:+1234567890\nEND:VCARD',
'otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'
"https://example.com/qr1",
"WIFI:S:MyNetwork;T:WPA;P:password123;;",
"BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nTEL:+1234567890\nEND:VCARD",
"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
];
const formats = ['URL', 'WiFi', 'vCard', 'TOTP'];
const formats = ["URL", "WiFi", "vCard", "TOTP"];
const randomIndex = Math.floor(Math.random() * sampleData.length);
return {
data: sampleData[randomIndex],
format: formats[randomIndex],
timestamp: new Date()
timestamp: new Date(),
};
}
return null;
}
private addScanResult(result: ScanResult): void {
// Check for duplicates
const isDuplicate = this.scanResults.some(
existing => existing.data === result.data
(existing) => existing.data === result.data,
);
if (!isDuplicate) {
this.scanResults.unshift(result);
// Provide feedback
this.provideFeedback();
// Emit event
this.$emit('qr-detected', result.data);
console.log('[QRScannerComponent] QR code detected:', result.data);
this.$emit("qr-detected", result.data);
console.log("[QRScannerComponent] QR code detected:", result.data);
}
}
private provideFeedback(): void {
// Audio feedback
if (this.settings.audioFeedback) {
this.playBeepSound();
}
// Vibration feedback
if (this.settings.vibrateOnScan && 'vibrate' in navigator) {
if (this.settings.vibrateOnScan && "vibrate" in navigator) {
navigator.vibrate(100);
}
}
private playBeepSound(): void {
// Create a simple beep sound
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
oscillator.type = 'sine';
oscillator.type = "sine";
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.1,
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).then(() => {
console.log('[QRScannerComponent] Copied to clipboard:', text);
}).catch(error => {
console.error('[QRScannerComponent] Failed to copy:', error);
});
navigator.clipboard
.writeText(text)
.then(() => {
console.log("[QRScannerComponent] Copied to clipboard:", text);
})
.catch((error) => {
console.error("[QRScannerComponent] Failed to copy:", error);
});
}
removeResult(index: number): void {
this.scanResults.splice(index, 1);
}
clearResults(): void {
this.scanResults = [];
console.log('[QRScannerComponent] Results cleared');
console.log("[QRScannerComponent] Results cleared");
}
exportResults(): void {
const data = JSON.stringify(this.scanResults, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `qr-scan-results-${new Date().toISOString().split('T')[0]}.json`;
a.download = `qr-scan-results-${new Date().toISOString().split("T")[0]}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[QRScannerComponent] Results exported');
console.log("[QRScannerComponent] Results exported");
}
formatTime(date: Date): string {
return date.toLocaleTimeString();
}
// Event emitters
@Emit('qr-detected')
@Emit("qr-detected")
emitQRDetected(data: string): string {
return data;
}
@@ -573,8 +582,12 @@ export default class QRScannerComponent extends Vue {
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
0% {
top: 0;
}
100% {
top: 100%;
}
}
.scan-results {
@@ -705,4 +718,4 @@ export default class QRScannerComponent extends Vue {
margin-top: 3px;
margin-left: 24px;
}
</style>
</style>

View File

@@ -1,99 +1,100 @@
<template>
<div class="threejs-viewer">
<h2>3D Model Viewer</h2>
<!-- Viewer controls -->
<div class="viewer-controls">
<button @click="loadModel" :disabled="isLoading || !modelUrl">
{{ isLoading ? 'Loading...' : 'Load Model' }}
<button :disabled="isLoading || !modelUrl" @click="loadModel">
{{ isLoading ? "Loading..." : "Load Model" }}
</button>
<button @click="resetCamera" :disabled="!isModelLoaded">
<button :disabled="!isModelLoaded" @click="resetCamera">
Reset Camera
</button>
<button @click="toggleAnimation" :disabled="!isModelLoaded">
{{ isAnimating ? 'Stop' : 'Start' }} Animation
<button :disabled="!isModelLoaded" @click="toggleAnimation">
{{ isAnimating ? "Stop" : "Start" }} Animation
</button>
<button @click="toggleWireframe" :disabled="!isModelLoaded">
{{ showWireframe ? 'Hide' : 'Show' }} Wireframe
<button :disabled="!isModelLoaded" @click="toggleWireframe">
{{ showWireframe ? "Hide" : "Show" }} Wireframe
</button>
</div>
<!-- Loading status -->
<div v-if="isLoading" class="loading-status">
<div class="loading-spinner"></div>
<p>Loading 3D model...</p>
<p class="loading-detail">{{ loadingProgress }}% complete</p>
</div>
<!-- Error status -->
<div v-if="loadError" class="error-status">
<p>Failed to load model: {{ loadError }}</p>
<button @click="retryLoad" class="retry-btn">Retry</button>
<button class="retry-btn" @click="retryLoad">Retry</button>
</div>
<!-- 3D Canvas -->
<div
<div
ref="canvasContainer"
class="canvas-container"
:class="{ 'model-loaded': isModelLoaded }"
>
<canvas
ref="threeCanvas"
class="three-canvas"
></canvas>
<canvas ref="threeCanvas" class="three-canvas"></canvas>
<!-- Overlay controls -->
<div v-if="isModelLoaded" class="overlay-controls">
<div class="control-group">
<label>Camera Distance:</label>
<input
type="range"
<input
v-model.number="cameraDistance"
min="1"
max="20"
type="range"
min="1"
max="20"
step="0.1"
@input="updateCameraDistance"
/>
<span>{{ cameraDistance.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Rotation Speed:</label>
<input
type="range"
<input
v-model.number="rotationSpeed"
min="0"
max="2"
type="range"
min="0"
max="2"
step="0.1"
/>
<span>{{ rotationSpeed.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Light Intensity:</label>
<input
type="range"
<input
v-model.number="lightIntensity"
min="0"
max="2"
type="range"
min="0"
max="2"
step="0.1"
@input="updateLightIntensity"
/>
<span>{{ lightIntensity.toFixed(1) }}</span>
</div>
</div>
<!-- Model info -->
<div v-if="modelInfo" class="model-info">
<h4>Model Information</h4>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Vertices:</span>
<span class="info-value">{{ modelInfo.vertexCount.toLocaleString() }}</span>
<span class="info-value">{{
modelInfo.vertexCount.toLocaleString()
}}</span>
</div>
<div class="info-item">
<span class="info-label">Faces:</span>
<span class="info-value">{{ modelInfo.faceCount.toLocaleString() }}</span>
<span class="info-value">{{
modelInfo.faceCount.toLocaleString()
}}</span>
</div>
<div class="info-item">
<span class="info-label">Materials:</span>
@@ -101,12 +102,14 @@
</div>
<div class="info-item">
<span class="info-label">File Size:</span>
<span class="info-value">{{ formatFileSize(modelInfo.fileSize) }}</span>
<span class="info-value">{{
formatFileSize(modelInfo.fileSize)
}}</span>
</div>
</div>
</div>
</div>
<!-- Performance metrics -->
<div v-if="performanceMetrics" class="performance-metrics">
<h4>Performance Metrics</h4>
@@ -117,11 +120,15 @@
</div>
<div class="metric">
<span class="metric-label">Render Time:</span>
<span class="metric-value">{{ performanceMetrics.renderTime }}ms</span>
<span class="metric-value"
>{{ performanceMetrics.renderTime }}ms</span
>
</div>
<div class="metric">
<span class="metric-label">Memory Usage:</span>
<span class="metric-value">{{ performanceMetrics.memoryUsage }}MB</span>
<span class="metric-value"
>{{ performanceMetrics.memoryUsage }}MB</span
>
</div>
<div class="metric">
<span class="metric-label">Draw Calls:</span>
@@ -133,7 +140,7 @@
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from 'vue-facing-decorator';
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
interface ModelInfo {
vertexCount: number;
@@ -155,43 +162,43 @@ interface PerformanceMetrics {
/**
* ThreeJS 3D Model Viewer Component
*
*
* Demonstrates lazy loading for heavy 3D rendering libraries.
* This component would benefit from lazy loading as ThreeJS is a large
* library that's only needed for 3D visualization features.
*
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: 'ThreeJSViewer'
name: "ThreeJSViewer",
})
export default class ThreeJSViewer extends Vue {
@Prop({ required: true }) readonly modelUrl!: string;
// Component state
isLoading = false;
isModelLoaded = false;
loadError: string | null = null;
loadingProgress = 0;
// Animation state
isAnimating = false;
showWireframe = false;
// Camera and lighting controls
cameraDistance = 5;
rotationSpeed = 0.5;
lightIntensity = 1;
// Canvas references
canvasContainer: HTMLElement | null = null;
threeCanvas: HTMLCanvasElement | null = null;
// Model and performance data
modelInfo: ModelInfo | null = null;
performanceMetrics: PerformanceMetrics | null = null;
// ThreeJS objects (will be lazy loaded)
private three: any = null;
private scene: any = null;
@@ -202,98 +209,97 @@ export default class ThreeJSViewer extends Vue {
private animationId: number | null = null;
private frameCount = 0;
private lastTime = 0;
// Lifecycle hooks
mounted(): void {
console.log('[ThreeJSViewer] Component mounted');
console.log("[ThreeJSViewer] Component mounted");
this.initializeCanvas();
}
beforeUnmount(): void {
this.cleanup();
console.log('[ThreeJSViewer] Component unmounting');
console.log("[ThreeJSViewer] Component unmounting");
}
// Methods
private initializeCanvas(): void {
this.canvasContainer = this.$refs.canvasContainer as HTMLElement;
this.threeCanvas = this.$refs.threeCanvas as HTMLCanvasElement;
if (this.threeCanvas) {
this.threeCanvas.width = this.canvasContainer.clientWidth;
this.threeCanvas.height = this.canvasContainer.clientHeight;
}
}
async loadModel(): Promise<void> {
if (this.isLoading || !this.modelUrl) return;
this.isLoading = true;
this.loadError = null;
this.loadingProgress = 0;
try {
console.log('[ThreeJSViewer] Loading 3D model:', this.modelUrl);
console.log("[ThreeJSViewer] Loading 3D model:", this.modelUrl);
// Lazy load ThreeJS
await this.loadThreeJS();
// Initialize scene
await this.initializeScene();
// Load model
await this.loadModelFile();
// Start rendering
this.startRendering();
this.isModelLoaded = true;
this.isLoading = false;
// Emit model loaded event
this.$emit('model-loaded', this.modelInfo);
console.log('[ThreeJSViewer] Model loaded successfully');
this.$emit("model-loaded", this.modelInfo);
console.log("[ThreeJSViewer] Model loaded successfully");
} catch (error) {
console.error('[ThreeJSViewer] Failed to load model:', error);
this.loadError = error instanceof Error ? error.message : 'Unknown error';
console.error("[ThreeJSViewer] Failed to load model:", error);
this.loadError = error instanceof Error ? error.message : "Unknown error";
this.isLoading = false;
}
}
private async loadThreeJS(): Promise<void> {
// Simulate loading ThreeJS library
this.loadingProgress = 20;
await this.simulateLoading(500);
// In a real implementation, you would import ThreeJS here
// this.three = await import('three');
this.loadingProgress = 40;
await this.simulateLoading(300);
}
private async initializeScene(): Promise<void> {
this.loadingProgress = 60;
// Simulate scene initialization
await this.simulateLoading(400);
// In a real implementation, you would set up ThreeJS scene here
// this.scene = new this.three.Scene();
// this.camera = new this.three.PerspectiveCamera(75, width / height, 0.1, 1000);
// this.renderer = new this.three.WebGLRenderer({ canvas: this.threeCanvas });
this.loadingProgress = 80;
}
private async loadModelFile(): Promise<void> {
this.loadingProgress = 90;
// Simulate model loading
await this.simulateLoading(600);
// Simulate model info
this.modelInfo = {
vertexCount: Math.floor(Math.random() * 50000) + 1000,
@@ -302,144 +308,144 @@ export default class ThreeJSViewer extends Vue {
fileSize: Math.floor(Math.random() * 5000000) + 100000,
boundingBox: {
min: { x: -1, y: -1, z: -1 },
max: { x: 1, y: 1, z: 1 }
}
max: { x: 1, y: 1, z: 1 },
},
};
this.loadingProgress = 100;
}
private async simulateLoading(delay: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, delay));
return new Promise((resolve) => setTimeout(resolve, delay));
}
private startRendering(): void {
if (!this.isModelLoaded) return;
this.isAnimating = true;
this.animate();
// Start performance monitoring
this.startPerformanceMonitoring();
}
private animate(): void {
if (!this.isAnimating) return;
this.animationId = requestAnimationFrame(() => this.animate());
// Simulate model rotation
if (this.model && this.rotationSpeed > 0) {
// this.model.rotation.y += this.rotationSpeed * 0.01;
}
// Simulate rendering
// this.renderer.render(this.scene, this.camera);
this.frameCount++;
}
private startPerformanceMonitoring(): void {
const updateMetrics = () => {
if (!this.isAnimating) return;
const now = performance.now();
const deltaTime = now - this.lastTime;
if (deltaTime > 0) {
const fps = Math.round(1000 / deltaTime);
this.performanceMetrics = {
fps: Math.min(fps, 60), // Cap at 60 FPS for display
renderTime: Math.round(deltaTime),
memoryUsage: Math.round((Math.random() * 50 + 10) * 100) / 100,
drawCalls: Math.floor(Math.random() * 100) + 10
drawCalls: Math.floor(Math.random() * 100) + 10,
};
}
this.lastTime = now;
requestAnimationFrame(updateMetrics);
};
updateMetrics();
}
resetCamera(): void {
if (!this.isModelLoaded) return;
this.cameraDistance = 5;
this.updateCameraDistance();
console.log('[ThreeJSViewer] Camera reset');
console.log("[ThreeJSViewer] Camera reset");
}
toggleAnimation(): void {
this.isAnimating = !this.isAnimating;
if (this.isAnimating) {
this.animate();
} else if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
console.log('[ThreeJSViewer] Animation toggled:', this.isAnimating);
console.log("[ThreeJSViewer] Animation toggled:", this.isAnimating);
}
toggleWireframe(): void {
this.showWireframe = !this.showWireframe;
// In a real implementation, you would toggle wireframe mode
// this.model.traverse((child: any) => {
// if (child.isMesh) {
// child.material.wireframe = this.showWireframe;
// }
// });
console.log('[ThreeJSViewer] Wireframe toggled:', this.showWireframe);
console.log("[ThreeJSViewer] Wireframe toggled:", this.showWireframe);
}
updateCameraDistance(): void {
if (!this.isModelLoaded) return;
// In a real implementation, you would update camera position
// this.camera.position.z = this.cameraDistance;
// this.camera.lookAt(0, 0, 0);
}
updateLightIntensity(): void {
if (!this.isModelLoaded) return;
// In a real implementation, you would update light intensity
// this.light.intensity = this.lightIntensity;
}
retryLoad(): void {
this.loadError = null;
this.loadModel();
}
private cleanup(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.renderer) {
this.renderer.dispose();
}
this.isAnimating = false;
this.isModelLoaded = false;
}
formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
// Event emitters
@Emit('model-loaded')
@Emit("model-loaded")
emitModelLoaded(info: ModelInfo): ModelInfo {
return info;
}
@@ -499,8 +505,12 @@ export default class ThreeJSViewer extends Vue {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-detail {
@@ -654,4 +664,4 @@ export default class ThreeJSViewer extends Vue {
color: #007bff;
font-weight: bold;
}
</style>
</style>

View File

@@ -1682,3 +1682,7 @@ export const PUSH_NOTIFICATION_TIMEOUT_SHORT = 3000;
export const PUSH_NOTIFICATION_TIMEOUT_MEDIUM = 5000;
export const PUSH_NOTIFICATION_TIMEOUT_LONG = 7000;
export const PUSH_NOTIFICATION_TIMEOUT_PERSISTENT = -1;
// InviteOneAcceptView.vue timeout constants
export const INVITE_TIMEOUT_STANDARD = 3000; // Standard error messages
export const INVITE_TIMEOUT_LONG = 5000; // Missing invite and invalid data errors

View File

@@ -82,7 +82,9 @@ export const baseUrlSchema = z.object({
});
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = Object.keys(deepLinkSchemas) as readonly (keyof typeof deepLinkSchemas)[];
export const VALID_DEEP_LINK_ROUTES = Object.keys(
deepLinkSchemas,
) as readonly (keyof typeof deepLinkSchemas)[];
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
@@ -94,4 +96,6 @@ export interface DeepLinkError extends Error {
}
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES as [string, ...string[]]);
export const routeSchema = z.enum(
VALID_DEEP_LINK_ROUTES as [string, ...string[]],
);

View File

@@ -72,7 +72,8 @@ const handleDeepLink = async (data: { url: string }) => {
await deepLinkHandler.handleDeepLink(data.url);
} catch (error) {
logger.error("[DeepLink] Error handling deep link: ", error);
let message: string = error instanceof Error ? error.message : safeStringify(error);
let message: string =
error instanceof Error ? error.message : safeStringify(error);
if (data.url) {
message += `\nURL: ${data.url}`;
}

View File

@@ -10,7 +10,6 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
app.config.errorHandler = (

View File

@@ -56,7 +56,9 @@ import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks";
// Helper function to extract the first key from a Zod object schema
function getFirstKeyFromZodObject(schema: z.ZodObject<any>): string | undefined {
function getFirstKeyFromZodObject(
schema: z.ZodObject<any>,
): string | undefined {
const shape = schema.shape;
const keys = Object.keys(shape);
return keys.length > 0 ? keys[0] : undefined;
@@ -64,21 +66,24 @@ function getFirstKeyFromZodObject(schema: z.ZodObject<any>): string | undefined
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
*
* It's an object where keys are the deep link routes and values are objects with 'name' and 'paramKey'.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
*/
export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
Object.entries(deepLinkSchemas).reduce((acc, [routeName, schema]) => {
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
acc[routeName] = {
name: routeName,
paramKey
Object.entries(deepLinkSchemas).reduce(
(acc, [routeName, schema]) => {
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
acc[routeName] = {
name: routeName,
paramKey,
};
return acc;
}, {} as Record<string, { name: string; paramKey?: string }>);
},
{} as Record<string, { name: string; paramKey?: string }>,
);
/**
* Handles processing and routing of deep links in the application.
@@ -200,7 +205,10 @@ export class DeepLinkHandler {
validatedQuery = await schema.parseAsync(query);
} catch (error) {
// For parameter validation errors, provide specific error feedback
logConsoleAndDb(`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`, true);
logConsoleAndDb(
`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`,
true,
);
await this.router.replace({
name: "deep-link-error",
params,
@@ -223,7 +231,10 @@ export class DeepLinkHandler {
query: validatedQuery,
});
} catch (error) {
logConsoleAndDb(`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`, true);
logConsoleAndDb(
`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`,
true,
);
// For parameter validation errors, provide specific error feedback
await this.router.replace({
name: "deep-link-error",
@@ -231,12 +242,11 @@ export class DeepLinkHandler {
query: {
originalPath: path,
errorCode: "ROUTING_ERROR",
errorMessage: `Error routing to ${routeName}: ${(JSON.stringify(error))}`,
errorMessage: `Error routing to ${routeName}: ${JSON.stringify(error)}`,
...validatedQuery,
},
});
}
}
/**

View File

@@ -1302,5 +1302,7 @@ export class CapacitorPlatformService implements PlatformService {
// --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {}
public get isPWAEnabled(): boolean { return false; }
public get isPWAEnabled(): boolean {
return false;
}
}

View File

@@ -166,5 +166,7 @@ export class ElectronPlatformService extends CapacitorPlatformService {
// --- PWA/Web-only methods (no-op for Electron) ---
public registerServiceWorker(): void {}
public get isPWAEnabled(): boolean { return false; }
public get isPWAEnabled(): boolean {
return false;
}
}

View File

@@ -77,5 +77,7 @@ declare global {
declare module 'vue' {
interface ComponentCustomProperties {
$notify: (notification: any, timeout?: number) => void;
$route: import('vue-router').RouteLocationNormalizedLoaded;
$router: import('vue-router').Router;
}
}

View File

@@ -258,7 +258,7 @@
<!-- id used by puppeteer test script -->
<h3
id="advanced"
data-testid="advancedSettings"
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
@click="toggleShowGeneralAdvanced"
>
@@ -1092,13 +1092,11 @@ export default class AccountViewView extends Vue {
this.publicHex = identity.keys[0].publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = identity.keys[0].meta?.derivationPath as string;
} else if (account?.publicKeyHex) {
// use the backup values in the top level of the account object
this.publicHex = account.publicKeyHex as string;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = account.derivationPath as string;
}
}

View File

@@ -22,131 +22,31 @@
</div>
<!-- New Contact -->
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<span v-if="isRegistered" class="flex">
<router-link
:to="{ name: 'invite-one' }"
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<font-awesome icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<ContactInputForm
:is-registered="isRegistered"
v-model="contactInput"
@submit="onClickNewContact"
@show-onboard-meeting="showOnboardMeetingDialog"
@registration-required="notify.warning('You must get registered before you can create invites.')"
@navigate-onboard-meeting="$router.push({ name: 'onboard-meeting-list' })"
@qr-scan="handleQRCodeClick"
/>
<button
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@click="showOnboardMeetingDialog()"
>
<font-awesome icon="chair" class="fa-fw text-2xl" />
</button>
</span>
<span v-else class="flex">
<span
class="flex items-center 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.5 py-1 mr-1 rounded-md"
>
<font-awesome
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="
notify.warning(
'You must get registered before you can create invites.',
)
"
/>
</span>
<span
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<font-awesome
icon="chair"
class="fa-fw text-2xl"
@click="$router.push({ name: 'onboard-meeting-list' })"
/>
</span>
</span>
<button
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@click="handleQRCodeClick"
>
<font-awesome icon="qrcode" class="fa-fw text-2xl" />
</button>
<textarea
v-model="contactInput"
type="text"
placeholder="New URL or DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
/>
<button
class="px-4 rounded-r bg-green-200 border border-green-400"
@click="onClickNewContact()"
>
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
<div v-if="contacts.length > 0" class="flex justify-between">
<div class="">
<div v-if="!showGiveNumbers" class="flex items-center">
<input
type="checkbox"
:checked="contactsSelected.length === contacts.length"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
/>
<button
v-if="!showGiveNumbers"
:class="
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'
"
data-testId="copySelectedContactsButtonTop"
@click="copySelectedContacts()"
>
Copy
</button>
<font-awesome
icon="circle-info"
class="text-2xl text-blue-500 ml-2"
@click="showCopySelectionsInfo()"
/>
</div>
</div>
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
>
{{
showGiveTotals
? "Totals"
: showGiveConfirmed
? "Confirmed Amounts"
: "Unconfirmed Amounts"
}}
<font-awesome icon="left-right" class="fa-fw" />
</button>
<button
class="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-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
{{ showGiveNumbers ? "Hide Actions" : "See Actions" }}
</button>
</div>
</div>
<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
@@ -165,183 +65,48 @@
id="listContacts"
class="border-t border-slate-300 my-2"
>
<li
v-for="contact in filteredContacts()"
<ContactListItem
v-for="contact in filteredContacts"
:key="contact.did"
class="border-b border-slate-300 pt-1 pb-1"
data-testId="contactListItem"
>
<div class="flex items-center justify-between gap-3">
<div class="flex overflow-hidden min-w-0 items-center gap-3">
<input
v-if="!showGiveNumbers"
type="checkbox"
:checked="contactsSelected.includes(contact.did)"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
@click="
contactsSelected.includes(contact.did)
? contactsSelected.splice(
contactsSelected.indexOf(contact.did),
1,
)
: contactsSelected.push(contact.did)
"
/>
<EntityIcon
:contact="contact"
:icon-size="48"
class="shrink-0 align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden"
@click="showLargeIdenticon = contact"
/>
<div class="overflow-hidden">
<h2 class="text-base font-semibold truncate">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
{{ contactNameNonBreakingSpace(contact.name) }}
</router-link>
</h2>
<div class="flex gap-1.5 items-center overflow-hidden">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500"
/>
</router-link>
<span class="text-xs truncate">{{ contact.did }}</span>
</div>
<div class="text-sm">
{{ contact.notes }}
</div>
</div>
</div>
<div
v-if="showGiveNumbers && contact.did != activeDid"
class="flex gap-1.5 items-end"
>
<div class="text-center">
<div class="text-xs leading-none mb-1">From/To</div>
<div class="flex items-center">
<button
class="text-sm 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-2.5 py-1.5 rounded-l-md"
:title="givenToMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(contact.did, activeDid)"
>
{{
/* eslint-disable prettier/prettier */
showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
</button>
<button
class="text-sm 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-2.5 py-1.5 rounded-r-md border-l"
:title="givenByMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(activeDid, contact.did)"
>
{{
/* eslint-disable prettier/prettier */
showGiveTotals
? ((givenByMeConfirmed[contact.did] || 0)
+ (givenByMeUnconfirmed[contact.did] || 0))
: showGiveConfirmed
? (givenByMeConfirmed[contact.did] || 0)
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
</button>
</div>
</div>
<button
class="text-sm 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-2 py-1.5 rounded-md"
data-testId="offerButton"
@click="openOfferDialog(contact.did, contact.name)"
>
Offer
</button>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="text-sm 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-2 py-1.5 rounded-md"
title="See more given activity"
>
<font-awesome icon="file-lines" class="fa-fw" />
</router-link>
</div>
</div>
</li>
: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>
<div v-if="contacts.length > 0" class="mt-2 w-full text-left">
<input
v-if="!showGiveNumbers"
type="checkbox"
:checked="contactsSelected.length === contacts.length"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllBottom"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
/>
<button
v-if="!showGiveNumbers"
:class="
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'
"
@click="copySelectedContacts()"
>
Copy
</button>
</div>
<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" />
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full">
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:contact="showLargeIdenticon"
:icon-size="512"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="showLargeIdenticon = undefined"
/>
</div>
</div>
<LargeIdenticonModal
:contact="showLargeIdenticon"
@close="showLargeIdenticon = undefined"
/>
</section>
</template>
@@ -362,6 +127,11 @@ 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 { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import { logConsoleAndDb } from "../db/index";
import { Contact } from "../db/tables/contacts";
@@ -418,6 +188,26 @@ import {
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({
components: {
GiftedDialog,
@@ -426,6 +216,11 @@ import {
QuickNav,
ContactNameDialog,
TopMessage,
ContactListItem,
ContactInputForm,
ContactListHeader,
ContactBulkActions,
LargeIdenticonModal,
},
mixins: [PlatformServiceMixin],
})
@@ -470,6 +265,11 @@ export default class ContactsView extends Vue {
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);
@@ -603,9 +403,7 @@ export default class ContactsView extends Vue {
}
}
private contactNameNonBreakingSpace(contactName?: string) {
return (contactName || AppString.NO_CONTACT_NAME).replace(/\s/g, "\u00A0");
}
// Legacy danger() and warning() methods removed - now using this.notify.error() and this.notify.warning()
@@ -624,7 +422,8 @@ export default class ContactsView extends Vue {
);
}
private filteredContacts() {
// Computed properties for template streamlining
get filteredContacts() {
return this.showGiveNumbers
? this.contactsSelected.length === 0
? this.contacts
@@ -634,6 +433,54 @@ export default class ContactsView extends Vue {
: 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;
@@ -723,14 +570,31 @@ export default class ContactsView extends Vue {
}
}
/**
* 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) {
// Use notification helper and constant
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) ||
@@ -741,7 +605,7 @@ export default class ContactsView extends Vue {
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // "did" is reliable as of v 0.3.49
did: userInfo.did || payload["iss"],
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
@@ -749,10 +613,16 @@ export default class ContactsView extends Vue {
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return;
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 = [];
@@ -766,61 +636,79 @@ export default class ContactsView extends Vue {
await Promise.all(lineAdded);
this.notify.success(NOTIFY_CONTACTS_ADDED_CSV.message);
} catch (e) {
const fullError =
"Error adding contacts from CSV: " + errorStringForLog(e);
const fullError = "Error adding contacts from CSV: " + errorStringForLog(e);
logConsoleAndDb(fullError, true);
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACTS_ADD_ERROR.message);
}
// Replace PlatformServiceFactory query with mixin method
this.contacts = await this.$getAllContacts();
return;
return true;
}
return false;
}
/**
* Parse contact from DID format with optional parameters
*/
private async tryParseDidContact(contactInput: string): Promise<boolean> {
if (contactInput.startsWith("did:")) {
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(); // eslint-disable-line prettier/prettier
nextPublicKeyHashInput = contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier
}
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();
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString(
"base64",
);
}
let nextPubKeyHashB64 = nextPublicKeyHashInput;
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) {
// it must be all hex (compressed public key), so convert
nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier
}
const newContact = {
did,
name,
publicKeyBase64,
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
return;
}
// 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("[")) {
// assume there's a JSON array of contacts in the input
const jsonContactInput = contactInput.substring(
contactInput.indexOf("["),
contactInput.lastIndexOf("]") + 1,
@@ -831,18 +719,14 @@ export default class ContactsView extends Vue {
name: "contact-import",
query: { contacts: JSON.stringify(contacts) },
});
return true;
} catch (e) {
const fullError =
"Error adding contacts from array: " + errorStringForLog(e);
const fullError = "Error adding contacts from array: " + errorStringForLog(e);
logConsoleAndDb(fullError, true);
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACT_INPUT_PARSE_ERROR.message);
}
return;
}
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACT_NO_CONTACT_FOUND.message);
return false;
}
private async addContactFromEndorserMobileLine(
@@ -855,94 +739,145 @@ export default class ContactsView extends Vue {
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) {
if (!newContact.did) {
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACT_NO_DID.message);
return;
}
if (!isDid(newContact.did)) {
// Use notification helper and constant
this.notify.error(NOTIFY_CONTACT_INVALID_DID.message);
// Validate contact data
if (!this.validateContactData(newContact)) {
return;
}
// Replace PlatformServiceFactory with mixin method
try {
// Insert contact into database
await this.$insertContact(newContact);
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
let addedMessage;
if (this.activeDid) {
this.setVisibility(newContact, true, false);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage = NOTIFY_CONTACTS_ADDED_VISIBLE.message;
} else {
addedMessage = NOTIFY_CONTACTS_ADDED.message;
}
// Update local contacts list
this.updateContactsList(newContact);
// Set visibility and get success message
const addedMessage = await this.handleContactVisibility(newContact);
// Clear input field
this.contactInput = "";
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 1000);
}
}
// Use notification helper and constant
// Handle registration prompt if needed
await this.handleRegistrationPrompt(newContact);
// Show success notification
this.notify.success(addedMessage);
} catch (err) {
const fullError =
"Error when adding contact to storage: " + errorStringForLog(err);
logConsoleAndDb(fullError, true);
let message = NOTIFY_CONTACT_IMPORT_ERROR.message;
if (
(err as any).message?.indexOf(
"Key already exists in the object store.",
) > -1
) {
message = NOTIFY_CONTACT_IMPORT_CONFLICT.message;
}
if ((err as any).name === "ConstraintError") {
message += " " + NOTIFY_CONTACT_IMPORT_CONSTRAINT.message;
}
// Use notification helper and constant
this.notify.error(message, TIMEOUTS.LONG);
this.handleContactAddError(err);
}
}
// note that this is also in DIDView.vue
/**
* 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 || this.hideRegisterPromptOnNewContact || newContact.registered) {
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: any): void {
const fullError = "Error when adding contact to storage: " + errorStringForLog(err);
logConsoleAndDb(fullError, true);
let message = NOTIFY_CONTACT_IMPORT_ERROR.message;
if ((err as any).message?.indexOf("Key already exists in the object store.") > -1) {
message = NOTIFY_CONTACT_IMPORT_CONFLICT.message;
}
if ((err as any).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();
@@ -994,7 +929,10 @@ export default class ContactsView extends Vue {
}
}
// note that this is also in DIDView.vue
/**
* 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,
@@ -1027,6 +965,10 @@ export default class ContactsView extends Vue {
}
}
/**
* 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 (
@@ -1173,6 +1115,10 @@ export default class ContactsView extends Vue {
};
}
/**
* 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
@@ -1216,6 +1162,10 @@ export default class ContactsView extends Vue {
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
@@ -1268,6 +1218,10 @@ export default class ContactsView extends Vue {
}
}
/**
* Handle QR code button click - route to appropriate scanner
* Uses native scanner on mobile platforms, web scanner otherwise
*/
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });

View File

@@ -31,79 +31,124 @@
<h2>Supported Deep Links</h2>
<ul>
<li v-for="(routeItem, index) in validRoutes" :key="index">
<code>timesafari://{{ routeItem }}/:{{ deepLinkSchemaKeys[routeItem] }}</code>
<code
>timesafari://{{ routeItem }}/:{{
deepLinkSchemaKeys[routeItem]
}}</code
>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { VALID_DEEP_LINK_ROUTES, deepLinkSchemas } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db/databaseUtil";
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import {
VALID_DEEP_LINK_ROUTES,
deepLinkSchemas,
} from "../interfaces/deepLinks";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
const route = useRoute();
const router = useRouter();
// an object with the route as the key and the first param name as the value
const deepLinkSchemaKeys = Object.fromEntries(
Object.entries(deepLinkSchemas).map(([route, schema]) => {
const param = Object.keys(schema.shape)[0];
return [route, param];
})
);
/**
* DeepLinkErrorView - Displays error information for invalid deep links
*
* This view shows detailed error information when a user follows an invalid
* or unsupported deep link. It provides debugging information and allows
* users to report issues or navigate back to the home page.
*
* @author Matthew Raymer
*/
@Component({
name: "DeepLinkErrorView",
mixins: [PlatformServiceMixin],
})
export default class DeepLinkErrorView extends Vue {
// Route and router access
get route() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this as any).$route as RouteLocationNormalizedLoaded;
}
// Extract error information from query params
const errorCode = computed(
() => (route.query.errorCode as string) || "UNKNOWN_ERROR",
);
const errorMessage = computed(
() =>
(route.query.errorMessage as string) ||
"The deep link you followed is invalid or not supported.",
);
const originalPath = computed(() => route.query.originalPath as string);
const validRoutes = VALID_DEEP_LINK_ROUTES;
get router() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this as any).$router as Router;
}
// Format the path and include any parameters
const formattedPath = computed(() => {
if (!originalPath.value) return "";
const path = originalPath.value.replace(/^\/+/, "");
// Deep link schema keys mapping
get deepLinkSchemaKeys() {
return Object.fromEntries(
Object.entries(deepLinkSchemas).map(([route, schema]) => {
const param = Object.keys(schema.shape)[0];
return [route, param];
}),
);
}
// Log for debugging
logger.log(
"[DeepLinkError] Original Path:",
originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
// Computed properties for error information
get errorCode(): string {
return (this.route.query.errorCode as string) || "UNKNOWN_ERROR";
}
return path;
});
get errorMessage(): string {
return (
(this.route.query.errorMessage as string) ||
"The deep link you followed is invalid or not supported."
);
}
// Navigation methods
const goHome = () => router.replace({ name: "home" });
const reportIssue = () => {
// Open a support form or email
window.open(
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
encodeURIComponent(
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
),
);
};
get originalPath(): string {
return this.route.query.originalPath as string;
}
// Log the error for analytics
onMounted(() => {
logConsoleAndDb(
`[DeepLinkError] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}, query: ${JSON.stringify(route.query)}`,
true,
);
});
get validRoutes() {
return VALID_DEEP_LINK_ROUTES;
}
// Format the path and include any parameters
get formattedPath(): string {
if (!this.originalPath) return "";
const path = this.originalPath.replace(/^\/+/, "");
// Log for debugging
logger.log(
"[DeepLinkError] Original Path:",
this.originalPath,
"Route Params:",
this.route.params,
"Route Query:",
this.route.query,
);
return path;
}
// Navigation methods
goHome(): void {
this.router.replace({ name: "home" });
}
reportIssue(): void {
// Open a support form or email
window.open(
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
encodeURIComponent(
`I encountered an error with a deep link: timesafari://${this.originalPath}\nError: ${this.errorMessage}`,
),
);
}
// Lifecycle hook
mounted(): void {
// Log the error for analytics
this.$logAndConsole(
`[DeepLinkError] Error page displayed for path: ${this.originalPath}, code: ${this.errorCode}, params: ${JSON.stringify(this.route.params)}, query: ${JSON.stringify(this.route.query)}`,
true,
);
}
}
</script>
<style scoped>

View File

@@ -42,14 +42,19 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router, RouteLocationNormalized } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "../constants/app";
import {
logConsoleAndDb,
} from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { APP_SERVER } from "../constants/app";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import { errorStringForLog } from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers } from "@/utils/notify";
import {
NOTIFY_INVITE_MISSING,
NOTIFY_INVITE_PROCESSING_ERROR,
NOTIFY_INVITE_TRUNCATED_DATA,
INVITE_TIMEOUT_STANDARD,
INVITE_TIMEOUT_LONG,
} from "@/constants/notifications";
/**
* Invite One Accept View Component
@@ -78,10 +83,11 @@ import { generateSaveAndActivateIdentity } from "../libs/util";
*/
@Component({
components: { QuickNav },
mixins: [PlatformServiceMixin],
})
export default class InviteOneAcceptView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Notification helpers */
notify!: ReturnType<typeof createNotifyHelpers>;
/** Router instance for navigation */
$router!: Router;
/** Route instance for current route */
@@ -113,7 +119,7 @@ export default class InviteOneAcceptView extends Vue {
this.checkingInvite = true;
// Load or generate identity
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
@@ -122,7 +128,10 @@ export default class InviteOneAcceptView extends Vue {
}
// Extract JWT from route path
const jwt = (this.$route.params.jwt as string) || this.$route.query.jwt as string || "";
const jwt =
(this.$route.params.jwt as string) ||
(this.$route.query.jwt as string) ||
"";
await this.processInvite(jwt, false);
this.checkingInvite = false;
@@ -224,15 +233,7 @@ export default class InviteOneAcceptView extends Vue {
*/
private handleMissingJwt(notify: boolean) {
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
this.notify.error(NOTIFY_INVITE_MISSING.message, INVITE_TIMEOUT_LONG);
}
}
@@ -243,17 +244,12 @@ export default class InviteOneAcceptView extends Vue {
*/
private handleError(error: unknown, notify: boolean) {
const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$logAndConsole(fullError, true);
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
this.notify.error(
NOTIFY_INVITE_PROCESSING_ERROR.message,
INVITE_TIMEOUT_STANDARD,
);
}
}
@@ -277,14 +273,9 @@ export default class InviteOneAcceptView extends Vue {
jwtInput.endsWith("invite-one-accept") ||
jwtInput.endsWith("invite-one-accept/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the invite data; it's missing some at the end. Try another way to get the full data.",
},
5000,
this.notify.error(
NOTIFY_INVITE_TRUNCATED_DATA.message,
INVITE_TIMEOUT_LONG,
);
}
}

View File

@@ -82,11 +82,11 @@ export default class ShareMyContactInfoView extends Vue {
// @ts-ignore
window.__SHARE_CONTACT_DEBUG__ = { settings, activeDid };
// eslint-disable-next-line no-console
console.log('[ShareMyContactInfoView] mounted', { settings, activeDid });
console.log("[ShareMyContactInfoView] mounted", { settings, activeDid });
if (!activeDid) {
// eslint-disable-next-line no-console
console.log('[ShareMyContactInfoView] No activeDid, redirecting to root');
this.$router.push({ name: 'home' });
console.log("[ShareMyContactInfoView] No activeDid, redirecting to root");
this.$router.push({ name: "home" });
}
}
@@ -134,10 +134,7 @@ export default class ShareMyContactInfoView extends Vue {
/**
* Generate the contact message URL for sharing
*/
private async generateContactMessage(
settings: Settings,
account: Account,
) {
private async generateContactMessage(settings: Settings, account: Account) {
const givenName = settings.firstName || "";
const isRegistered = !!settings.isRegistered;
const profileImageUrl = settings.profileImageUrl || "";