Compare commits

...

6 Commits

Author SHA1 Message Date
Matthew Raymer 5a6e5289ff fix: update server settings test and initialization 2 months ago
Matthew Raymer 6620311b7d fix: improve feed loading and onboarding dialog handling 2 months ago
Matthew Raymer 6e2bdc69e9 refactor: improve code quality and type safety 2 months ago
Matthew Raymer b8c3517072 feat(ui): optimize EntityIcon component and fix event handling 2 months ago
Matthew Raymer a0cf9ea721 feat(backup): implement platform-specific database backup service 2 months ago
Matthew Raymer 42d706b1fb WIP: Platform-specific service architecture and configuration refactor 2 months ago
  1. 1
      .env.electron
  2. 5
      .env.mobile
  3. 1
      .env.web
  4. 6
      .eslintrc.js
  5. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  6. 4
      android/.gradle/buildOutputCleanup/cache.properties
  7. BIN
      android/.gradle/file-system.probe
  8. 2
      android/app/capacitor.build.gradle
  9. 8
      android/app/src/main/assets/capacitor.plugins.json
  10. 2
      android/app/src/main/assets/public/index.html
  11. 2
      android/build.gradle
  12. 6
      android/capacitor.settings.gradle
  13. 2
      android/gradle/wrapper/gradle-wrapper.properties
  14. 1517
      package-lock.json
  15. 20
      package.json
  16. 10
      playwright.config-local.ts
  17. 8
      playwright.config.ts
  18. 1
      src/components/ActivityListItem.vue
  19. 95
      src/components/EntityIcon.vue
  20. 257
      src/components/ProfileSection.vue
  21. 26
      src/db/index.ts
  22. 3
      src/interfaces/common.ts
  23. 34
      src/interfaces/identifier.ts
  24. 1
      src/interfaces/index.ts
  25. 288
      src/interfaces/service.ts
  26. 4
      src/libs/crypto/vc/index.ts
  27. 2
      src/libs/endorserServer.ts
  28. 63
      src/main.ts
  29. 82
      src/platforms/capacitor/DatabaseBackupService.ts
  30. 359
      src/router/index.ts
  31. 95
      src/services/DatabaseBackupService.ts
  32. 195
      src/services/PlatformServiceFactory.ts
  33. 105
      src/services/ProfileService.ts
  34. 110
      src/services/RateLimitsService.ts
  35. 35
      src/services/platforms/capacitor/DatabaseBackupService.ts
  36. 18
      src/services/platforms/electron/DatabaseBackupService.ts
  37. 20
      src/services/platforms/empty.ts
  38. 33
      src/services/platforms/web/DatabaseBackupService.ts
  39. 64
      src/types/capacitor.d.ts
  40. 7
      src/types/index.ts
  41. 430
      src/types/interfaces.ts
  42. 82
      src/utils/logger.ts
  43. 1965
      src/views/AccountViewView.vue
  44. 47
      src/views/ClaimCertificateView.vue
  45. 114
      src/views/ClaimReportCertificateView.vue
  46. 2
      src/views/ContactGiftingView.vue
  47. 4
      src/views/HelpView.vue
  48. 609
      src/views/HomeView.vue
  49. 2
      src/views/ProjectViewView.vue
  50. 6
      src/views/ProjectsView.vue
  51. 47
      test-playwright/00-noid-tests.spec.ts
  52. 3
      tsconfig.json
  53. 46
      vite.config.base.ts
  54. 12
      vite.config.common.mts
  55. 156
      vite.config.dev.mts.timestamp-1743747195916-245e61245d5ec.mjs
  56. 28
      vite.config.mobile.ts
  57. 60
      vite.config.ts
  58. 109
      vite.config.web.mts

1
.env.electron

@ -0,0 +1 @@
PLATFORM=electron

5
.env.mobile

@ -0,0 +1,5 @@
PLATFORM=capacitor
VITE_ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim
VITE_PARTNER_API_URL=https://test-api.partner.ch/api/v2
VITE_IMAGE_API_URL=https://test-api.images.ch/api/v2
VITE_PUSH_SERVER_URL=https://test-api.push.ch/api/v2

1
.env.web

@ -0,0 +1 @@
PLATFORM=web

6
.eslintrc.js

@ -26,6 +26,10 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn", "no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off" "@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}]
}, },
}; };

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

4
android/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +1,2 @@
#Fri Mar 21 07:27:50 UTC 2025 #Thu Apr 03 10:21:42 UTC 2025
gradle.version=8.2.1 gradle.version=8.11.1

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/capacitor.build.gradle

@ -10,6 +10,8 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
} }

8
android/app/src/main/assets/capacitor.plugins.json

@ -2,5 +2,13 @@
{ {
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
} }
] ]

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-CZMUlUNO.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.2.1' 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

6
android/capacitor.settings.gradle

@ -4,3 +4,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')

2
android/gradle/wrapper/gradle-wrapper.properties

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

1517
package-lock.json

File diff suppressed because it is too large

20
package.json

@ -45,10 +45,13 @@
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^6.2.0",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^6.2.1",
"@capacitor/filesystem": "^6.0.3",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
"@electron/remote": "^2.1.2",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0", "@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
@ -98,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",
@ -123,7 +126,7 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.14.11", "@types/node": "^20.17.30",
"@types/node-fetch": "^2.6.12", "@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11", "@types/ramda": "^0.29.11",
"@types/sqlite3": "^3.1.11", "@types/sqlite3": "^3.1.11",
@ -133,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",
@ -142,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"
}, },

10
playwright.config-local.ts

@ -69,11 +69,11 @@ export default defineConfig({
permissions: ["clipboard-read"], permissions: ["clipboard-read"],
}, },
}, },
{ // {
name: 'firefox', // name: 'firefox',
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/, // testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
use: { ...devices['Desktop Firefox'] }, // use: { ...devices['Desktop Firefox'] },
}, // },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
// { // {

8
playwright.config.ts

@ -40,10 +40,10 @@ export default defineConfig({
permissions: ["clipboard-read"], permissions: ["clipboard-read"],
}, },
}, },
{ // {
name: 'firefox', // name: 'firefox',
use: { ...devices['Desktop Firefox'] }, // use: { ...devices['Desktop Firefox'] },
}, // },
// { // {
// name: 'webkit', // name: 'webkit',

1
src/components/ActivityListItem.vue

@ -192,6 +192,7 @@ import ProjectIcon from "./ProjectIcon.vue";
EntityIcon, EntityIcon,
ProjectIcon, ProjectIcon,
}, },
emits: ["loadClaim", "viewImage", "cacheImage", "confirmClaim"],
}) })
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo; @Prop() record!: GiveRecordWithContactInfo;

95
src/components/EntityIcon.vue

@ -1,41 +1,100 @@
<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 class="cursor-pointer" @click="handleClick">
<img
v-if="!identifier"
:src="blankSquareUrl"
class="rounded"
:width="iconSize"
:height="iconSize"
/>
<svg
v-else
:width="iconSize"
:height="iconSize"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<g v-for="(path, index) in avatarPaths" :key="index">
<path :d="path" />
</g>
</svg>
</div>
</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";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
@Component @Component
export default class EntityIcon extends Vue { export default class EntityIcon extends Vue {
@Prop contact: Contact; @Prop({ required: false }) contact?: Contact;
@Prop entityId = ""; // overridden by contact.did or profileImageUrl @Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl @Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() { private avatarPaths: string[] = [];
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl; private blankSquareUrl =
if (imageUrl) { import.meta.env.VITE_BASE_URL + "assets/blank-square.svg";
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else { get imageUrl(): string {
const identifier = this.contact?.did || this.entityId; return this.contact?.profileImageUrl || this.profileImageUrl;
if (!identifier) { }
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} get hasImage(): boolean {
// https://api.dicebear.com/8.x/avataaars/svg?seed= return !!this.imageUrl;
// ... 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. get identifier(): string | undefined {
return this.contact?.did || this.entityId;
}
handleClick() {
try {
// Emit a simple event without passing the event object
this.$emit("click");
} catch (error) {
logger.error("Error handling click event:", error);
}
}
generateAvatarPaths(): string[] {
if (!this.identifier) return [];
const options: StyleOptions<object> = { const options: StyleOptions<object> = {
seed: (identifier as string) || "", seed: this.identifier,
size: this.iconSize, size: this.iconSize,
}; };
const avatar = createAvatar(avataaars, options); const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString(); const svgString = avatar.toString();
return svgString;
// Extract paths from SVG string
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "image/svg+xml");
const paths = Array.from(doc.querySelectorAll("path")).map(
(path) => path.getAttribute("d") || "",
);
return paths;
} }
mounted() {
this.avatarPaths = this.generateAvatarPaths();
logger.log("EntityIcon mounted, profileImageUrl:", this.profileImageUrl);
logger.log("EntityIcon mounted, entityId:", this.entityId);
logger.log("EntityIcon mounted, iconSize:", this.iconSize);
} }
} }
</script> </script>

257
src/components/ProfileSection.vue

