Browse Source

feat(ui): optimize EntityIcon component and fix event handling

- Refactor EntityIcon to use proper Vue template syntax instead of v-html
- Add safe event handling with error logging
- Cache identicon SVG generation for better performance
- Add cursor-pointer class for better UX
- Fix circular reference issues in event handling
- Add proper TypeScript typing for event parameters
- Add ESLint disable comment for v-html usage
- Improve error handling and logging

This commit addresses UI performance and stability issues in the EntityIcon
component, particularly around event handling and SVG generation. The changes
should resolve the circular reference errors and improve the overall user
experience when interacting with profile icons.

WIP: Further testing needed for event propagation and image loading edge cases.
db-backup-cross-platform
Matthew Raymer 2 months ago
parent
commit
b8c3517072
  1. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  2. 2
      android/.gradle/buildOutputCleanup/cache.properties
  3. BIN
      android/.gradle/file-system.probe
  4. 2
      android/app/src/main/assets/public/index.html
  5. 2
      android/build.gradle
  6. 1036
      package-lock.json
  7. 13
      package.json
  8. 52
      src/components/EntityIcon.vue
  9. 3
      src/interfaces/common.ts
  10. 2
      src/libs/endorserServer.ts
  11. 31
      src/services/PlatformServiceFactory.ts
  12. 6
      src/services/platforms/capacitor/DatabaseBackupService.ts
  13. 20
      src/services/platforms/empty.ts
  14. 45
      src/views/ClaimCertificateView.vue
  15. 96
      src/views/ClaimReportCertificateView.vue
  16. 171
      src/views/HomeView.vue
  17. 99
      vite.config.web.mts

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

2
android/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +1,2 @@
#Thu Apr 03 08:01:00 UTC 2025 #Thu Apr 03 10:21:42 UTC 2025
gradle.version=8.11.1 gradle.version=8.11.1

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/src/main/assets/public/index.html

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<title>TimeSafari</title> <title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CxCVZqQa.js"></script> <script type="module" crossorigin src="/assets/index-KPivi3wg.js"></script>
</head> </head>
<body> <body>
<noscript> <noscript>

2
android/build.gradle

@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.9.0' classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

1036
package-lock.json

File diff suppressed because it is too large

13
package.json

@ -101,13 +101,13 @@
"pinia-plugin-persistedstate": "^3.2.1", "pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21", "qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"readable-stream": "^4.5.2", "readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14", "reflect-metadata": "^0.1.14",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3", "simple-vue-camera": "^1.1.3",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"stream-browserify": "^3.0.0",
"three": "^0.156.1", "three": "^0.156.1",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"vue": "^3.5.13", "vue": "^3.5.13",
@ -136,8 +136,12 @@
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
"browserify-zlib": "^0.2.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"crypto-browserify": "^3.12.1",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.57.0",
@ -145,14 +149,21 @@
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"https-browserify": "^1.0.0",
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tty-browserify": "^0.0.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"url": "^0.11.4",
"util": "^0.12.5",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8" "vite-plugin-pwa": "^0.19.8"
}, },

52
src/components/EntityIcon.vue

