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.
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>TimeSafari</title>
|
||||
<script type="module" crossorigin src="/assets/index-CxCVZqQa.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-KPivi3wg.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
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'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
1028
package-lock.json
generated
1028
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -101,13 +101,13 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"ramda": "^0.29.1",
|
||||
"readable-stream": "^4.5.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"simple-vue-camera": "^1.1.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.156.1",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"vue": "^3.5.13",
|
||||
@@ -136,8 +136,12 @@
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"assert": "^2.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -145,14 +149,21 @@
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"markdownlint": "^0.37.4",
|
||||
"markdownlint-cli": "^0.44.0",
|
||||
"npm-check-updates": "^17.1.13",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tty-browserify": "^0.0.1",
|
||||
"typescript": "~5.2.2",
|
||||
"url": "^0.11.4",
|
||||
"util": "^0.12.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^0.19.8"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="w-fit" v-html="generateIcon()"></div>
|
||||
<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>
|
||||
|
||||
<script lang="ts">
|
||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||
import { avataaars } from "@dicebear/collection";
|
||||
@@ -16,11 +32,26 @@ export default class EntityIcon extends Vue {
|
||||
@Prop iconSize = 0;
|
||||
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
||||
|
||||
generateIcon() {
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
if (imageUrl) {
|
||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
} else {
|
||||
private identiconSvg = "";
|
||||
|
||||
get imageUrl(): string {
|
||||
return this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!identifier) {
|
||||
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
|
||||
// ... which looks similar to '' at the dicebear site but which is different.
|
||||
const options: StyleOptions<object> = {
|
||||
seed: (identifier as string) || "",
|
||||
seed: identifier || "",
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
return svgString;
|
||||
}
|
||||
return avatar.toString();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.identiconSvg = this.generateIdenticon();
|
||||
logger.log('EntityIcon mounted, profileImageUrl:', this.profileImageUrl);
|
||||
logger.log('EntityIcon mounted, entityId:', this.entityId);
|
||||
logger.log('EntityIcon mounted, iconSize:', this.iconSize);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context"?: string;
|
||||
"@type": string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
agent?: { identifier: string } | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
} from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export type { GenericVerifiableCredential, GenericCredWrapper };
|
||||
|
||||
/**
|
||||
* Standard context for schema.org data
|
||||
* @constant {string}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @file PlatformServiceFactory.ts
|
||||
* @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
|
||||
* implementations of services. It uses Vite's dynamic import feature to load the
|
||||
@@ -38,6 +40,7 @@
|
||||
*/
|
||||
|
||||
import { DatabaseBackupService } from "./DatabaseBackupService";
|
||||
import { DatabaseBackupService as StubDatabaseBackupService } from "./platforms/empty";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
@@ -109,7 +112,7 @@ export class PlatformServiceFactory {
|
||||
*
|
||||
* Dynamically loads and instantiates the appropriate implementation
|
||||
* 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
|
||||
* @throws {Error} If the service fails to load or instantiate
|
||||
@@ -126,20 +129,32 @@ export class PlatformServiceFactory {
|
||||
* ```
|
||||
*/
|
||||
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 {
|
||||
logger.log(`Loading platform-specific service for ${this.platform}`);
|
||||
// Update the import path to point to the correct location
|
||||
const { DatabaseBackupService: PlatformService } = await import(
|
||||
`../platforms/${this.platform}/DatabaseBackupService`
|
||||
// Use dynamic import with platform-specific path
|
||||
const module = await import(
|
||||
/* @vite-ignore */
|
||||
`./platforms/${this.platform}/DatabaseBackupService.ts`
|
||||
);
|
||||
logger.log('Platform service loaded successfully');
|
||||
return new PlatformService();
|
||||
return new module.DatabaseBackupService();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to load platform-specific service for ${this.platform}:`,
|
||||
error,
|
||||
`[TimeSafari] Failed to load platform-specific service for ${this.platform}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
// Fallback to stub implementation on error
|
||||
logger.log('Falling back to stub implementation');
|
||||
return new StubDatabaseBackupService();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file DatabaseBackupService.ts
|
||||
* @description Mobile-specific implementation of the DatabaseBackupService
|
||||
* @description Capacitor-specific implementation of the DatabaseBackupService
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@@ -9,13 +9,13 @@ import { DatabaseBackupService } from "../../DatabaseBackupService";
|
||||
import { Filesystem } from "@capacitor/filesystem";
|
||||
import { Share } from "@capacitor/share";
|
||||
|
||||
export default class MobileDatabaseBackupService extends DatabaseBackupService {
|
||||
export default class CapacitorDatabaseBackupService extends DatabaseBackupService {
|
||||
protected async handleBackup(
|
||||
base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
_blob: Blob,
|
||||
): Promise<void> {
|
||||
// Mobile platform handling
|
||||
// Capacitor platform handling
|
||||
const fileName = `database-backup-${new Date().toISOString()}.json`;
|
||||
const path = `backups/${fileName}`;
|
||||
|
||||
20
src/services/platforms/empty.ts
Normal file
20
src/services/platforms/empty.ts
Normal file
@@ -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 };
|
||||
@@ -5,6 +5,17 @@
|
||||
<router-link :to="'/claim/' + claimId">
|
||||
<canvas ref="claimCanvas" class="w-full block mx-auto"></canvas>
|
||||
</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>
|
||||
</section>
|
||||
@@ -13,13 +24,17 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { nextTick } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import QRCodeVue from 'qrcode.vue';
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
@Component
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue,
|
||||
},
|
||||
})
|
||||
export default class ClaimCertificateView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@@ -31,6 +46,8 @@ export default class ClaimCertificateView extends Vue {
|
||||
|
||||
serverUtil = serverUtil;
|
||||
|
||||
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -252,19 +269,23 @@ export default class ClaimCertificateView extends Vue {
|
||||
);
|
||||
|
||||
// Generate and draw QR code
|
||||
const qrCodeCanvas = document.createElement("canvas");
|
||||
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);
|
||||
await this.generateQRCode();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
<section id="Content">
|
||||
<div v-if="claimData">
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -9,13 +20,19 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { nextTick } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import QRCodeVue from 'qrcode.vue';
|
||||
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as endorserServer from "../libs/endorserServer";
|
||||
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
@Component
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue,
|
||||
},
|
||||
})
|
||||
export default class ClaimReportCertificateView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@@ -23,10 +40,14 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
claimId = "";
|
||||
claimData = null;
|
||||
claimData: GenericCredWrapper<GenericVerifiableCredential> | null = null;
|
||||
|
||||
endorserServer = endorserServer;
|
||||
|
||||
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
|
||||
private readonly CANVAS_WIDTH = 1100;
|
||||
private readonly CANVAS_HEIGHT = 850;
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -64,19 +85,13 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
}
|
||||
|
||||
async drawCanvas(
|
||||
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
|
||||
claimData: GenericCredWrapper<GenericVerifiableCredential>,
|
||||
) {
|
||||
await db.open();
|
||||
const allContacts = await db.contacts.toArray();
|
||||
|
||||
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
|
||||
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");
|
||||
if (ctx) {
|
||||
// Load the background image
|
||||
@@ -84,7 +99,7 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
backgroundImage.src = "/img/background/cert-frame-2.jpg";
|
||||
backgroundImage.onload = async () => {
|
||||
// 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
|
||||
ctx.fillStyle = "black";
|
||||
@@ -98,8 +113,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const claimTypeWidth = ctx.measureText(claimTypeText).width;
|
||||
ctx.fillText(
|
||||
claimTypeText,
|
||||
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.33,
|
||||
(this.CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.33,
|
||||
);
|
||||
|
||||
if (claimData.claim.agent) {
|
||||
@@ -108,8 +123,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const presentedWidth = ctx.measureText(presentedText).width;
|
||||
ctx.fillText(
|
||||
presentedText,
|
||||
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.37,
|
||||
(this.CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.37,
|
||||
);
|
||||
const agentText = endorserServer.didInfoForCertificate(
|
||||
claimData.claim.agent,
|
||||
@@ -119,8 +134,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const agentWidth = ctx.measureText(agentText).width;
|
||||
ctx.fillText(
|
||||
agentText,
|
||||
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.4,
|
||||
(this.CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.4,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,8 +150,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const descriptionWidth = ctx.measureText(descriptionLine).width;
|
||||
ctx.fillText(
|
||||
descriptionLine,
|
||||
(CANVAS_WIDTH - descriptionWidth) / 2,
|
||||
CANVAS_HEIGHT * 0.45,
|
||||
(this.CANVAS_WIDTH - descriptionWidth) / 2,
|
||||
this.CANVAS_HEIGHT * 0.45,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,33 +164,35 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
claimData.issuer,
|
||||
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
|
||||
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(
|
||||
"via EndorserSearch.com",
|
||||
CANVAS_WIDTH * 0.3,
|
||||
CANVAS_HEIGHT * 0.73,
|
||||
this.CANVAS_WIDTH * 0.3,
|
||||
this.CANVAS_HEIGHT * 0.73,
|
||||
);
|
||||
|
||||
// Generate and draw QR code
|
||||
const qrCodeCanvas = document.createElement("canvas");
|
||||
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);
|
||||
await this.generateQRCode();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -186,5 +203,18 @@ canvas {
|
||||
left: 0;
|
||||
width: 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>
|
||||
|
||||
@@ -267,7 +267,7 @@ Raymer * @version 1.0.0 */
|
||||
:confirmer-id-list="record.confirmerIdList"
|
||||
@load-claim="onClickLoadClaim"
|
||||
@view-image="openImageViewer"
|
||||
@cache-image="cacheImageData"
|
||||
@cache-image="(url: string) => cacheImageData(url)"
|
||||
@confirm-claim="confirmClaim"
|
||||
/>
|
||||
</ul>
|
||||
@@ -429,6 +429,7 @@ export default class HomeView extends Vue {
|
||||
selectedImageData: Blob | null = null;
|
||||
isImageViewerOpen = false;
|
||||
imageCache: Map<string, Blob | null> = new Map();
|
||||
loadMoreTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initializes the component on mount
|
||||
@@ -446,13 +447,26 @@ export default class HomeView extends Vue {
|
||||
*/
|
||||
async mounted() {
|
||||
try {
|
||||
await this.initializeIdentity();
|
||||
await this.loadSettings();
|
||||
await this.loadContacts();
|
||||
// Parallelize initialization operations
|
||||
const initPromises = [
|
||||
this.initializeIdentity(),
|
||||
this.loadSettings(),
|
||||
this.loadContacts()
|
||||
];
|
||||
|
||||
await Promise.all(initPromises);
|
||||
|
||||
// Sequential operations that depend on the above
|
||||
await this.checkRegistrationStatus();
|
||||
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) {
|
||||
this.handleError(err);
|
||||
}
|
||||
@@ -471,7 +485,9 @@ export default class HomeView extends Vue {
|
||||
*/
|
||||
private async initializeIdentity() {
|
||||
try {
|
||||
// Load DIDs first as it's critical
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
if (this.allMyDids.length === 0) {
|
||||
this.isCreatingIdentifier = true;
|
||||
const newDid = await generateSaveAndActivateIdentity();
|
||||
@@ -479,75 +495,44 @@ export default class HomeView extends Vue {
|
||||
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.activeDid = settings.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.allContacts = contacts;
|
||||
this.feedLastViewedClaimId = settings.lastViewedClaimId;
|
||||
this.givenName = settings.firstName || "";
|
||||
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
||||
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
|
||||
this.lastAckedOfferToUserProjectsJwtId =
|
||||
settings.lastAckedOfferToUserProjectsJwtId;
|
||||
this.lastAckedOfferToUserProjectsJwtId = settings.lastAckedOfferToUserProjectsJwtId;
|
||||
this.searchBoxes = settings.searchBoxes || [];
|
||||
this.showShortcutBvc = !!settings.showShortcutBvc;
|
||||
|
||||
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
||||
|
||||
// Start non-critical operations
|
||||
if (!settings.finishedOnboarding) {
|
||||
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
||||
OnboardPage.Home,
|
||||
);
|
||||
(this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
|
||||
}
|
||||
|
||||
// someone may have have registered after sharing contact info, so recheck
|
||||
// Check registration status in background
|
||||
if (!this.isRegistered && this.activeDid) {
|
||||
try {
|
||||
const resp = await fetchEndorserRateLimits(
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
this.activeDid,
|
||||
);
|
||||
if (resp.status === 200) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
isRegistered: true,
|
||||
...(await retrieveSettingsForActiveAccount()),
|
||||
this.checkRegistrationStatus().catch(err => {
|
||||
logger.error("Error checking registration status:", err);
|
||||
});
|
||||
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();
|
||||
// Start feed update in background
|
||||
this.updateAllFeed().catch(err => {
|
||||
logger.error("Error updating feed:", err);
|
||||
});
|
||||
|
||||
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) {
|
||||
const offersToUserProjects = await getNewOffersToUserProjects(
|
||||
this.axios,
|
||||
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) {
|
||||
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
|
||||
this.$notify(
|
||||
@@ -555,8 +540,7 @@ export default class HomeView extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
(err as { userMessage?: string })?.userMessage ||
|
||||
text: (err as { userMessage?: string })?.userMessage ||
|
||||
"There was an error retrieving your settings or the latest activity.",
|
||||
},
|
||||
5000,
|
||||
@@ -767,11 +751,14 @@ export default class HomeView extends Vue {
|
||||
* @param payload Boolean indicating if more items should be loaded
|
||||
*/
|
||||
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) {
|
||||
// Add debounce to prevent multiple rapid calls
|
||||
if (this.loadMoreTimeout) {
|
||||
clearTimeout(this.loadMoreTimeout);
|
||||
}
|
||||
this.loadMoreTimeout = setTimeout(async () => {
|
||||
await this.updateAllFeed();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,11 +868,20 @@ export default class HomeView extends Vue {
|
||||
* @param records Array of feed records to process
|
||||
*/
|
||||
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);
|
||||
if (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;
|
||||
}
|
||||
@@ -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 receiver is named, show "... received"
|
||||
* - If only recipient is named, show "... received"
|
||||
*/
|
||||
|
||||
const giverInfo = giveRecord.giver;
|
||||
@@ -1588,15 +1584,15 @@ export default class HomeView extends Vue {
|
||||
* Caches image data for sharing
|
||||
*
|
||||
* @public
|
||||
* Called by ActivityListItem component
|
||||
* @param event Event object
|
||||
* Called by ActivityListItem component and openImageViewer
|
||||
* @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 {
|
||||
// For images that might fail CORS, just store the URL
|
||||
// The Web Share API will handle sharing the URL appropriately
|
||||
this.imageCache.set(imageUrl, null);
|
||||
this.imageCache.set(imageUrl, blob || null);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to cache image:", error);
|
||||
}
|
||||
@@ -1607,12 +1603,47 @@ export default class HomeView extends Vue {
|
||||
*
|
||||
* @public
|
||||
* 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) {
|
||||
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
|
||||
private async openImageViewer(eventOrUrl: Event | string) {
|
||||
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.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,67 +33,60 @@
|
||||
* @see PlatformServiceFactory.ts
|
||||
*/
|
||||
|
||||
import { defineConfig, mergeConfig, loadEnv } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { createBuildConfig } from "./vite.config.common.mts";
|
||||
import { loadAppConfig } from "./vite.config.utils.mts";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import baseConfig from "./vite.config.base";
|
||||
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
// Load environment variables based on build mode
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
// Define Node.js built-in modules that need browser compatibility
|
||||
const nodeBuiltins = {
|
||||
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
|
||||
const baseConfig = await createBuildConfig('web');
|
||||
|
||||
// Load application-specific configuration
|
||||
const appConfig = await loadAppConfig();
|
||||
|
||||
// Merge configurations with web-specific settings
|
||||
return mergeConfig(baseConfig, {
|
||||
// Define platform-specific environment variables
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
plugins: [vue()],
|
||||
optimizeDeps: {
|
||||
...baseConfig.optimizeDeps,
|
||||
include: [...(baseConfig.optimizeDeps?.include || []), 'qrcode.vue'],
|
||||
exclude: Object.keys(nodeBuiltins),
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
'import.meta.env.VITE_PLATFORM': JSON.stringify('web'),
|
||||
global: 'globalThis'
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
...baseConfig.resolve,
|
||||
alias: {
|
||||
...baseConfig.resolve?.alias,
|
||||
...nodeBuiltins
|
||||
}
|
||||
},
|
||||
|
||||
// Build output configuration
|
||||
build: {
|
||||
// Output directory for web builds
|
||||
outDir: 'dist/web',
|
||||
|
||||
// Rollup-specific options
|
||||
...baseConfig.build,
|
||||
commonjsOptions: {
|
||||
...baseConfig.build?.commonjsOptions,
|
||||
include: [/node_modules/],
|
||||
exclude: [/src\/services\/platforms\/electron/],
|
||||
transformMixedEsModules: true
|
||||
},
|
||||
rollupOptions: {
|
||||
...baseConfig.build?.rollupOptions,
|
||||
external: Object.keys(nodeBuiltins),
|
||||
output: {
|
||||
// Create separate vendor chunk for Vue-related dependencies
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
...baseConfig.build?.rollupOptions?.output,
|
||||
globals: nodeBuiltins
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user