@ -0,0 +1,257 @@
/** * @file ProfileSection.vue * @description Component for managing user
profile information * @author Matthew Raymer * @version 1.0.0 */
<template>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
<div v-if="loading" class="text-center mb-2">
<font-awesome
icon="spinner"
class="fa-spin text-slate-400"
></font-awesome>
Loading profile...
</div>
<div v-else class="flex items-center mb-2">
<span class="font-bold">Public Profile</span>
<font-awesome
icon="circle-info"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click="showProfileInfo"
/>
</div>
<textarea
v-model="profileDesc"
class="w-full h-32 p-2 border border-slate-300 rounded-md"
placeholder="Write something about yourself for the public..."
:readonly="loading || saving"
:class="{ 'bg-slate-100': loading || saving }"
></textarea>
<div class="flex items-center mb-4" @click="toggleLocation">
<input v-model="includeLocation" type="checkbox" class="mr-2" />
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your
place.
</p>
<l-map
ref="profileMap"
class="!z-40 rounded-md"
@click="handleMapClick"
@ready="onMapReady"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="latitude && longitude"
:lat-lng="[latitude, longitude]"
@click="confirmEraseLocation"
/>
</l-map>
</div>
<div v-if="!loading && !saving">
<div class="flex justify-between items-center">
<button
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loading || saving"
:class="{
'opacity-50 cursor-not-allowed': loading || saving,
}"
@click="saveProfile"
>
Save Profile
</button>
<button
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loading || saving"
:class="{
'opacity-50 cursor-not-allowed':
loading || saving || (!profileDesc && !includeLocation),
}"
@click="confirmDeleteProfile"
>
Delete Profile
</button>
</div>
</div>
<div v-else-if="loading">Loading...</div>
<div v-else>Saving...</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { ProfileService } from "../services/ProfileService";
import { logger } from "../utils/logger";
@Component({
components: {
LMap,
LMarker,
LTileLayer,
},
})
export default class ProfileSection extends Vue {
@Prop({ required: true }) activeDid!: string;
@Prop({ required: true }) partnerApiServer!: string;
@Emit("profile-updated") profileUpdated() {}
loading = true;
saving = false;
profileDesc = "";
latitude = 0;
longitude = 0;
includeLocation = false;
zoom = 2;
async mounted() {
await this.loadProfile();
}
async loadProfile() {
try {
const profile = await ProfileService.loadProfile(
this.activeDid,
this.partnerApiServer,
);
if (profile) {
this.profileDesc = profile.description || "";
this.latitude = profile.location?.lat || 0;
this.longitude = profile.location?.lng || 0;
this.includeLocation = !!(this.latitude && this.longitude);
}
} catch (error) {
logger.error("Error loading profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "Your server profile is not available.",
});
} finally {
this.loading = false;
}
}
async saveProfile() {
this.saving = true;
try {
await ProfileService.saveProfile(this.activeDid, this.partnerApiServer, {
description: this.profileDesc,
location: this.includeLocation
? {
lat: this.latitude,
lng: this.longitude,
}
: undefined,
});
this.$notify({
group: "alert",
type: "success",
title: "Profile Saved",
text: "Your profile has been updated successfully.",
});
this.profileUpdated();
} catch (error) {
logger.error("Error saving profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Saving Profile",
text: "There was an error saving your profile.",
});
} finally {
this.saving = false;
}
}
toggleLocation() {
this.includeLocation = !this.includeLocation;
if (!this.includeLocation) {
this.latitude = 0;
this.longitude = 0;
this.zoom = 2;
}
}
handleMapClick(event: { latlng: { lat: number; lng: number } }) {
this.latitude = event.latlng.lat;
this.longitude = event.latlng.lng;
}
onMapReady(map: L.Map) {
const zoom = this.latitude && this.longitude ? 12 : 2;
map.setView([this.latitude, this.longitude], zoom);
}
confirmEraseLocation() {
this.$notify({
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: () => {
this.latitude = 0;
this.longitude = 0;
this.zoom = 2;
this.includeLocation = false;
},
});
}
async confirmDeleteProfile() {
this.$notify({
group: "modal",
type: "confirm",
title: "Delete Profile",
text: "Are you sure you want to delete your public profile? This will remove your description and location from the server, and it cannot be undone.",
onYes: this.deleteProfile,
});
}
async deleteProfile() {
this.saving = true;
try {
await ProfileService.deleteProfile(this.activeDid, this.partnerApiServer);
this.profileDesc = "";
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
this.$notify({
group: "alert",
type: "success",
title: "Profile Deleted",
text: "Your profile has been deleted successfully.",
});
this.profileUpdated();
} catch (error) {
logger.error("Error deleting profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Deleting Profile",
text: "There was an error deleting your profile.",
});
} finally {
this.saving = false;
}
}
showProfileInfo() {
this.$notify({
group: "alert",
type: "info",
title: "Public Profile Information",
text: "This data will be published for all to see, so be careful what your write. Your ID will only be shared with people who you allow to see your activity.",
});
}
}
</script>

26
src/db/index.ts

@ -1,6 +1,8 @@
import BaseDexie, { Table } from "dexie"; import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import { exportDB, ExportOptions } from "dexie-export-import";
import * as R from "ramda"; import * as R from "ramda";
import Dexie from "dexie";
import { Account, AccountsSchema } from "./tables/accounts"; import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts"; import { Contact, ContactSchema } from "./tables/contacts";
@ -26,19 +28,26 @@ type NonsensitiveTables = {
}; };
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T; export type SecretDexie<T extends Record<string, Dexie.Table> = SecretTable> =
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T; BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> = export type SensitiveDexie<
BaseDexie & T; T extends Record<string, Dexie.Table> = SensitiveTables,
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
export type NonsensitiveDexie<
T extends Record<string, Dexie.Table> = NonsensitiveTables,
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
//// Initialize the DBs, starting with the sensitive ones. //// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB // Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie; export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema); secretDB.version(1).stores(SecretSchema);
secretDB.export = (options) => exportDB(secretDB, options);
// Initialize Dexie database for accounts // Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
accountsDexie.version(1).stores(AccountsSchema);
accountsDexie.export = (options) => exportDB(accountsDexie, options);
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount // Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside. // so that it's clear whether the usage needs the private key inside.
@ -54,8 +63,15 @@ export const accountsDBPromise = useSecretAndInitializeAccountsDB(
//// Now initialize the other DB. //// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data // Initialize Dexie database for non-sensitive data
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
db.version(1).stores({
contacts: ContactSchema.contacts,
logs: LogSchema.logs,
settings: SettingsSchema.settings,
temp: TempSchema.temp,
});
db.export = (options) => exportDB(db, options);
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning

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;
} }

34
src/interfaces/identifier.ts

@ -0,0 +1,34 @@
/**
* Interface for a Decentralized Identifier (DID)
* @author Matthew Raymer
*/
import { KeyMeta } from "../libs/crypto/vc";
export interface IKey {
id: string;
type: string;
controller: string;
ethereumAddress: string;
publicKeyHex: string;
privateKeyHex: string;
meta?: KeyMeta;
}
export interface IService {
id: string;
type: string;
serviceEndpoint: string;
description?: string;
metadata?: {
version?: string;
capabilities?: string[];
config?: Record<string, unknown>;
};
}
export interface IIdentifier {
did: string;
keys: IKey[];
services: IService[];
}

1
src/interfaces/index.ts

@ -5,3 +5,4 @@ export * from "./limits";
export * from "./records"; export * from "./records";
export * from "./user"; export * from "./user";
export * from "./deepLinks"; export * from "./deepLinks";
export * from "./identifier";

288
src/interfaces/service.ts

@ -0,0 +1,288 @@
/**
* @file service.ts
* @description Service interfaces for Decentralized Identifiers (DIDs)
*
* This module defines the service interfaces used in the TimeSafari application.
* Services are associated with DIDs to provide additional functionality and endpoints.
*
* Architecture:
* 1. Base IService interface defines common service properties
* 2. Specialized interfaces extend IService for specific service types
* 3. Services are stored in IIdentifier.services array
* 4. Services are loaded and managed by PlatformServiceFactory
*
* Service Types:
* - EndorserService: Handles claims and endorsements
* - PushNotificationService: Manages web push notifications
* - ProfileService: Handles user profiles and settings
* - BackupService: Manages data backup and restore
*
* @see IIdentifier
* @see PlatformServiceFactory
* @see DatabaseBackupService
*/
/**
* Base interface for all DID services
*
* This interface defines the core properties that all services must implement.
* It follows the W3C DID specification for service endpoints.
*
* @example
* const service: IService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* description: 'Endorser service for claims and endorsements',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: { apiServer: 'https://api.endorser.ch' }
* }
* };
*/
export interface IService {
/**
* Unique identifier for the service
* @example 'endorser-service'
* @example 'push-notification-service'
*/
id: string;
/**
* Type of service
* @example 'EndorserService'
* @example 'PushNotificationService'
*/
type: string;
/**
* Endpoint URL for the service
* @example 'https://api.endorser.ch'
* @example 'https://push.timesafari.app'
*/
serviceEndpoint: string;
/**
* Optional human-readable description of the service
* @example 'Service for handling claims and endorsements'
*/
description?: string;
/**
* Optional metadata for service configuration
*/
metadata?: {
/**
* Service version in semantic versioning format
* @example '1.0.0'
*/
version?: string;
/**
* Array of service capabilities
* @example ['claims', 'endorsements']
* @example ['notifications', 'alerts']
*/
capabilities?: string[];
/**
* Service-specific configuration
* @example { apiServer: 'https://api.endorser.ch' }
*/
config?: Record<string, unknown>;
};
}
/**
* Service for handling claims and endorsements
*
* This service provides endpoints for:
* - Submitting claims
* - Managing endorsements
* - Checking rate limits
*
* @example
* const endorserService: IEndorserService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: {
* apiServer: 'https://api.endorser.ch',
* rateLimits: {
* claimsPerDay: 100,
* endorsementsPerDay: 1000
* }
* }
* }
* };
*/
export interface IEndorserService extends IService {
/** @override */
type: "EndorserService";
/** @override */
metadata: {
version: string;
capabilities: ["claims", "endorsements"];
config: {
/**
* API server URL
* @example 'https://api.endorser.ch'
*/
apiServer: string;
/**
* Optional rate limits
*/
rateLimits?: {
/**
* Maximum claims per day
* @default 100
*/
claimsPerDay: number;
/**
* Maximum endorsements per day
* @default 1000
*/
endorsementsPerDay: number;
};
};
};
}
/**
* Service for managing web push notifications
*
* This service provides endpoints for:
* - Registering push subscriptions
* - Sending push notifications
* - Managing notification preferences
*
* @example
* const pushService: IPushNotificationService = {
* id: 'push-service',
* type: 'PushNotificationService',
* serviceEndpoint: 'https://push.timesafari.app',
* metadata: {
* version: '1.0.0',
* capabilities: ['notifications'],
* config: {
* pushServer: 'https://push.timesafari.app',
* vapidPublicKey: '...'
* }
* }
* };
*/
export interface IPushNotificationService extends IService {
/** @override */
type: "PushNotificationService";
/** @override */
metadata: {
version: string;
capabilities: ["notifications"];
config: {
/**
* Push server URL
* @example 'https://push.timesafari.app'
*/
pushServer: string;
/**
* Optional VAPID public key for push notifications
*/
vapidPublicKey?: string;
};
};
}
/**
* Service for managing user profiles and settings
*
* This service provides endpoints for:
* - Managing user profiles
* - Updating user settings
* - Retrieving user preferences
*
* @example
* const profileService: IProfileService = {
* id: 'profile-service',
* type: 'ProfileService',
* serviceEndpoint: 'https://partner-api.endorser.ch',
* metadata: {
* version: '1.0.0',
* capabilities: ['profile', 'settings'],
* config: {
* partnerApiServer: 'https://partner-api.endorser.ch'
* }
* }
* };
*/
export interface IProfileService extends IService {
/** @override */
type: "ProfileService";
/** @override */
metadata: {
version: string;
capabilities: ["profile", "settings"];
config: {
/**
* Partner API server URL
* @example 'https://partner-api.endorser.ch'
*/
partnerApiServer: string;
};
};
}
/**
* Service for managing data backup and restore operations
*
* This service provides endpoints for:
* - Creating backups
* - Restoring from backups
* - Managing backup storage
*
* @example
* const backupService: IBackupService = {
* id: 'backup-service',
* type: 'BackupService',
* serviceEndpoint: 'https://backup.timesafari.app',
* metadata: {
* version: '1.0.0',
* capabilities: ['backup', 'restore'],
* config: {
* storageType: 'cloud',
* encryptionKey: '...'
* }
* }
* };
*/
export interface IBackupService extends IService {
/** @override */
type: "BackupService";
/** @override */
metadata: {
version: string;
capabilities: ["backup", "restore"];
config: {
/**
* Storage type for backups
* @default 'local'
*/
storageType: "local" | "cloud";
/**
* Optional encryption key for backups
*/
encryptionKey?: string;
};
};
}

4
src/libs/crypto/vc/index.ts