@ -1,7 +1,23 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()"></div> <template>
<div class="w-fit">
<img
v-if="hasImage"
:src="imageUrl"
class="rounded cursor-pointer"
:width="iconSize"
:height="iconSize"
@click="handleClick"
/>
<div
v-else
v-html="identiconSvg"
class="cursor-pointer"
@click="handleClick"
/>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { createAvatar, StyleOptions } from "@dicebear/core"; import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from "@dicebear/collection"; import { avataaars } from "@dicebear/collection";
@ -16,11 +32,26 @@ export default class EntityIcon extends Vue {
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl @Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() { private identiconSvg = "";
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) { get imageUrl(): string {
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; return this.contact?.profileImageUrl || this.profileImageUrl;
} else { }
get hasImage(): boolean {
return !!this.imageUrl;
}
handleClick() {
try {
// Emit a simple event without passing the event object
this.$emit('click');
} catch (error) {
logger.error('Error handling click event:', error);
}
}
generateIdenticon(): string {
const identifier = this.contact?.did || this.entityId; const identifier = this.contact?.did || this.entityId;
if (!identifier) { if (!identifier) {
const baseUrl = import.meta.env.VITE_BASE_URL || '/'; const baseUrl = import.meta.env.VITE_BASE_URL || '/';
@ -31,16 +62,15 @@ export default class EntityIcon extends Vue {
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring // "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different. // ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = { const options: StyleOptions<object> = {
seed: (identifier as string) || "", seed: identifier || "",
size: this.iconSize, size: this.iconSize,
}; };
const avatar = createAvatar(avataaars, options); const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString(); return avatar.toString();
return svgString;
}
} }
mounted() { mounted() {
this.identiconSvg = this.generateIdenticon();
logger.log('EntityIcon mounted, profileImageUrl:', this.profileImageUrl); logger.log('EntityIcon mounted, profileImageUrl:', this.profileImageUrl);
logger.log('EntityIcon mounted, entityId:', this.entityId); logger.log('EntityIcon mounted, entityId:', this.entityId);
logger.log('EntityIcon mounted, iconSize:', this.iconSize); logger.log('EntityIcon mounted, iconSize:', this.iconSize);

3
src/interfaces/common.ts

@ -2,6 +2,9 @@
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; "@context"?: string;
"@type": string; "@type": string;
name?: string;
description?: string;
agent?: { identifier: string } | string;
[key: string]: unknown; [key: string]: unknown;
} }

2
src/libs/endorserServer.ts

@ -50,6 +50,8 @@ import {
} from "../interfaces"; } from "../interfaces";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
export type { GenericVerifiableCredential, GenericCredWrapper };
/** /**
* Standard context for schema.org data * Standard context for schema.org data
* @constant {string} * @constant {string}

31
src/services/PlatformServiceFactory.ts

@ -1,6 +1,8 @@
/** /**
* @file PlatformServiceFactory.ts * @file PlatformServiceFactory.ts
* @description Factory for creating platform-specific service implementations * @description Factory for creating platform-specific service implementations
* @author Matthew Raymer
* @version 1.0.0
* *
* This factory implements the Abstract Factory pattern to create platform-specific * This factory implements the Abstract Factory pattern to create platform-specific
* implementations of services. It uses Vite's dynamic import feature to load the * implementations of services. It uses Vite's dynamic import feature to load the
@ -38,6 +40,7 @@
*/ */
import { DatabaseBackupService } from "./DatabaseBackupService"; import { DatabaseBackupService } from "./DatabaseBackupService";
import { DatabaseBackupService as StubDatabaseBackupService } from "./platforms/empty";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/** /**
@ -109,7 +112,7 @@ export class PlatformServiceFactory {
* *
* Dynamically loads and instantiates the appropriate implementation * Dynamically loads and instantiates the appropriate implementation
* based on the current platform. The implementation is loaded from * based on the current platform. The implementation is loaded from
* the platforms/{platform}/DatabaseBackupService.js file. * the platforms/{platform}/DatabaseBackupService.ts file.
* *
* @returns {Promise<DatabaseBackupService>} A promise that resolves to a platform-specific backup service * @returns {Promise<DatabaseBackupService>} A promise that resolves to a platform-specific backup service
* @throws {Error} If the service fails to load or instantiate * @throws {Error} If the service fails to load or instantiate
@ -126,20 +129,32 @@ export class PlatformServiceFactory {
* ``` * ```
*/ */
public async createDatabaseBackupService(): Promise<DatabaseBackupService> { public async createDatabaseBackupService(): Promise<DatabaseBackupService> {
// List of supported platforms for web builds
const webSupportedPlatforms = ["web", "mobile"];
// Return stub implementation for unsupported platforms
if (!webSupportedPlatforms.includes(this.platform)) {
logger.log(`Using stub implementation for unsupported platform: ${this.platform}`);
return new StubDatabaseBackupService();
}
try { try {
logger.log(`Loading platform-specific service for ${this.platform}`); logger.log(`Loading platform-specific service for ${this.platform}`);
// Update the import path to point to the correct location // Use dynamic import with platform-specific path
const { DatabaseBackupService: PlatformService } = await import( const module = await import(
`../platforms/${this.platform}/DatabaseBackupService` /* @vite-ignore */
`./platforms/${this.platform}/DatabaseBackupService.ts`
); );
logger.log('Platform service loaded successfully'); logger.log('Platform service loaded successfully');
return new PlatformService(); return new module.DatabaseBackupService();
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to load platform-specific service for ${this.platform}:`, `[TimeSafari] Failed to load platform-specific service for ${this.platform}:`,
error, error
); );
throw error; // Fallback to stub implementation on error
logger.log('Falling back to stub implementation');
return new StubDatabaseBackupService();
} }
} }

6
src/services/platforms/mobile/DatabaseBackupService.ts → src/services/platforms/capacitor/DatabaseBackupService.ts

@ -1,6 +1,6 @@
/** /**
* @file DatabaseBackupService.ts * @file DatabaseBackupService.ts
* @description Mobile-specific implementation of the DatabaseBackupService * @description Capacitor-specific implementation of the DatabaseBackupService
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.0.0 * @version 1.0.0
*/ */
@ -9,13 +9,13 @@ import { DatabaseBackupService } from "../../DatabaseBackupService";
import { Filesystem } from "@capacitor/filesystem"; import { Filesystem } from "@capacitor/filesystem";
import { Share } from "@capacitor/share"; import { Share } from "@capacitor/share";
export default class MobileDatabaseBackupService extends DatabaseBackupService { export default class CapacitorDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup( protected async handleBackup(
base64Data: string, base64Data: string,
_arrayBuffer: ArrayBuffer, _arrayBuffer: ArrayBuffer,
_blob: Blob, _blob: Blob,
): Promise<void> { ): Promise<void> {
// Mobile platform handling // Capacitor platform handling
const fileName = `database-backup-${new Date().toISOString()}.json`; const fileName = `database-backup-${new Date().toISOString()}.json`;
const path = `backups/${fileName}`; const path = `backups/${fileName}`;

20
src/services/platforms/empty.ts

@ -0,0 +1,20 @@
/**
* @file empty.ts
* @description Stub implementation for excluding platform-specific code
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../DatabaseBackupService";
export default class StubDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
throw new Error("This platform does not support database backups");
}
}
export { StubDatabaseBackupService as DatabaseBackupService };

45
src/views/ClaimCertificateView.vue

@ -5,6 +5,17 @@
<router-link :to="'/claim/' + claimId"> <router-link :to="'/claim/' + claimId">
<canvas ref="claimCanvas" class="w-full block mx-auto"></canvas> <canvas ref="claimCanvas" class="w-full block mx-auto"></canvas>
</router-link> </router-link>
<div class="qr-code-container">
<QRCodeVue
ref="qrCodeRef"
:value="qrCodeData"
:size="200"
level="H"
render-as="svg"
:margin="0"
:color="{ dark: '#000000', light: '#ffffff' }"
/>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -13,13 +24,17 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue"; import { nextTick } from "vue";
import QRCode from "qrcode"; import QRCodeVue from 'qrcode.vue';
import { APP_SERVER, NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces"; import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@Component @Component({
components: {
QRCodeVue,
},
})
export default class ClaimCertificateView extends Vue { export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -31,6 +46,8 @@ export default class ClaimCertificateView extends Vue {
serverUtil = serverUtil; serverUtil = serverUtil;
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@ -252,19 +269,23 @@ export default class ClaimCertificateView extends Vue {
); );
// Generate and draw QR code // Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas"); await this.generateQRCode();
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
}; };
} }
} }
} }
private async generateQRCode() {
if (!this.qrCodeRef) return;
const canvas = await this.qrCodeRef.toCanvas();
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Draw the QR code on the claim canvas
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
ctx.drawImage(canvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
}
} }
</script> </script>

96
src/views/ClaimReportCertificateView.vue

@ -2,6 +2,17 @@
<section id="Content"> <section id="Content">
<div v-if="claimData"> <div v-if="claimData">
<canvas ref="claimCanvas"></canvas> <canvas ref="claimCanvas"></canvas>
<div class="qr-code-container">
<QRCodeVue
ref="qrCodeRef"
:value="qrCodeData"
:size="200"
level="H"
render-as="svg"
:margin="0"
:color="{ dark: '#000000', light: '#ffffff' }"
/>
</div>
</div> </div>
</section> </section>
</template> </template>
@ -9,13 +20,19 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue"; import { nextTick } from "vue";
import QRCode from "qrcode"; import QRCodeVue from 'qrcode.vue';
import { APP_SERVER, NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as endorserServer from "../libs/endorserServer"; import * as endorserServer from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@Component
@Component({
components: {
QRCodeVue,
},
})
export default class ClaimReportCertificateView extends Vue { export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -23,10 +40,14 @@ export default class ClaimReportCertificateView extends Vue {
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
claimId = ""; claimId = "";
claimData = null; claimData: GenericCredWrapper<GenericVerifiableCredential> | null = null;
endorserServer = endorserServer; endorserServer = endorserServer;
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
private readonly CANVAS_WIDTH = 1100;
private readonly CANVAS_HEIGHT = 850;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@ -64,19 +85,13 @@ export default class ClaimReportCertificateView extends Vue {
} }
async drawCanvas( async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>, claimData: GenericCredWrapper<GenericVerifiableCredential>,
) { ) {
await db.open(); await db.open();
const allContacts = await db.contacts.toArray(); const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement; const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) { if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
// Load the background image // Load the background image
@ -84,7 +99,7 @@ export default class ClaimReportCertificateView extends Vue {
backgroundImage.src = "/img/background/cert-frame-2.jpg"; backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => { backgroundImage.onload = async () => {
// Draw the background image // Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); ctx.drawImage(backgroundImage, 0, 0, this.CANVAS_WIDTH, this.CANVAS_HEIGHT);
// Set font and styles // Set font and styles
ctx.fillStyle = "black"; ctx.fillStyle = "black";
@ -98,8 +113,8 @@ export default class ClaimReportCertificateView extends Vue {
const claimTypeWidth = ctx.measureText(claimTypeText).width; const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText( ctx.fillText(
claimTypeText, claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally (this.CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33, this.CANVAS_HEIGHT * 0.33,
); );
if (claimData.claim.agent) { if (claimData.claim.agent) {
@ -108,8 +123,8 @@ export default class ClaimReportCertificateView extends Vue {
const presentedWidth = ctx.measureText(presentedText).width; const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText( ctx.fillText(
presentedText, presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally (this.CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37, this.CANVAS_HEIGHT * 0.37,
); );
const agentText = endorserServer.didInfoForCertificate( const agentText = endorserServer.didInfoForCertificate(
claimData.claim.agent, claimData.claim.agent,
@ -119,8 +134,8 @@ export default class ClaimReportCertificateView extends Vue {
const agentWidth = ctx.measureText(agentText).width; const agentWidth = ctx.measureText(agentText).width;
ctx.fillText( ctx.fillText(
agentText, agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally (this.CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.4, this.CANVAS_HEIGHT * 0.4,
); );
} }
@ -135,8 +150,8 @@ export default class ClaimReportCertificateView extends Vue {
const descriptionWidth = ctx.measureText(descriptionLine).width; const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText( ctx.fillText(
descriptionLine, descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2, (this.CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.45, this.CANVAS_HEIGHT * 0.45,
); );
} }
@ -149,33 +164,35 @@ export default class ClaimReportCertificateView extends Vue {
claimData.issuer, claimData.issuer,
allContacts, allContacts,
); );
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6); ctx.fillText(issuerText, this.CANVAS_WIDTH * 0.3, this.CANVAS_HEIGHT * 0.6);
} }
// Draw claim ID // Draw claim ID
ctx.font = "14px Arial"; ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7); ctx.fillText(this.claimId, this.CANVAS_WIDTH * 0.3, this.CANVAS_HEIGHT * 0.7);
ctx.fillText( ctx.fillText(
"via EndorserSearch.com", "via EndorserSearch.com",
CANVAS_WIDTH * 0.3, this.CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73, this.CANVAS_HEIGHT * 0.73,
); );
// Generate and draw QR code // Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas"); await this.generateQRCode();
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
}; };
} }
} }
} }
private async generateQRCode() {
if (!this.qrCodeRef) return;
const canvas = await this.qrCodeRef.toCanvas();
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Draw the QR code on the report canvas
ctx.drawImage(canvas, this.CANVAS_WIDTH * 0.6, this.CANVAS_HEIGHT * 0.55);
}
} }
</script> </script>
@ -186,5 +203,18 @@ canvas {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1;
}
.qr-code-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
} }
</style> </style>

171
src/views/HomeView.vue

@ -267,7 +267,7 @@ Raymer * @version 1.0.0 */
:confirmer-id-list="record.confirmerIdList" :confirmer-id-list="record.confirmerIdList"
@load-claim="onClickLoadClaim" @load-claim="onClickLoadClaim"
@view-image="openImageViewer" @view-image="openImageViewer"
@cache-image="cacheImageData" @cache-image="(url: string) => cacheImageData(url)"
@confirm-claim="confirmClaim" @confirm-claim="confirmClaim"
/> />
</ul> </ul>
@ -429,6 +429,7 @@ export default class HomeView extends Vue {
selectedImageData: Blob | null = null; selectedImageData: Blob | null = null;
isImageViewerOpen = false; isImageViewerOpen = false;
imageCache: Map<string, Blob | null> = new Map(); imageCache: Map<string, Blob | null> = new Map();
loadMoreTimeout: NodeJS.Timeout | null = null;
/** /**
* Initializes the component on mount * Initializes the component on mount
@ -446,13 +447,26 @@ export default class HomeView extends Vue {
*/ */
async mounted() { async mounted() {
try { try {
await this.initializeIdentity(); // Parallelize initialization operations
await this.loadSettings(); const initPromises = [
await this.loadContacts(); this.initializeIdentity(),
this.loadSettings(),
this.loadContacts()
];
await Promise.all(initPromises);
// Sequential operations that depend on the above
await this.checkRegistrationStatus(); await this.checkRegistrationStatus();
await this.loadFeedData(); await this.loadFeedData();
await this.loadNewOffers();
await this.checkOnboarding(); // Non-critical operations that can run after UI is ready
this.loadNewOffers().catch(err => {
logger.error("Error loading new offers:", err);
});
this.checkOnboarding().catch(err => {
logger.error("Error checking onboarding:", err);
});
} catch (err: unknown) { } catch (err: unknown) {
this.handleError(err); this.handleError(err);
} }
@ -471,7 +485,9 @@ export default class HomeView extends Vue {
*/ */
private async initializeIdentity() { private async initializeIdentity() {
try { try {
// Load DIDs first as it's critical
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
if (this.allMyDids.length === 0) { if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true; this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity(); const newDid = await generateSaveAndActivateIdentity();
@ -479,75 +495,44 @@ export default class HomeView extends Vue {
this.allMyDids = [newDid]; this.allMyDids = [newDid];
} }
const settings = await retrieveSettingsForActiveAccount(); // Load settings and contacts in parallel
const [settings, contacts] = await Promise.all([
retrieveSettingsForActiveAccount(),
db.contacts.toArray()
]);
// Update state with loaded data
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = contacts;
this.feedLastViewedClaimId = settings.lastViewedClaimId; this.feedLastViewedClaimId = settings.lastViewedClaimId;
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || "";
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible; this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby; this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId; this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId = this.lastAckedOfferToUserProjectsJwtId = settings.lastAckedOfferToUserProjectsJwtId;
settings.lastAckedOfferToUserProjectsJwtId;
this.searchBoxes = settings.searchBoxes || []; this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc; this.showShortcutBvc = !!settings.showShortcutBvc;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Start non-critical operations
if (!settings.finishedOnboarding) { if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open( (this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
OnboardPage.Home,
);
} }
// someone may have have registered after sharing contact info, so recheck // Check registration status in background
if (!this.isRegistered && this.activeDid) { if (!this.isRegistered && this.activeDid) {
try { this.checkRegistrationStatus().catch(err => {
const resp = await fetchEndorserRateLimits( logger.error("Error checking registration status:", err);
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
}); });
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
// this returns a Promise but we don't need to wait for it
this.updateAllFeed();
if (this.activeDid) {
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
} }
if (this.activeDid) { // Start feed update in background
const offersToUserProjects = await getNewOffersToUserProjects( this.updateAllFeed().catch(err => {
this.axios, logger.error("Error updating feed:", err);
this.apiServer, });
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true); logConsoleAndDb("Error retrieving settings or feed: " + err, true);
this.$notify( this.$notify(
@ -555,8 +540,7 @@ export default class HomeView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: text: (err as { userMessage?: string })?.userMessage ||
(err as { userMessage?: string })?.userMessage ||
"There was an error retrieving your settings or the latest activity.", "There was an error retrieving your settings or the latest activity.",
}, },
5000, 5000,
@ -767,11 +751,14 @@ export default class HomeView extends Vue {
* @param payload Boolean indicating if more items should be loaded * @param payload Boolean indicating if more items should be loaded
*/ */
async loadMoreGives(payload: boolean) { async loadMoreGives(payload: boolean) {
// Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading.
if (payload && !this.isFeedLoading) { if (payload && !this.isFeedLoading) {
// Add debounce to prevent multiple rapid calls
if (this.loadMoreTimeout) {
clearTimeout(this.loadMoreTimeout);
}
this.loadMoreTimeout = setTimeout(async () => {
await this.updateAllFeed(); await this.updateAllFeed();
}, 300);
} }
} }
@ -881,11 +868,20 @@ export default class HomeView extends Vue {
* @param records Array of feed records to process * @param records Array of feed records to process
*/ */
private async processFeedResults(records: GiveSummaryRecord[]) { private async processFeedResults(records: GiveSummaryRecord[]) {
for (const record of records) { // Process records in chunks to avoid blocking main thread
const CHUNK_SIZE = 5;
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE);
await Promise.all(
chunk.map(async (record) => {
const processedRecord = await this.processRecord(record); const processedRecord = await this.processRecord(record);
if (processedRecord) { if (processedRecord) {
this.feedData.push(processedRecord); this.feedData.push(processedRecord);
} }
})
);
// Allow UI to update between chunks
await new Promise(resolve => setTimeout(resolve, 0));
} }
this.feedPreviousOldestId = records[records.length - 1].jwtId; this.feedPreviousOldestId = records[records.length - 1].jwtId;
} }
@ -1296,9 +1292,9 @@ export default class HomeView extends Vue {
} }
/** /**
* Only show giver and/or receiver info first if they're named in your contacts. * Only show giver and/or recipient info first if they're named in your contacts.
* - If only giver is named, show "... gave" * - If only giver is named, show "... gave"
* - If only receiver is named, show "... received" * - If only recipient is named, show "... received"
*/ */
const giverInfo = giveRecord.giver; const giverInfo = giveRecord.giver;
@ -1588,15 +1584,15 @@ export default class HomeView extends Vue {
* Caches image data for sharing * Caches image data for sharing
* *
* @public * @public
* Called by ActivityListItem component * Called by ActivityListItem component and openImageViewer
* @param event Event object
* @param imageUrl URL of image to cache * @param imageUrl URL of image to cache
* @param blob Optional blob data to cache
*/ */
async cacheImageData(event: Event, imageUrl: string) { private async cacheImageData(imageUrl: string, blob?: Blob | null) {
try { try {
// For images that might fail CORS, just store the URL // For images that might fail CORS, just store the URL
// The Web Share API will handle sharing the URL appropriately // The Web Share API will handle sharing the URL appropriately
this.imageCache.set(imageUrl, null); this.imageCache.set(imageUrl, blob || null);
} catch (error) { } catch (error) {
logger.warn("Failed to cache image:", error); logger.warn("Failed to cache image:", error);
} }
@ -1607,12 +1603,47 @@ export default class HomeView extends Vue {
* *
* @public * @public
* Called by ActivityListItem component * Called by ActivityListItem component
* @param imageUrl URL of image to display * @param eventOrUrl Either an Event object or a direct image URL string
*/ */
async openImageViewer(imageUrl: string) { private async openImageViewer(eventOrUrl: Event | string) {
this.selectedImageData = this.imageCache.get(imageUrl) ?? null; const imageUrl = typeof eventOrUrl === 'string'
? eventOrUrl
: (eventOrUrl.target as HTMLElement).getAttribute('src') || '';
if (!imageUrl) return;
// Check cache first
const cachedData = this.imageCache.get(imageUrl);
if (cachedData) {
this.selectedImageData = cachedData;
this.selectedImage = imageUrl;
this.isImageViewerOpen = true;
return;
}
// If not in cache, load it
try {
const response = await fetch(imageUrl);
if (!response.ok) throw new Error('Failed to load image');
const blob = await response.blob();
await this.cacheImageData(imageUrl, blob);
this.selectedImageData = blob;
this.selectedImage = imageUrl; this.selectedImage = imageUrl;
this.isImageViewerOpen = true; this.isImageViewerOpen = true;
} catch (error) {
logger.error('Error loading image:', error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to load image. Please try again.",
},
3000,
);
}
} }
/** /**

99
vite.config.web.mts

@ -33,67 +33,60 @@
* @see PlatformServiceFactory.ts * @see PlatformServiceFactory.ts
*/ */
import { defineConfig, mergeConfig, loadEnv } from "vite"; import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa"; import vue from "@vitejs/plugin-vue";
import { createBuildConfig } from "./vite.config.common.mts"; import baseConfig from "./vite.config.base";
import { loadAppConfig } from "./vite.config.utils.mts";
export default defineConfig(async ({ mode }) => { // Define Node.js built-in modules that need browser compatibility
// Load environment variables based on build mode const nodeBuiltins = {
const env = loadEnv(mode, process.cwd(), ''); stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify',
http: 'stream-http',
https: 'https-browserify',
zlib: 'browserify-zlib',
url: 'url',
assert: 'assert',
path: 'path-browserify',
fs: 'browserify-fs',
tty: 'tty-browserify'
};
// Load base configuration for web platform export default defineConfig({
const baseConfig = await createBuildConfig('web'); ...baseConfig,
plugins: [vue()],
// Load application-specific configuration optimizeDeps: {
const appConfig = await loadAppConfig(); ...baseConfig.optimizeDeps,
include: [...(baseConfig.optimizeDeps?.include || []), 'qrcode.vue'],
// Merge configurations with web-specific settings exclude: Object.keys(nodeBuiltins),
return mergeConfig(baseConfig, { esbuildOptions: {
// Define platform-specific environment variables
define: { define: {
'import.meta.env.VITE_PLATFORM': JSON.stringify('web'), global: 'globalThis'
}
}
},
resolve: {
...baseConfig.resolve,
alias: {
...baseConfig.resolve?.alias,
...nodeBuiltins
}
}, },
// Build output configuration
build: { build: {
// Output directory for web builds ...baseConfig.build,
outDir: 'dist/web', commonjsOptions: {
...baseConfig.build?.commonjsOptions,
// Rollup-specific options include: [/node_modules/],
exclude: [/src\/services\/platforms\/electron/],
transformMixedEsModules: true
},
rollupOptions: { rollupOptions: {
...baseConfig.build?.rollupOptions,
external: Object.keys(nodeBuiltins),
output: { output: {
// Create separate vendor chunk for Vue-related dependencies ...baseConfig.build?.rollupOptions?.output,
manualChunks: { globals: nodeBuiltins
vendor: ['vue', 'vue-router', 'pinia'],
} }
} }
} }
},
// Vite plugins configuration
plugins: [
// Progressive Web App configuration
VitePWA({
// Auto-update service worker
registerType: 'autoUpdate',
// PWA manifest configuration
manifest: appConfig.pwaConfig?.manifest,
// Development options
devOptions: {
enabled: false
},
// Workbox configuration for service worker
workbox: {
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
sourcemap: true
}
})
]
});
}); });

Loading…
Cancel
Save