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. 1038
      package-lock.json
  7. 13
      package.json
  8. 76
      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. 187
      src/views/HomeView.vue
  17. 111
      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

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">
<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>

2
android/build.gradle

@ -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

1038
package-lock.json

File diff suppressed because it is too large

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"
},

76
src/components/EntityIcon.vue

@ -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,31 +32,45 @@ 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 {
const identifier = this.contact?.did || this.entityId;
if (!identifier) {
const baseUrl = import.meta.env.VITE_BASE_URL || '/';
return `<img src="${baseUrl}assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "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) || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
return svgString;
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 || '/';
return `<img src="${baseUrl}assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "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 || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
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);

3
src/interfaces/common.ts

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

2
src/libs/endorserServer.ts

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

31
src/services/PlatformServiceFactory.ts

@ -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();
}
}

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

@ -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

@ -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">
<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>

96
src/views/ClaimReportCertificateView.vue

@ -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>

187
src/views/HomeView.vue

@ -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.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
this.checkRegistrationStatus().catch(err => {
logger.error("Error checking registration status:", err);
});
}
// 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) {
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
// Start feed update in background
this.updateAllFeed().catch(err => {
logger.error("Error updating feed:", err);
});
// 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) {
await this.updateAllFeed();
// 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) {
const processedRecord = await this.processRecord(record);
if (processedRecord) {
this.feedData.push(processedRecord);
}
// 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;
this.selectedImage = imageUrl;
this.isImageViewerOpen = true;
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,
);
}
}
/**

111
vite.config.web.mts

@ -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(), '');
// Load base configuration for web platform
const baseConfig = await createBuildConfig('web');
// Load application-specific configuration
const appConfig = await loadAppConfig();
// 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'
};
// Merge configurations with web-specific settings
return mergeConfig(baseConfig, {
// Define platform-specific environment variables
define: {
'import.meta.env.VITE_PLATFORM': JSON.stringify('web'),
},
// Build output configuration
build: {
// Output directory for web builds
outDir: 'dist/web',
// Rollup-specific options
rollupOptions: {
output: {
// Create separate vendor chunk for Vue-related dependencies
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
}
}
export default defineConfig({
...baseConfig,
plugins: [vue()],
optimizeDeps: {
...baseConfig.optimizeDeps,
include: [...(baseConfig.optimizeDeps?.include || []), 'qrcode.vue'],
exclude: Object.keys(nodeBuiltins),
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
resolve: {
...baseConfig.resolve,
alias: {
...baseConfig.resolve?.alias,
...nodeBuiltins
}
},
build: {
...baseConfig.build,
commonjsOptions: {
...baseConfig.build?.commonjsOptions,
include: [/node_modules/],
exclude: [/src\/services\/platforms\/electron/],
transformMixedEsModules: true
},
// 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
}
})
]
});
rollupOptions: {
...baseConfig.build?.rollupOptions,
external: Object.keys(nodeBuiltins),
output: {
...baseConfig.build?.rollupOptions?.output,
globals: nodeBuiltins
}
}
}
});

Loading…
Cancel
Save