@ -38,6 +38,10 @@ export interface KeyMeta {
* The Webauthn credential ID in hex, if this is from a passkey * The Webauthn credential ID in hex, if this is from a passkey
*/ */
passkeyCredIdHex?: string; passkeyCredIdHex?: string;
/**
* The derivation path for the key
*/
derivationPath?: string;
} }
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver }); const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });

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}

63
src/main.ts

@ -200,14 +200,63 @@ function setupGlobalErrorHandler(app: VueApp) {
}; };
} }
const app = createApp(App) const app = createApp(App);
.component("fa", FontAwesomeIcon)
.component("camera", Camera) // Add global error handler for component registration
.use(createPinia()) app.config.errorHandler = (err, vm, info) => {
.use(VueAxios, axios) logger.error("Vue global error:", {
.use(router) error:
.use(Notifications); err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
componentName: vm?.$options?.name || "unknown",
info,
componentData: vm
? {
hasRouter: !!vm.$router,
hasNotify: !!vm.$notify,
hasAxios: !!vm.axios,
}
: "no vm data",
});
};
// Register components and plugins with error handling
try {
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
const pinia = createPinia();
app.use(pinia);
logger.log("Pinia store initialized");
app.use(VueAxios, axios);
logger.log("Axios initialized");
app.use(router);
logger.log("Router initialized");
app.use(Notifications);
logger.log("Notifications initialized");
setupGlobalErrorHandler(app); setupGlobalErrorHandler(app);
logger.log("Global error handler setup");
// Mount the app
app.mount("#app"); app.mount("#app");
logger.log("App mounted successfully");
} catch (error) {
logger.error("Critical error during app initialization:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
}

82
src/platforms/capacitor/DatabaseBackupService.ts

@ -0,0 +1,82 @@
/**
* @file DatabaseBackupService.ts
* @description Capacitor-specific implementation of database backup service
*
* This service handles database backup operations on Capacitor platforms (Android/iOS)
* using the Filesystem and Share plugins. It creates a temporary backup file,
* writes the backup data to it, and shares the file using the platform's share sheet.
*/
import { Filesystem, Directory } from "@capacitor/filesystem";
import { Share } from "@capacitor/share";
import { DatabaseBackupService as BaseDatabaseBackupService } from "../../services/DatabaseBackupService";
import { log, error } from "../../utils/logger";
export class DatabaseBackupService extends BaseDatabaseBackupService {
/**
* Handles the backup process for Capacitor platforms
*
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
*/
protected async handleBackup(
base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
try {
log("Starting backup process for Capacitor platform");
// Create a timestamped backup file name
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `timesafari-backup-${timestamp}.json`;
const backupFilePath = `backups/${backupFileName}`;
log("Creating backup file:", {
fileName: backupFileName,
path: backupFilePath,
});
// Write the backup file
const writeResult = (await Filesystem.writeFile({
path: backupFilePath,
data: base64Data,
directory: Directory.Cache,
recursive: true,
})) as unknown as { uri: string };
if (!writeResult.uri) {
throw new Error("Failed to write backup file: No URI returned");
}
log("Backup file written successfully:", { uri: writeResult.uri });
// Share the backup file
log("Sharing backup file");
await Share.share({
title: "TimeSafari Backup",
text: "Your TimeSafari backup file",
url: writeResult.uri,
dialogTitle: "Share TimeSafari Backup",
});
log("Backup shared successfully");
// Clean up the temporary file
try {
await Filesystem.deleteFile({
path: backupFilePath,
directory: Directory.Cache,
});
log("Temporary backup file cleaned up");
} catch (cleanupError) {
error("Failed to clean up temporary backup file:", cleanupError);
// Don't throw here as the backup was successful
}
} catch (err) {
error("Error during backup process:", err);
throw err;
}
}
}

359
src/router/index.ts

@ -6,8 +6,13 @@ import {
RouteLocationNormalized, RouteLocationNormalized,
RouteRecordRaw, RouteRecordRaw,
} from "vue-router"; } from "vue-router";
import { accountsDBPromise } from "../db/index"; import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
} from "../db/index";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { Component as VueComponent } from "vue-facing-decorator";
import { defineComponent } from "vue";
/** /**
* *
@ -35,7 +40,79 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/account", path: "/account",
name: "account", name: "account",
component: () => import("../views/AccountViewView.vue"), component: () => {
logger.log("Starting lazy load of AccountViewView");
return new Promise((resolve) => {
import("../views/AccountViewView.vue")
.then((module) => {
if (!module?.default) {
logger.error(
"AccountViewView module loaded but default export is missing",
{
module: {
hasDefault: !!module?.default,
keys: Object.keys(module || {}),
},
},
);
resolve(createErrorComponent());
return;
}
// Check if the component has the required dependencies
const component = module.default;
logger.log("AccountViewView loaded, checking dependencies...", {
componentName: component.name,
hasVueComponent: component instanceof VueComponent,
hasClass: typeof component === "function",
type: typeof component,
});
resolve(component);
})
.catch((err) => {
logger.error("Failed to load AccountViewView:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
type: typeof err,
});
resolve(createErrorComponent());
});
});
},
beforeEnter: async (to, from, next) => {
try {
logger.log("Account route beforeEnter guard starting");
// Check if required dependencies are available
const settings = await retrieveSettingsForActiveAccount();
logger.log("Account route: settings loaded", {
hasActiveDid: !!settings.activeDid,
isRegistered: !!settings.isRegistered,
});
next();
} catch (error) {
logger.error("Error in account route beforeEnter:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
next({ name: "home" });
}
},
}, },
{ {
path: "/claim/:id?", path: "/claim/:id?",
@ -315,25 +392,271 @@ const router = createRouter({
// Replace initial URL to start at `/` if necessary // Replace initial URL to start at `/` if necessary
router.replace(initialPath || "/"); router.replace(initialPath || "/");
const errorHandler = ( // Add global error handler
// eslint-disable-next-line @typescript-eslint/no-explicit-any router.onError((error, to, from) => {
error: any, logger.error("Router error:", {
to: RouteLocationNormalized, error:
from: RouteLocationNormalized, error instanceof Error
) => { ? {
// Handle the error here name: error.name,
logger.error("Caught in top level error handler:", error, to, from); message: error.message,
alert("Something is very wrong. Try reloading or restarting the app."); stack: error.stack,
}
: error,
to: {
name: to.name,
path: to.path,
},
from: {
name: from.name,
path: from.path,
},
});
// If it's a reference error during account view import, try to handle it gracefully
if (error instanceof ReferenceError && to.name === "account") {
logger.error("Account view import error:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
// Instead of redirecting, let the component's error handling take over
return;
}
});
// Add navigation guard for debugging
router.beforeEach((to, from, next) => {
logger.log("Navigation debug:", {
to: {
fullPath: to.fullPath,
path: to.path,
name: to.name,
params: to.params,
query: to.query,
},
from: {
fullPath: from.fullPath,
path: from.path,
name: from.name,
params: from.params,
query: from.query,
},
});
// For account route, try to preload the component
if (to.name === "account") {
logger.log("Preloading account component...");
// Wrap in try-catch and use Promise
new Promise((resolve) => {
logger.log("Starting dynamic import of AccountViewView");
// Add immediate try-catch to get more context
try {
const importPromise = import("../views/AccountViewView.vue");
logger.log("Import initiated successfully");
importPromise
.then((module) => {
try {
logger.log("Import completed, analyzing module:", {
moduleExists: !!module,
moduleType: typeof module,
moduleKeys: Object.keys(module || {}),
hasDefault: !!module?.default,
defaultType: module?.default
? typeof module.default
: "undefined",
defaultConstructor: module?.default?.constructor?.name,
moduleContent: {
...Object.fromEntries(
Object.entries(module).map(([key, value]) => [
key,
typeof value === "function"
? "function"
: typeof value === "object"
? Object.keys(value || {})
: typeof value,
]),
),
},
});
if (!module?.default) {
logger.error(
"AccountViewView preload: module loaded but default export is missing",
{
module: {
hasDefault: !!module?.default,
keys: Object.keys(module || {}),
moduleType: typeof module,
exports: Object.keys(module || {}).map((key) => ({
key,
type: typeof (module as any)[key],
value:
typeof (module as any)[key] === "function"
? "function"
: typeof (module as any)[key] === "object"
? Object.keys((module as any)[key] || {})
: (module as any)[key],
})),
},
},
);
resolve(null);
return;
}
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page const component = module.default;
// Try to safely inspect the component
const componentDetails = {
componentName: component.name,
hasVueComponent: component instanceof VueComponent,
hasClass: typeof component === "function",
type: typeof component,
properties: Object.keys(component),
decorators: Object.getOwnPropertyDescriptor(
component,
"__decorators",
),
vueOptions:
(component as any).__vccOpts ||
(component as any).options ||
null,
setup: typeof (component as any).setup === "function",
render: typeof (component as any).render === "function",
components: (component as any).components
? Object.keys((component as any).components)
: null,
imports: Object.keys(module).filter((key) => key !== "default"),
}; };
router.onError(errorHandler); // Assign the error handler to the router instance logger.log("Successfully analyzed component:", componentDetails);
resolve(component);
} catch (analysisError) {
logger.error("Error during component analysis:", {
error:
analysisError instanceof Error
? {
name: analysisError.name,
message: analysisError.message,
stack: analysisError.stack,
keys: Object.keys(analysisError),
properties: Object.getOwnPropertyNames(analysisError),
}
: analysisError,
type: typeof analysisError,
phase: "analysis",
});
resolve(null);
}
})
.catch((err) => {
logger.error("Failed to preload account component:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
keys: Object.keys(err),
properties: Object.getOwnPropertyNames(err),
}
: err,
type: typeof err,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
},
phase: "module-load",
});
resolve(null);
});
} catch (immediateError) {
logger.error("Immediate error during import initiation:", {
error:
immediateError instanceof Error
? {
name: immediateError.name,
message: immediateError.message,
stack: immediateError.stack,
keys: Object.keys(immediateError),
properties: Object.getOwnPropertyNames(immediateError),
}
: immediateError,
type: typeof immediateError,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
importPath: "../views/AccountViewView.vue",
},
phase: "import",
});
resolve(null);
}
}).catch((err) => {
logger.error("Critical error in account component preload:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
},
phase: "wrapper",
});
});
}
// Always call next() to continue navigation
next();
});
function createErrorComponent() {
return defineComponent({
name: "AccountViewError",
components: {
// Add any required components here
},
setup() {
const goHome = () => {
router.push({ name: "home" });
};
// router.beforeEach((to, from, next) => { return {
// console.log("Navigating to view:", to.name); goHome,
// console.log("From view:", from.name); };
// next(); },
// }); template: `
<section class="p-6 pb-24 max-w-3xl mx-auto">
<h1 class="text-4xl text-center font-light mb-8">Error Loading Account View</h1>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Failed to load account view.</strong>
<span class="block sm:inline"> Please try refreshing the page.</span>
</div>
<div class="mt-4 text-center">
<button @click="goHome" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Return to Home
</button>
</div>
</section>
`,
});
}
export default router; export default router;

95
src/services/DatabaseBackupService.ts

@ -0,0 +1,95 @@
/**
* @file DatabaseBackupService.ts
* @description Base service class for handling database backup operations
*
* This service implements the Template Method pattern to provide a common interface
* for database backup operations across different platforms. It defines the structure
* of backup operations while delegating platform-specific implementations to subclasses.
*
* Build Process Integration:
* 1. Platform-Specific Implementation:
* - Each platform (web, electron, capacitor) has its own implementation
* - Implementations are loaded dynamically via PlatformServiceFactory
* - Located in ./platforms/{platform}/DatabaseBackupService.ts
*
* 2. Build Configuration:
* - Vite config files (vite.config.*.mts) set VITE_PLATFORM
* - PlatformServiceFactory uses this to load correct implementation
* - Build process creates separate chunks for each platform
*
* 3. Data Handling:
* - Supports multiple data formats (base64, ArrayBuffer, Blob)
* - Platform implementations handle format conversion
* - Ensures consistent backup format across platforms
*
* Usage:
* - Create backup: DatabaseBackupService.createAndShareBackup(data)
* - Platform-specific: new WebDatabaseBackupService().handleBackup()
*
* @see PlatformServiceFactory.ts
* @see vite.config.web.mts
* @see vite.config.electron.mts
* @see vite.config.capacitor.mts
*/
import { PlatformServiceFactory } from "./PlatformServiceFactory";
import { log, error } from "../utils/logger";
export class DatabaseBackupService {
/**
* Template method that must be implemented by platform-specific services
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
* @throws Error if not implemented by subclass
*/
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
throw new Error(
"handleBackup must be implemented by platform-specific service",
);
}
/**
* Factory method to create and share a backup
* Uses PlatformServiceFactory to get platform-specific implementation
*
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
* @returns Promise that resolves when backup is complete
*/
public static async createAndShareBackup(
base64Data: string,
arrayBuffer: ArrayBuffer,
blob: Blob,
): Promise<void> {
try {
log("Creating platform-specific backup service");
const backupService = await this.getPlatformSpecificBackupService();
log("Backup service created successfully");
log("Executing platform-specific backup");
await backupService.handleBackup(base64Data, arrayBuffer, blob);
log("Backup completed successfully");
} catch (err) {
error("Error during backup creation:", err);
if (err instanceof Error) {
error("Error details:", {
name: err.name,
message: err.message,
stack: err.stack,
});
}
throw err;
}
}
private static async getPlatformSpecificBackupService(): Promise<DatabaseBackupService> {
const factory = PlatformServiceFactory.getInstance();
return await factory.createDatabaseBackupService();
}
}

195
src/services/PlatformServiceFactory.ts

@ -0,0 +1,195 @@
/**
* @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
* appropriate implementation based on the current platform (web, electron, etc.).
*
* Architecture:
* 1. Singleton Pattern:
* - Ensures only one factory instance exists
* - Manages platform-specific service instances
* - Maintains consistent state across the application
*
* 2. Dynamic Loading:
* - Uses Vite's dynamic import for platform-specific code
* - Loads services on-demand based on platform
* - Handles platform detection and service instantiation
*
* 3. Platform Detection:
* - Uses VITE_PLATFORM environment variable
* - Supports web, electron, and capacitor platforms
* - Falls back to 'web' if platform is not specified
*
* Usage:
* ```typescript
* // Get factory instance
* const factory = PlatformServiceFactory.getInstance();
*
* // Create platform-specific service
* const backupService = await factory.createDatabaseBackupService();
* ```
*
* @see vite.config.web.mts
* @see vite.config.electron.mts
* @see vite.config.capacitor.mts
* @see DatabaseBackupService
*/
import { DatabaseBackupService } from "./DatabaseBackupService";
import { DatabaseBackupService as StubDatabaseBackupService } from "./platforms/empty";
import { logger } from "../utils/logger";
/**
* Factory class for creating platform-specific service implementations
*
* This class manages the creation and instantiation of platform-specific
* service implementations. It uses the Abstract Factory pattern to provide
* a consistent interface for creating services across different platforms.
*
* @example
* ```typescript
* // Get factory instance
* const factory = PlatformServiceFactory.getInstance();
*
* // Create platform-specific service
* const backupService = await factory.createDatabaseBackupService();
*
* // Use the service
* await backupService.handleBackup(data);
* ```
*/
export class PlatformServiceFactory {
/**
* Singleton instance of the factory
* @private
*/
private static instance: PlatformServiceFactory;
/**
* Current platform identifier
* @private
*/
private platform: string;
/**
* Private constructor to enforce singleton pattern
*
* Initializes the factory with the current platform from environment variables.
* Falls back to 'web' if no platform is specified.
*
* @private
*/
private constructor() {
this.platform = import.meta.env.VITE_PLATFORM || "web";
}
/**
* Gets the singleton instance of the factory
*
* Creates a new instance if one doesn't exist, otherwise returns
* the existing instance.
*
* @returns {PlatformServiceFactory} The singleton factory instance
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* ```
*/
public static getInstance(): PlatformServiceFactory {
if (!PlatformServiceFactory.instance) {
PlatformServiceFactory.instance = new PlatformServiceFactory();
}
return PlatformServiceFactory.instance;
}
/**
* Creates a platform-specific database backup service
*
* Dynamically loads and instantiates the appropriate implementation
* based on the current platform. The implementation is loaded from
* 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
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* try {
* const backupService = await factory.createDatabaseBackupService();
* await backupService.handleBackup(data);
* } catch (error) {
* logger.error('Failed to create backup service:', error);
* }
* ```
*/
public async createDatabaseBackupService(): Promise<DatabaseBackupService> {
// List of supported platforms for web builds
const webSupportedPlatforms = ["web", "capacitor", "electron"];
// 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}`);
// 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 module.DatabaseBackupService();
} catch (error) {
logger.error(
`[TimeSafari] Failed to load platform-specific service for ${this.platform}:`,
error,
);
// Fallback to stub implementation on error
logger.log("Falling back to stub implementation");
return new StubDatabaseBackupService();
}
}
/**
* Gets the current platform identifier
*
* @returns {string} The current platform identifier
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* logger.log(factory.getPlatform()); // 'web', 'electron', or 'capacitor'
* ```
*/
public getPlatform(): string {
return this.platform;
}
/**
* Sets the current platform identifier
*
* This method is primarily used for testing purposes to override
* the platform detection. Use with caution in production code.
*
* @param {string} platform - The platform identifier to set
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* factory.setPlatform('electron'); // For testing purposes only
* ```
*/
public setPlatform(platform: string): void {
this.platform = platform;
}
}

105
src/services/ProfileService.ts

@ -0,0 +1,105 @@
/**
* @file ProfileService.ts
* @description Service class for handling user profile operations
* @author Matthew Raymer
* @version 1.0.0
*/
import { logger } from "../utils/logger";
import { getHeaders } from "../libs/endorserServer";
import type { UserProfile } from "@/types/interfaces";
export class ProfileService {
/**
* Saves a user profile to the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @param profile - The profile data to save
* @returns Promise<void>
*/
static async saveProfile(
activeDid: string,
partnerApiServer: string,
profile: Partial<UserProfile>,
): Promise<void> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfile`,
{
method: "POST",
headers,
body: JSON.stringify(profile),
},
);
if (!response.ok) {
throw new Error(`Failed to save profile: ${response.statusText}`);
}
} catch (error) {
logger.error("Error saving profile:", error);
throw error;
}
}
/**
* Deletes a user profile from the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @returns Promise<void>
*/
static async deleteProfile(
activeDid: string,
partnerApiServer: string,
): Promise<void> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfile`,
{
method: "DELETE",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to delete profile: ${response.statusText}`);
}
} catch (error) {
logger.error("Error deleting profile:", error);
throw error;
}
}
/**
* Loads a user profile from the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @returns Promise<UserProfile | null>
*/
static async loadProfile(
activeDid: string,
partnerApiServer: string,
): Promise<UserProfile | null> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
{ headers },
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to load profile: ${response.statusText}`);
}
return await response.json();
} catch (error) {
logger.error("Error loading profile:", error);
throw error;
}
}
}

110
src/services/RateLimitsService.ts

@ -0,0 +1,110 @@
/**
* @file RateLimitsService.ts
* @description Service class for handling rate limit operations
* @author Matthew Raymer
* @version 1.0.0
*/
import { logger } from "../utils/logger";
import { getHeaders } from "../libs/endorserServer";
import type { EndorserRateLimits, ImageRateLimits } from "../interfaces/limits";
import axios from "axios";
export class RateLimitsService {
/**
* Fetches rate limits for a given DID
* @param apiServer - The API server URL
* @param did - The user's DID
* @returns Promise<EndorserRateLimits>
*/
static async fetchRateLimits(
apiServer: string,
did: string,
): Promise<EndorserRateLimits> {
logger.log("Fetching rate limits for DID:", did);
logger.log("Using API server:", apiServer);
try {
const headers = await getHeaders(did);
const response = await axios.get(
`${apiServer}/api/v2/rate-limits/${did}`,
{ headers },
);
logger.log("Rate limits response:", response.data);
return response.data;
} catch (error) {
if (
axios.isAxiosError(error) &&
(error.response?.status === 400 || error.response?.status === 404)
) {
const errorData = error.response.data as {
error?: { message?: string; code?: string };
};
if (
errorData.error?.code === "UNREGISTERED_USER" ||
error.response?.status === 404
) {
logger.log("User is not registered, returning default limits");
return {
doneClaimsThisWeek: "0",
maxClaimsPerWeek: "0",
nextWeekBeginDateTime: new Date().toISOString(),
doneRegistrationsThisMonth: "0",
maxRegistrationsPerMonth: "0",
nextMonthBeginDateTime: new Date().toISOString(),
};
}
}
logger.error("Error fetching rate limits:", error);
throw error;
}
}
/**
* Fetches image rate limits for a given DID
* @param apiServer - The API server URL
* @param activeDid - The user's active DID
* @returns Promise<ImageRateLimits>
*/
static async fetchImageRateLimits(
apiServer: string,
activeDid: string,
): Promise<ImageRateLimits> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${apiServer}/api/endorser/imageRateLimits/${activeDid}`,
{ headers },
);
if (!response.ok) {
throw new Error(
`Failed to fetch image rate limits: ${response.statusText}`,
);
}
return await response.json();
} catch (error) {
logger.error("Error fetching image rate limits:", error);
throw error;
}
}
/**
* Formats rate limit error messages
* @param error - The error object
* @returns string
*/
static formatRateLimitError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "object" && error !== null) {
const err = error as {
response?: { data?: { error?: { message?: string } } };
};
return err.response?.data?.error?.message || "An unknown error occurred";
}
return "An unknown error occurred";
}
}

35
src/services/platforms/capacitor/DatabaseBackupService.ts

@ -0,0 +1,35 @@
/**
* @file DatabaseBackupService.ts
* @description Capacitor-specific implementation of the DatabaseBackupService
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { Filesystem } from "@capacitor/filesystem";
import { Share } from "@capacitor/share";
export default class CapacitorDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
// Capacitor platform handling
const fileName = `database-backup-${new Date().toISOString()}.json`;
const path = `backups/${fileName}`;
await Filesystem.writeFile({
path,
data: base64Data,
directory: "CACHE",
recursive: true,
});
await Share.share({
title: "Database Backup",
text: "Here's your database backup",
url: path,
});
}
}

18
src/services/platforms/electron/DatabaseBackupService.ts

@ -0,0 +1,18 @@
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { dialog } from "electron";
import * as fs from "fs";
import * as path from "path";
export default class ElectronDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(base64Data: string): Promise<void> {
const { filePath } = await dialog.showSaveDialog({
title: "Save Database Backup",
defaultPath: path.join(process.env.HOME || "", "database-backup.json"),
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (filePath) {
fs.writeFileSync(filePath, base64Data, "base64");
}
}
}

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 };

33
src/services/platforms/web/DatabaseBackupService.ts

@ -0,0 +1,33 @@
/**
* @file DatabaseBackupService.ts
* @description Web-specific implementation of the DatabaseBackupService
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { log, error } from "../../../utils/logger";
export default class WebDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
blob: Blob,
): Promise<void> {
try {
log("Starting web platform backup");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `database-backup-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log("Web platform backup completed");
} catch (err) {
error("Error during web platform backup:", err);
throw err;
}
}
}

64
src/types/capacitor.d.ts

@ -0,0 +1,64 @@
/**
* Type declarations for Capacitor modules used in the application.
* @author Matthew Raymer
*/
declare module "@capacitor/filesystem" {
export interface FileWriteOptions {
path: string;
data: string;
directory?: string;
encoding?: string;
recursive?: boolean;
}
export interface FileReadResult {
data: string;
}
export interface FileDeleteOptions {
path: string;
directory?: string;
}
export interface FilesystemDirectory {
Cache: "CACHE";
Documents: "DOCUMENTS";
Data: "DATA";
External: "EXTERNAL";
ExternalStorage: "EXTERNAL_STORAGE";
}
export interface Filesystem {
writeFile(options: FileWriteOptions): Promise<void>;
readFile(options: {
path: string;
directory?: string;
}): Promise<FileReadResult>;
deleteFile(options: FileDeleteOptions): Promise<void>;
}
export const Filesystem: Filesystem;
export const Directory: FilesystemDirectory;
export const Encoding: {
UTF8: "utf8";
ASCII: "ascii";
UTF16: "utf16";
};
}
declare module "@capacitor/share" {
export interface ShareOptions {
title?: string;
text?: string;
url?: string;
dialogTitle?: string;
files?: string[];
}
export interface Share {
share(options: ShareOptions): Promise<void>;
}
export const Share: Share;
}

7
src/types/index.ts

@ -1,3 +1,10 @@
/**
* Index file for all type declarations.
* @author Matthew Raymer
*/
export * from "./interfaces";
import { GiveSummaryRecord, GiveVerifiableCredential } from "interfaces"; import { GiveSummaryRecord, GiveVerifiableCredential } from "interfaces";
export interface GiveRecordWithContactInfo extends GiveSummaryRecord { export interface GiveRecordWithContactInfo extends GiveSummaryRecord {

430
src/types/interfaces.ts

@ -0,0 +1,430 @@
/**
* @file interfaces.ts
* @description Core type declarations for the TimeSafari application
*
* This module defines the core interfaces and types used throughout the application.
* It serves as the central location for type definitions that are shared across
* multiple components and services.
*
* Architecture:
* 1. DID (Decentralized Identifier) Types:
* - IIdentifier: Core DID structure
* - IKey: Cryptographic key information
* - IService: Service endpoints and capabilities
*
* 2. Verifiable Credential Types:
* - GenericCredWrapper: Base wrapper for all credentials
* - GiveVerifiableCredential: Gift-related credentials
* - OfferVerifiableCredential: Offer-related credentials
* - RegisterVerifiableCredential: Registration credentials
*
* 3. Service Types:
* - EndorserService: Claims and endorsements
* - PushNotificationService: Web push notifications
* - ProfileService: User profiles
* - BackupService: Data backup
*
* @see src/interfaces/identifier.ts
* @see src/interfaces/claims.ts
* @see src/interfaces/limits.ts
*/
import { GiveVerifiableCredential } from "../interfaces";
/**
* Interface for a Decentralized Identifier (DID)
*
* This interface defines the structure of a DID, which is a unique identifier
* that can be used to look up a DID document containing information associated
* with the DID, such as public keys and service endpoints.
*
* @example
* ```typescript
* const identifier: IIdentifier = {
* did: 'did:ethr:0x123...',
* provider: 'ethr',
* keys: [{
* kid: 'keys-1',
* kms: 'local',
* type: 'Secp256k1',
* publicKeyHex: '0x...',
* meta: { derivationPath: "m/44'/60'/0'/0/0" }
* }],
* services: [{
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch'
* }]
* };
* ```
*/
export interface IIdentifier {
/**
* The DID string in the format 'did:method:identifier'
* @example 'did:ethr:0x123...'
*/
did: string;
/**
* The DID method provider
* @example 'ethr'
*/
provider: string;
/**
* Array of cryptographic keys associated with the DID
*/
keys: Array<{
/**
* Key identifier
* @example 'keys-1'
*/
kid: string;
/**
* Key management system
* @example 'local'
*/
kms: string;
/**
* Key type
* @example 'Secp256k1'
*/
type: string;
/**
* Public key in hexadecimal format
* @example '0x...'
*/
publicKeyHex: string;
/**
* Optional metadata about the key
*/
meta?: {
/**
* HD wallet derivation path
* @example "m/44'/60'/0'/0/0"
*/
derivationPath?: string;
/**
* Key usage or purpose
* @example "signing", "encryption"
*/
usage?: string;
/**
* Key creation timestamp
*/
createdAt?: number;
/**
* Additional key metadata
*/
[key: string]: unknown;
};
}>;
/**
* Array of service endpoints associated with the DID
*/
services: Array<{
/**
* Service identifier
* @example 'endorser-service'
*/
id: string;
/**
* Service type
* @example 'EndorserService'
*/
type: string;
/**
* Service endpoint URL
* @example 'https://api.endorser.ch'
*/
serviceEndpoint: string;
/**
* Optional service description
*/
description?: string;
}>;
/**
* Optional metadata about the identifier
*/
meta?: {
/**
* DID method-specific metadata
* @example { network: "mainnet", chainId: 1 } for ethr
*/
method?: Record<string, unknown>;
/**
* Identifier creation timestamp
*/
createdAt?: number;
/**
* Last update timestamp
*/
updatedAt?: number;
/**
* Additional identifier metadata
*/
[key: string]: unknown;
};
}
/**
* Interface for a cryptographic key
*
* This interface defines the structure of a cryptographic key used in the
* DID system. It includes both public and private key information, along
* with metadata about the key's purpose and derivation.
*
* @example
* ```typescript
* const key: IKey = {
* id: 'did:ethr:0x123...#keys-1',
* type: 'Secp256k1VerificationKey2018',
* controller: 'did:ethr:0x123...',
* ethereumAddress: '0x123...',
* publicKeyHex: '0x...',
* privateKeyHex: '0x...',
* meta: {
* derivationPath: "m/44'/60'/0'/0/0"
* }
* };
* ```
*/
export interface IKey {
/**
* Unique identifier for the key
* @example 'did:ethr:0x123...#keys-1'
*/
id: string;
/**
* Key type specification
* @example 'Secp256k1VerificationKey2018'
*/
type: string;
/**
* DID that controls this key
* @example 'did:ethr:0x123...'
*/
controller: string;
/**
* Associated Ethereum address
* @example '0x123...'
*/
ethereumAddress: string;
/**
* Public key in hexadecimal format
* @example '0x...'
*/
publicKeyHex: string;
/**
* Private key in hexadecimal format
* @example '0x...'
*/
privateKeyHex: string;
/**
* Optional metadata about the key
*/
meta?: {
/**
* HD wallet derivation path
* @example "m/44'/60'/0'/0/0"
*/
derivationPath?: string;
/**
* Key usage or purpose
* @example "signing", "encryption"
*/
usage?: string;
/**
* Key creation timestamp
*/
createdAt?: number;
/**
* Additional key metadata
*/
[key: string]: unknown;
};
}
/**
* Interface for a service endpoint
*
* This interface defines the structure of a service endpoint that can be
* associated with a DID. Services provide additional functionality and
* endpoints for DID operations.
*
* @example
* ```typescript
* const service: IService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* description: 'Service for handling claims and endorsements',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: {
* apiServer: 'https://api.endorser.ch'
* }
* }
* };
* ```
*/
export interface IService {
/**
* Unique identifier for the service
* @example 'endorser-service'
*/
id: string;
/**
* Type of service
* @example 'EndorserService'
*/
type: string;
/**
* Service endpoint URL
* @example 'https://api.endorser.ch'
*/
serviceEndpoint: string;
/**
* Optional human-readable description
*/
description?: string;
/**
* Optional service metadata
*/
metadata?: {
/**
* Service version
* @example '1.0.0'
*/
version?: string;
/**
* Array of service capabilities
* @example ['claims', 'endorsements']
*/
capabilities?: string[];
/**
* Service-specific configuration
*/
config?: Record<string, unknown>;
};
}
export interface ExportProgress {
status: "preparing" | "exporting" | "complete" | "error";
message?: string;
error?: Error;
}
export interface UserProfile {
/** User's profile description */
description: string;
/** User's location information */
location?: {
/** Latitude coordinate */
lat: number;
/** Longitude coordinate */
lng: number;
};
/** User's given name */
givenName?: string;
/** User's family name */
familyName?: string;
}
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
}
export interface LeafletMouseEvent {
latlng: {
lat: number;
lng: number;
};
}
export interface GiveRecordWithContactInfo {
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
handleId: string;
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
giver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
issuer: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
receiver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
providerPlanName?: string;
recipientProjectName?: string;
image?: string;
}
export interface TimeSafariError extends Error {
/**
* User-friendly error message
*/
userMessage?: string;
/**
* Error code for programmatic handling
*/
code?: string;
/**
* Additional error context
*/
context?: Record<string, unknown>;
}

82
src/utils/logger.ts

@ -4,6 +4,32 @@ function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => { return JSON.stringify(obj, (key, value) => {
// Skip Vue component instance properties
if (
value &&
typeof value === "object" &&
("$el" in value || "$options" in value || "$parent" in value)
) {
return "[Vue Component]";
}
// Handle Vue router objects
if (
value &&
typeof value === "object" &&
("fullPath" in value || "path" in value || "name" in value)
) {
return {
fullPath: value.fullPath,
path: value.path,
name: value.name,
params: value.params,
query: value.query,
hash: value.hash,
};
}
// Handle circular references
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
if (seen.has(value)) { if (seen.has(value)) {
return "[Circular]"; return "[Circular]";
@ -11,6 +37,7 @@ function safeStringify(obj: unknown) {
seen.add(value); seen.add(value);
} }
// Handle functions
if (typeof value === "function") { if (typeof value === "function") {
return `[Function: ${value.name || "anonymous"}]`; return `[Function: ${value.name || "anonymous"}]`;
} }
@ -19,28 +46,63 @@ function safeStringify(obj: unknown) {
}); });
} }
function formatMessage(message: string, ...args: unknown[]): string {
const prefix = "[TimeSafari]";
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
return `${prefix} ${message}${argsString}`;
}
export const logger = { export const logger = {
log: (message: string, ...args: unknown[]) => { log: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(message, ...args); console.log(formattedMessage);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
logToDb(message + argsString);
} }
}, },
warn: (message: string, ...args: unknown[]) => { warn: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(message, ...args); console.warn(formattedMessage);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
logToDb(message + argsString);
} }
}, },
error: (message: string, ...args: unknown[]) => { error: (message: string, ...args: unknown[]) => {
// Errors will always be logged const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(message, ...args); console.error(formattedMessage);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
logToDb(message + argsString);
}, },
}; };
export function log(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.log(formattedMessage);
}
export function error(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.error(formattedMessage);
}
export function warn(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.warn(formattedMessage);
}
export function info(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.info(formattedMessage);
}
export function debug(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.debug(formattedMessage);
}

1965
src/views/AccountViewView.vue

File diff suppressed because it is too large

47
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 { 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>

114
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 { 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 || "";
@ -63,20 +84,12 @@ export default class ClaimReportCertificateView extends Vue {
} }
} }
async drawCanvas( async drawCanvas(claimData: GenericCredWrapper<GenericVerifiableCredential>) {
claimData: endorserServer.GenericCredWrapper<endorserServer.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 +97,13 @@ 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 +117,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 +127,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 +138,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 +154,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 +168,43 @@ 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 +215,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>

2
src/views/ContactGiftingView.vue

@ -21,7 +21,7 @@
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow"> <span class="grow">
<img <img
src="../assets/blank-square.svg" src="@/assets/blank-square.svg"
width="32" width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/> />

4
src/views/HelpView.vue

@ -484,13 +484,13 @@
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer"> <a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span> <span class="text-blue-500 mr-1">CC0 1.0</span>
<img <img
src="../assets/help/creative-commons-circle.svg" src="@/assets/help/creative-commons-circle.svg"
alt="CC circle" alt="CC circle"
width="20" width="20"
class="display: inline" class="display: inline"
/> />
<img <img
src="../assets/help/creative-commons-zero.svg" src="@/assets/help/creative-commons-zero.svg"
alt="CC zero" alt="CC zero"
width="20" width="20"
style="display: inline" style="display: inline"

609
src/views/HomeView.vue

@ -135,7 +135,7 @@ Raymer * @version 1.0.0 */
> >
<li @click="openDialog()"> <li @click="openDialog()">
<img <img
src="../assets/blank-square.svg" src="@/assets/blank-square.svg"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer" class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/> />
<h3 <h3
@ -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>
@ -321,6 +321,7 @@ import {
db, db,
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateDefaultSettings,
updateAccountSettings, updateAccountSettings,
} from "../db/index"; } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
@ -345,10 +346,13 @@ import {
GiverReceiverInputInfo, GiverReceiverInputInfo,
OnboardPage, OnboardPage,
} from "../libs/util"; } from "../libs/util";
import { GiveSummaryRecord } from "../interfaces"; import { GiveSummaryRecord, PlanSummaryRecord } from "../interfaces/records";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "types"; import { GiveRecordWithContactInfo } from "types";
import { TimeSafariError } from "../types/interfaces";
import { GiveVerifiableCredential } from "../interfaces/claims";
import { GenericVerifiableCredential } from "../interfaces/common";
/** /**
* HomeView Component * HomeView Component
@ -411,7 +415,8 @@ export default class HomeView extends Vue {
isCreatingIdentifier = false; isCreatingIdentifier = false;
isFeedFilteredByVisible = false; isFeedFilteredByVisible = false;
isFeedFilteredByNearby = false; isFeedFilteredByNearby = false;
isFeedLoading = true; isFeedLoading = false;
isFeedLoadingInProgress = false;
isRegistered = false; isRegistered = false;
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
@ -429,6 +434,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 +452,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 +490,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,10 +500,25 @@ 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 || "";
// Ensure activeDid is set correctly
if (!settings.activeDid && this.allMyDids.length > 0) {
// If no activeDid is set but we have DIDs, use the first one
await updateDefaultSettings({ activeDid: this.allMyDids[0] });
this.activeDid = this.allMyDids[0];
} else {
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;
@ -493,70 +529,39 @@ export default class HomeView extends Vue {
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: TimeSafariError) => {
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: TimeSafariError) => {
this.axios, logger.error("Error updating feed:", err);
this.apiServer, });
this.activeDid, } catch (err) {
this.lastAckedOfferToUserProjectsJwtId, const error = err as TimeSafariError;
logConsoleAndDb(
"Error retrieving settings or feed: " + error.message,
true,
); );
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( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: text:
(err as { userMessage?: string })?.userMessage || error.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,
@ -630,7 +635,13 @@ export default class HomeView extends Vue {
this.isRegistered = true; this.isRegistered = true;
} }
} catch (e) { } catch (e) {
// ignore the error... just keep us unregistered // 400 errors are expected for unregistered users - log as info instead of warning
if (e instanceof Error && e.message.includes("400")) {
logger.log("User is not registered (expected 400 response)");
} else {
logger.warn("Unexpected error checking rate limits:", e);
}
// Keep the unregistered state
} }
} }
} }
@ -767,11 +778,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);
} }
} }
@ -835,28 +849,113 @@ export default class HomeView extends Vue {
* - this.feedLastViewedClaimId (via updateFeedLastViewedId) * - this.feedLastViewedClaimId (via updateFeedLastViewedId)
*/ */
async updateAllFeed() { async updateAllFeed() {
logger.log("Starting updateAllFeed...");
// Prevent multiple simultaneous feed loads
if (this.isFeedLoadingInProgress) {
logger.log("Feed load already in progress, skipping...");
return;
}
this.isFeedLoading = true; this.isFeedLoading = true;
this.isFeedLoadingInProgress = true;
let endOfResults = true; let endOfResults = true;
const MAX_RETRIES = 3;
let retryCount = 0;
const MIN_REQUIRED_ITEMS = 10; // Minimum number of items we want to load
try { try {
logger.log(`Attempting to connect to server: ${this.apiServer}`);
logger.log(`Using active DID: ${this.activeDid}`);
logger.log(`Previous oldest ID: ${this.feedPreviousOldestId || "none"}`);
const results = await this.retrieveGives( const results = await this.retrieveGives(
this.apiServer, this.apiServer,
this.feedPreviousOldestId, this.feedPreviousOldestId,
); );
logger.log(`Server response status: ${results.status || "unknown"}`);
logger.log(`Retrieved ${results.data.length} feed items`);
logger.log(`Hit limit: ${results.hitLimit}`);
logger.log(`Response headers: ${JSON.stringify(results.headers || {})}`);
// If we got a 304 response, we should stop here
if (results.data.length === 0 && !results.hitLimit) {
logger.log("Received 304 response - no new data");
this.isFeedLoading = false;
this.isFeedLoadingInProgress = false;
return;
}
if (results.data.length > 0) { if (results.data.length > 0) {
endOfResults = false; endOfResults = false;
try {
logger.log(`Processing ${results.data.length} feed results...`);
await this.processFeedResults(results.data); await this.processFeedResults(results.data);
logger.log("Successfully processed feed results");
await this.updateFeedLastViewedId(results.data); await this.updateFeedLastViewedId(results.data);
logger.log("Updated feed last viewed ID");
// If we don't have enough items and haven't hit the limit, try to load more
if (this.feedData.length < MIN_REQUIRED_ITEMS && !results.hitLimit) {
logger.log(
`Only have ${this.feedData.length} items, loading more...`,
);
this.feedPreviousOldestId =
results.data[results.data.length - 1].jwtId;
await this.updateAllFeed();
} }
} catch (e) { } catch (processError) {
this.handleFeedError(e); logger.error("Error in feed processing:", processError);
throw new Error(
`Feed processing error: ${processError instanceof Error ? processError.message : String(processError)}`,
);
}
} else {
logger.log("No new feed data received");
}
} catch (err) {
// Don't log empty error objects
if (err && typeof err === "object" && Object.keys(err).length === 0) {
logger.log("Received empty error object - likely a 304 response");
this.isFeedLoading = false;
this.isFeedLoadingInProgress = false;
return;
}
logger.error("Error in updateAllFeed:", err);
logger.error("Error details:", {
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
status: (err as any)?.response?.status,
statusText: (err as any)?.response?.statusText,
headers: (err as any)?.response?.headers,
apiServer: this.apiServer,
activeDid: this.activeDid,
feedDataLength: this.feedData.length,
isFeedLoading: this.isFeedLoading,
isFeedLoadingInProgress: this.isFeedLoadingInProgress,
});
const error = err instanceof Error ? err : new Error(String(err));
this.handleFeedError(error);
} }
if (this.feedData.length === 0 && !endOfResults) { // Only retry if we have no data and haven't hit the limit
if (
this.feedData.length === 0 &&
!endOfResults &&
retryCount < MAX_RETRIES
) {
retryCount++;
logger.log(
`Retrying feed update (attempt ${retryCount}/${MAX_RETRIES})...`,
);
await this.updateAllFeed(); await this.updateAllFeed();
} }
this.isFeedLoading = false; this.isFeedLoading = false;
this.isFeedLoadingInProgress = false;
logger.log(`Feed update completed with ${this.feedData.length} items`);
} }
/** /**
@ -881,13 +980,70 @@ 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) { logger.log(`Starting to process ${records.length} feed records...`);
// Process records in larger chunks to improve performance
const CHUNK_SIZE = 10; // Increased from 5 to 10
let processedCount = 0;
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE);
logger.log(
`Processing chunk ${i / CHUNK_SIZE + 1} of ${Math.ceil(records.length / CHUNK_SIZE)} (${chunk.length} records)...`,
);
try {
await Promise.all(
chunk.map(async (record) => {
try {
// Skip if we already have this record
if (this.feedData.some((r) => r.jwtId === record.jwtId)) {
logger.log(`Skipping duplicate record ${record.jwtId}`);
return;
}
logger.log(`Processing record ${record.jwtId}...`);
const processedRecord = await this.processRecord(record); const processedRecord = await this.processRecord(record);
if (processedRecord) { if (processedRecord) {
this.feedData.push(processedRecord); this.feedData.push(processedRecord);
processedCount++;
logger.log(
`Successfully added record ${record.jwtId} to feed (total processed: ${processedCount})`,
);
} else {
logger.log(`Record ${record.jwtId} filtered out`);
}
} catch (recordError) {
logger.error(
`Error processing record ${record.jwtId}:`,
recordError,
);
throw recordError;
}
}),
);
// Allow UI to update between chunks
await new Promise((resolve) => setTimeout(resolve, 0));
} catch (chunkError) {
logger.error("Error processing chunk:", chunkError);
throw chunkError;
}
}
logger.log(
`Completed processing ${processedCount} records out of ${records.length} total records`,
);
if (records.length > 0) {
// Update the oldest ID only if we have new records
const oldestId = records[records.length - 1].jwtId;
if (!this.feedPreviousOldestId || oldestId < this.feedPreviousOldestId) {
this.feedPreviousOldestId = oldestId;
logger.log(
`Updated feedPreviousOldestId to ${this.feedPreviousOldestId}`,
);
} }
} }
this.feedPreviousOldestId = records[records.length - 1].jwtId;
} }
/** /**
@ -925,19 +1081,97 @@ export default class HomeView extends Vue {
private async processRecord( private async processRecord(
record: GiveSummaryRecord, record: GiveSummaryRecord,
): Promise<GiveRecordWithContactInfo | null> { ): Promise<GiveRecordWithContactInfo | null> {
try {
logger.log(`Starting to process record ${record.jwtId}...`);
const claim = this.extractClaim(record); const claim = this.extractClaim(record);
const giverDid = this.extractGiverDid(claim); logger.log(`Extracted claim for ${record.jwtId}:`, claim);
const recipientDid = this.extractRecipientDid(claim);
// For hidden claims, we can use the provider's identifier as a fallback
let giverDid: string;
try {
giverDid = this.extractGiverDid(claim);
logger.log(`Extracted giver DID for ${record.jwtId}: ${giverDid}`);
} catch (giverError) {
if (claim.provider?.identifier) {
logger.log(
`Using provider identifier as fallback for giver DID: ${claim.provider.identifier}`,
);
giverDid = claim.provider.identifier as string;
} else {
logger.error(
`Error extracting giver DID for ${record.jwtId}:`,
giverError,
);
return null; // Skip this record instead of throwing
}
}
let recipientDid: string;
try {
recipientDid = this.extractRecipientDid(claim);
logger.log(
`Extracted recipient DID for ${record.jwtId}: ${recipientDid}`,
);
} catch (recipientError) {
logger.error(
`Error extracting recipient DID for ${record.jwtId}:`,
recipientError,
);
return null; // Skip this record instead of throwing
}
let fulfillsPlan: PlanSummaryRecord | null | undefined;
try {
fulfillsPlan = await this.getFulfillsPlan(record);
logger.log(
`Retrieved fulfills plan for ${record.jwtId}:`,
fulfillsPlan,
);
} catch (planError) {
logger.error(
`Error retrieving fulfills plan for ${record.jwtId}:`,
planError,
);
return null; // Skip this record instead of throwing
}
const fulfillsPlan = await this.getFulfillsPlan(record);
if (!this.shouldIncludeRecord(record, fulfillsPlan)) { if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
logger.log(
`Record ${record.jwtId} filtered out by shouldIncludeRecord`,
);
return null; return null;
} }
const provider = this.extractProvider(claim); let provider: GenericVerifiableCredential | null;
const providedByPlan = await this.getProvidedByPlan(provider); try {
provider = this.extractProvider(claim);
logger.log(`Extracted provider for ${record.jwtId}:`, provider);
} catch (providerError) {
logger.error(
`Error extracting provider for ${record.jwtId}:`,
providerError,
);
return null; // Skip this record instead of throwing
}
let providedByPlan: PlanSummaryRecord | null | undefined;
try {
providedByPlan = await this.getProvidedByPlan(provider);
logger.log(
`Retrieved provided by plan for ${record.jwtId}:`,
providedByPlan,
);
} catch (providedByError) {
logger.error(
`Error retrieving provided by plan for ${record.jwtId}:`,
providedByError,
);
return null; // Skip this record instead of throwing
}
return this.createFeedRecord( try {
const feedRecord = this.createFeedRecord(
record, record,
claim, claim,
giverDid, giverDid,
@ -946,6 +1180,19 @@ export default class HomeView extends Vue {
fulfillsPlan, fulfillsPlan,
providedByPlan, providedByPlan,
); );
logger.log(`Successfully created feed record for ${record.jwtId}`);
return feedRecord;
} catch (createError) {
logger.error(
`Error creating feed record for ${record.jwtId}:`,
createError,
);
return null; // Skip this record instead of throwing
}
} catch (error) {
logger.error(`Error processing record ${record.jwtId}:`, error);
return null; // Skip this record instead of throwing
}
} }
/** /**
@ -987,9 +1234,30 @@ export default class HomeView extends Vue {
* @param claim The claim object containing giver information * @param claim The claim object containing giver information
* @returns The giver's DID * @returns The giver's DID
*/ */
private extractGiverDid(claim: any) { private extractGiverDid(claim: GiveVerifiableCredential): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any try {
return claim.agent?.identifier || (claim.agent as any)?.did; if (!claim.agent?.identifier) {
logger.log("Agent identifier is missing in claim. Claim structure:", {
type: claim["@type"],
hasAgent: !!claim.agent,
agentIdentifier: claim.agent?.identifier,
provider: claim.provider,
recipient: claim.recipient,
});
// For hidden claims, we can use the provider's identifier as a fallback
if (claim.provider?.identifier) {
logger.log("Using provider identifier as fallback for giver DID");
return claim.provider.identifier as string;
}
// If no provider identifier, return a default value instead of throwing
return "did:none:HIDDEN";
}
return claim.agent.identifier;
} catch (error) {
logger.error("Error extracting giver DID:", error);
// Return a default value instead of throwing
return "did:none:HIDDEN";
}
} }
/** /**
@ -998,9 +1266,17 @@ export default class HomeView extends Vue {
* @internal * @internal
* Called by processRecord() * Called by processRecord()
*/ */
private extractRecipientDid(claim: any) { private extractRecipientDid(claim: GiveVerifiableCredential): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any try {
return claim.recipient?.identifier || (claim.recipient as any)?.did; if (!claim.recipient?.identifier) {
logger.error("Recipient identifier is missing in claim:", claim);
throw new Error("Recipient identifier is missing in claim");
}
return claim.recipient.identifier;
} catch (error) {
logger.error("Error extracting recipient DID:", error);
throw error;
}
} }
/** /**
@ -1056,14 +1332,15 @@ export default class HomeView extends Vue {
*/ */
private shouldIncludeRecord( private shouldIncludeRecord(
record: GiveSummaryRecord, record: GiveSummaryRecord,
fulfillsPlan: any, fulfillsPlan: PlanSummaryRecord | null | undefined,
): boolean { ): boolean {
if (!this.isAnyFeedFilterOn) { if (!this.isAnyFeedFilterOn) {
return true; return true;
} }
let anyMatch = false; let anyMatch = false;
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) { if (this.isFeedFilteredByVisible) {
// Don't filter out records with hidden DIDs
anyMatch = true; anyMatch = true;
} }
@ -1090,7 +1367,7 @@ export default class HomeView extends Vue {
* @internal * @internal
* Called by processRecord() * Called by processRecord()
*/ */
private extractProvider(claim: any) { private extractProvider(claim: GiveVerifiableCredential) {
return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider; return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider;
} }
@ -1100,7 +1377,9 @@ export default class HomeView extends Vue {
* @internal * @internal
* Called by processRecord() * Called by processRecord()
*/ */
private async getProvidedByPlan(provider: any) { private async getProvidedByPlan(
provider: GenericVerifiableCredential | null,
) {
return await getPlanFromCache( return await getPlanFromCache(
provider?.identifier as string, provider?.identifier as string,
this.axios, this.axios,
@ -1138,12 +1417,12 @@ export default class HomeView extends Vue {
*/ */
private createFeedRecord( private createFeedRecord(
record: GiveSummaryRecord, record: GiveSummaryRecord,
claim: any, claim: GiveVerifiableCredential,
giverDid: string, giverDid: string,
recipientDid: string, recipientDid: string,
provider: any, provider: GenericVerifiableCredential | null,
fulfillsPlan: any, fulfillsPlan: PlanSummaryRecord | null | undefined,
providedByPlan: any, providedByPlan: PlanSummaryRecord | null | undefined,
): GiveRecordWithContactInfo { ): GiveRecordWithContactInfo {
return { return {
...record, ...record,
@ -1202,16 +1481,40 @@ export default class HomeView extends Vue {
* @internal * @internal
* Called by updateAllFeed() * Called by updateAllFeed()
*/ */
private handleFeedError(e: any) { private handleFeedError(error: TimeSafariError | unknown) {
logger.error("Error with feed load:", e); // Skip logging empty error objects
if (error && typeof error === "object" && Object.keys(error).length === 0) {
logger.log("Received empty error object - likely a 304 response");
return;
}
logger.error("Error with feed load:", error);
// Better error message construction
let errorMessage = "There was an error retrieving feed data.";
if (error) {
if (typeof error === "string") {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "object" && error !== null) {
const timeSafariError = error as TimeSafariError;
if (timeSafariError.userMessage) {
errorMessage = timeSafariError.userMessage;
} else if (timeSafariError.message) {
errorMessage = timeSafariError.message;
}
}
}
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Feed Error", title: "Feed Error",
text: e.userMessage || "There was an error retrieving feed data.", text: errorMessage,
}, },
-1, 5000,
); );
} }
@ -1221,38 +1524,68 @@ export default class HomeView extends Vue {
* @internal * @internal
* Called by updateAllFeed() * Called by updateAllFeed()
* @param endorserApiServer API server URL * @param endorserApiServer API server URL
* @param beforeId OptioCalled by updateAllFeed()nal ID to fetch earlier results * @param beforeId Optional ID to fetch earlier results
* @returns claims in reverse chronological order * @returns claims in reverse chronological order
*/ */
async retrieveGives(endorserApiServer: string, beforeId?: string) { async retrieveGives(endorserApiServer: string, beforeId?: string) {
logger.log("Starting retrieveGives...");
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId; const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more
const headers = await getHeaders( const headers = await getHeaders(
this.activeDid, this.activeDid,
doNotShowErrorAgain ? undefined : this.$notify, doNotShowErrorAgain ? undefined : this.$notify,
); );
logger.log("Retrieved headers for retrieveGives");
// retrieve headers for this user, but if an error happens then report it but proceed with the fetch with no header // retrieve headers for this user, but if an error happens then report it but proceed with the fetch with no header
const response = await fetch( const url =
endorserApiServer + endorserApiServer +
"/api/v2/report/gives?giftNotTrade=true" + "/api/v2/report/gives?giftNotTrade=true" +
beforeQuery, beforeQuery;
{ logger.log("Making request to URL:", url);
const response = await fetch(url, {
method: "GET", method: "GET",
headers: headers, headers: headers,
}, });
logger.log("RetrieveGives response status:", response.status);
logger.log(
"RetrieveGives response headers:",
Object.fromEntries(response.headers.entries()),
); );
if (!response.ok) { // 304 Not Modified is a successful response
throw await response.text(); if (!response.ok && response.status !== 304) {
const errorText = await response.text();
logger.error("RetrieveGives error response:", errorText);
throw errorText;
} }
// For 304 responses, we can use the cached data
if (response.status === 304) {
logger.log("RetrieveGives: Got 304 Not Modified response");
return { data: [], hitLimit: false };
}
try {
const results = await response.json(); const results = await response.json();
logger.log(
"RetrieveGives response data length:",
results.data?.length || 0,
);
if (results.data) { if (results.data) {
logger.log("Successfully parsed response data");
return results; return results;
} else { } else {
logger.error("RetrieveGives: Invalid response format:", results);
throw JSON.stringify(results); throw JSON.stringify(results);
} }
} catch (parseError) {
logger.error("Error parsing response JSON:", parseError);
throw parseError;
}
} }
/** /**
@ -1296,9 +1629,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;
@ -1363,7 +1696,35 @@ export default class HomeView extends Vue {
* Called by template click handler * Called by template click handler
*/ */
goToActivityToUserPage() { goToActivityToUserPage() {
this.$router.push({ name: "new-activity" }); try {
if (!this.$router) {
logger.error("Router not initialized");
return;
}
this.$router.push({ name: "new-activity" }).catch((err) => {
logger.error("Navigation error:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Navigation Error",
text: "Unable to navigate to activity page. Please try again.",
},
5000,
);
});
} catch (err) {
logger.error("Error in goToActivityToUserPage:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "An unexpected error occurred. Please try again.",
},
5000,
);
}
} }
/** /**
@ -1588,15 +1949,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 +1968,48 @@ 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,
);
}
} }
/** /**

2
src/views/ProjectViewView.vue

@ -220,7 +220,7 @@
</li> </li>
<li @click="openGiftDialogToProject()"> <li @click="openGiftDialogToProject()">
<img <img
src="../assets/blank-square.svg" src="@/assets/blank-square.svg"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer" class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/> />
<h3 <h3

6
src/views/ProjectsView.vue

@ -279,11 +279,7 @@ import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
didInfo,
getHeaders,
getPlanFromCache,
} from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records"; import { OfferSummaryRecord, PlanData } from "../interfaces/records";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { OnboardPage } from "../libs/util"; import { OnboardPage } from "../libs/util";

47
test-playwright/00-noid-tests.spec.ts

@ -74,13 +74,30 @@ import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUt
test('Check activity feed - check that server is running', async ({ page }) => { test('Check activity feed - check that server is running', async ({ page }) => {
// Load app homepage // Load app homepage
await page.goto('./'); await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Check that initial 10 activities have been loaded // Wait for and dismiss onboarding dialog, with retry logic
const closeOnboarding = async () => {
const closeButton = page.getByTestId('closeOnboardingAndFinish');
if (await closeButton.isVisible()) {
await closeButton.click();
await expect(closeButton).toBeHidden();
}
};
// Initial dismissal
await closeOnboarding();
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Check and dismiss onboarding again if it reappeared
await closeOnboarding();
// Wait for initial feed items to load
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible(); await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
// Scroll down a bit to trigger loading additional activities // Scroll down a bit to trigger loading additional activities
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded(); await page.locator('ul#listLatestActivity li:nth-child(20)').scrollIntoViewIfNeeded();
}); });
test('Check discover results', async ({ page }) => { test('Check discover results', async ({ page }) => {
@ -104,8 +121,11 @@ test('Check no-ID messaging in account', async ({ page }) => {
// Check 'a friend needs to register you' notice // Check 'a friend needs to register you' notice
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible(); await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible();
// Check that there is no ID // Check that there is no ID by finding the wrapper first
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty(); const didWrapper = page.locator('[data-testId="didWrapper"]');
await expect(didWrapper).toBeVisible();
const codeElement = didWrapper.locator('code[role="code"]');
await expect(codeElement).toBeEmpty();
}); });
test('Check ability to share contact', async ({ page }) => { test('Check ability to share contact', async ({ page }) => {
@ -169,7 +189,14 @@ test('Check setting name & sharing info', async ({ page }) => {
test('Confirm test API setting (may fail if you are running your own Time Safari)', async ({ page }, testInfo) => { test('Confirm test API setting (may fail if you are running your own Time Safari)', async ({ page }, testInfo) => {
// Load account view // Load account view
await page.goto('./account'); await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
// Wait for and click the Advanced heading
const advancedHeading = page.getByRole('heading', { name: 'Advanced' });
await advancedHeading.waitFor({ state: 'visible' });
await advancedHeading.click();
// Wait for the Advanced section to be fully loaded
await page.waitForLoadState('networkidle');
// look into the config file: if it starts Time Safari, it might say which server it should set by default // look into the config file: if it starts Time Safari, it might say which server it should set by default
const webServer = testInfo.config.webServer; const webServer = testInfo.config.webServer;
@ -178,8 +205,12 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '=')); const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '='));
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1); const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch'; // Find the Claim Server input field using the label's for attribute
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer); const serverInput = page.locator('input[type="text"]').first();
await serverInput.waitFor({ state: 'visible' });
const endorserServer = endorserTermInConfig || 'https://api.endorser.ch';
await expect(serverInput).toHaveValue(endorserServer);
}); });
test('Check User 0 can register a random person', async ({ page }) => { test('Check User 0 can register a random person', async ({ page }) => {

3
tsconfig.json

@ -18,7 +18,8 @@
"@/db/*": ["db/*"], "@/db/*": ["db/*"],
"@/libs/*": ["libs/*"], "@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"], "@/constants/*": ["constants/*"],
"@/store/*": ["store/*"] "@/store/*": ["store/*"],
"@/types/*": ["types/*"]
}, },
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs "lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs
}, },

46
vite.config.base.ts

@ -0,0 +1,46 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify'
},
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: ['stream', 'util', 'crypto'],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto'
}
}
}
}
});

12
vite.config.common.mts

@ -36,7 +36,15 @@ export async function createBuildConfig(mode: string) {
assetsDir: 'assets', assetsDir: 'assets',
chunkSizeWarningLimit: 1000, chunkSizeWarningLimit: 1000,
rollupOptions: { rollupOptions: {
external: isCapacitor ? ['@capacitor/app'] : [] external: isCapacitor ? ['@capacitor/app'] : [],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.svg')) {
return 'assets/[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
} }
}, },
define: { define: {
@ -44,6 +52,8 @@ export async function createBuildConfig(mode: string) {
'process.env.VITE_PLATFORM': JSON.stringify(mode), 'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)), 'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)),
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""', __dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
'process.env.VITE_ASSET_URL': JSON.stringify(isCapacitor ? './assets/' : '/assets/'),
'process.env.VITE_BASE_URL': JSON.stringify(isCapacitor ? './' : '/')
}, },
resolve: { resolve: {
alias: { alias: {

156
vite.config.dev.mts.timestamp-1743747195916-245e61245d5ec.mjs

File diff suppressed because one or more lines are too long

28
vite.config.mobile.ts

@ -0,0 +1,28 @@
import { defineConfig, loadEnv } from "vite";
import baseConfig from "./vite.config.base";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
...baseConfig,
define: {
'import.meta.env.VITE_PLATFORM': JSON.stringify('mobile'),
},
build: {
...baseConfig.build,
outDir: 'dist/mobile',
rollupOptions: {
...baseConfig.build.rollupOptions,
output: {
...baseConfig.build.rollupOptions.output,
manualChunks: {
// Mobile-specific chunk splitting
vendor: ['vue', 'vue-router', 'pinia'],
capacitor: ['@capacitor/core', '@capacitor/filesystem', '@capacitor/share'],
}
}
}
}
};
});

60
vite.config.ts

@ -1,46 +1,18 @@
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue"; import baseConfig from "./vite.config.base";
import path from "path";
export default defineConfig({ // https://vitejs.dev/config/
plugins: [vue()], export default defineConfig(({ mode }) => {
resolve: { // Load env file based on `mode` in the current working directory.
alias: { // Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
'@': path.resolve(__dirname, './src'), const env = loadEnv(mode, process.cwd(), '');
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'), const platform = env.PLATFORM || 'web';
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'), // Load platform-specific config
stream: 'stream-browserify', const platformConfig = require(`./vite.config.${platform}`).default;
util: 'util',
crypto: 'crypto-browserify' return {
}, ...baseConfig,
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'], ...platformConfig,
}, };
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: ['stream', 'util', 'crypto'],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto'
}
}
}
}
}); });

109
vite.config.web.mts

@ -1,27 +1,92 @@
import { defineConfig, mergeConfig } from "vite"; /**
import { VitePWA } from "vite-plugin-pwa"; * @file vite.config.web.mts
import { createBuildConfig } from "./vite.config.common.mts"; * @description Vite configuration for web platform builds
import { loadAppConfig } from "./vite.config.utils.mts"; *
* This configuration file defines how the application is built for web platforms.
* It extends the base configuration with web-specific settings and optimizations.
*
* Build Process Integration:
* 1. Configuration Loading:
* - Loads environment variables based on build mode
* - Merges base configuration from vite.config.common.mts
* - Loads application-specific configuration
*
* 2. Platform Definition:
* - Sets VITE_PLATFORM environment variable to 'web'
* - Used by PlatformServiceFactory to load web-specific implementations
*
* 3. Build Output:
* - Outputs to 'dist/web' directory
* - Creates vendor chunk for Vue-related dependencies
* - Enables PWA features with auto-update capability
*
* 4. Development vs Production:
* - Development: Enables source maps and development features
* - Production: Optimizes chunks and enables PWA features
*
* Usage:
* - Development: npm run dev
* - Production: npm run build:web
*
* @see vite.config.common.mts
* @see vite.config.utils.mts
* @see PlatformServiceFactory.ts
*/
export default defineConfig(async () => { import { defineConfig } from "vite";
const baseConfig = await createBuildConfig('web'); import vue from "@vitejs/plugin-vue";
const appConfig = await loadAppConfig(); import baseConfig from "./vite.config.base";
return mergeConfig(baseConfig, { // Define Node.js built-in modules that need browser compatibility
plugins: [ const nodeBuiltins = {
VitePWA({ stream: 'stream-browserify',
registerType: 'autoUpdate', util: 'util',
manifest: appConfig.pwaConfig?.manifest, crypto: 'crypto-browserify',
devOptions: { http: 'stream-http',
enabled: false https: 'https-browserify',
zlib: 'browserify-zlib',
url: 'url',
assert: 'assert',
path: 'path-browserify',
fs: 'browserify-fs',
tty: 'tty-browserify'
};
export default defineConfig({
...baseConfig,
plugins: [vue()],
optimizeDeps: {
...baseConfig.optimizeDeps,
include: [...(baseConfig.optimizeDeps?.include || []), 'qrcode.vue'],
exclude: Object.keys(nodeBuiltins),
esbuildOptions: {
define: {
global: 'globalThis'
}
}
}, },
workbox: { resolve: {
cleanupOutdatedCaches: true, ...baseConfig.resolve,
skipWaiting: true, alias: {
clientsClaim: true, ...baseConfig.resolve?.alias,
sourcemap: true ...nodeBuiltins
}
},
build: {
...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: {
...baseConfig.build?.rollupOptions?.output,
globals: nodeBuiltins
}
}
} }
})
]
});
}); });

Loading…
Cancel
Save