forked from trent_larson/crowd-funder-for-time-pwa
Merge branch 'master' into claimview-fullfills-offer
This commit is contained in:
91
src/App.vue
91
src/App.vue
@@ -4,7 +4,7 @@
|
||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
class="fixed z-[120] top-[max(1rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
@@ -28,8 +28,12 @@
|
||||
class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md"
|
||||
>
|
||||
<div class="w-full px-4 py-3 overflow-hidden">
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">{{ notification.title }}</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">{{ notification.text }}</p>
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,9 +50,15 @@
|
||||
></font-awesome>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900 overflow-hidden">
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">{{ notification.title }}</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">{{ notification.text }}</p>
|
||||
<div
|
||||
class="relative w-full pl-4 pr-8 py-2 text-slate-900 overflow-hidden"
|
||||
>
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
|
||||
@@ -72,9 +82,15 @@
|
||||
></font-awesome>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900 overflow-hidden">
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">{{ notification.title }}</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">{{ notification.text }}</p>
|
||||
<div
|
||||
class="relative w-full pl-4 pr-8 py-2 text-emerald-900 overflow-hidden"
|
||||
>
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
|
||||
@@ -98,9 +114,15 @@
|
||||
></font-awesome>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900 overflow-hidden">
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">{{ notification.title }}</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">{{ notification.text }}</p>
|
||||
<div
|
||||
class="relative w-full pl-4 pr-8 py-2 text-amber-900 overflow-hidden"
|
||||
>
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
|
||||
@@ -124,9 +146,15 @@
|
||||
></font-awesome>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900 overflow-hidden">
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">{{ notification.title }}</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">{{ notification.text }}</p>
|
||||
<div
|
||||
class="relative w-full pl-4 pr-8 py-2 text-rose-900 overflow-hidden"
|
||||
>
|
||||
<h4 class="font-semibold text-ellipsis overflow-hidden">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-ellipsis overflow-hidden">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
|
||||
@@ -147,7 +175,9 @@
|
||||
"-permission", "-mute", "-off"
|
||||
-->
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
|
||||
<div
|
||||
class="fixed z-[100] top-[max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))] inset-x-0 w-full"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
@@ -478,13 +508,32 @@ export default class App extends Vue {
|
||||
|
||||
<style>
|
||||
#Content {
|
||||
padding-left: max(1.5rem, env(safe-area-inset-left));
|
||||
padding-right: max(1.5rem, env(safe-area-inset-right));
|
||||
padding-top: max(1.5rem, env(safe-area-inset-top));
|
||||
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
||||
padding-left: max(
|
||||
1.5rem,
|
||||
env(safe-area-inset-left),
|
||||
var(--safe-area-inset-left, 0px)
|
||||
);
|
||||
padding-right: max(
|
||||
1.5rem,
|
||||
env(safe-area-inset-right),
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
padding-top: max(
|
||||
1.5rem,
|
||||
env(safe-area-inset-top),
|
||||
var(--safe-area-inset-top, 0px)
|
||||
);
|
||||
padding-bottom: max(
|
||||
1.5rem,
|
||||
env(safe-area-inset-bottom),
|
||||
var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
#QuickNav ~ #Content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
|
||||
padding-bottom: calc(
|
||||
max(env(safe-area-inset-bottom), var(--safe-area-inset-bottom, 0px)) +
|
||||
6.333rem
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"warning": {
|
||||
"fillRule": "evenodd",
|
||||
"d": "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z",
|
||||
"clipRule": "evenodd"
|
||||
},
|
||||
"spinner": {
|
||||
"d": "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
},
|
||||
"chart": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
},
|
||||
"plus": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 4v16m8-8H4"
|
||||
},
|
||||
"settings": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
},
|
||||
"settingsDot": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
},
|
||||
"lock": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
},
|
||||
"download": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
},
|
||||
"check": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
},
|
||||
"edit": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
},
|
||||
"trash": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
},
|
||||
"plusCircle": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
},
|
||||
"info": {
|
||||
"fillRule": "evenodd",
|
||||
"d": "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",
|
||||
"clipRule": "evenodd"
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,12 @@
|
||||
transform: translateX(100%);
|
||||
background-color: #FFF !important;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
@apply z-[100] fixed inset-0 bg-black/50 flex justify-center items-center p-6;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply bg-white p-4 rounded-lg w-full max-w-lg;
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ import { GiveRecordWithContactInfo } from "@/interfaces/give";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { createNotifyHelpers } from "@/utils/notify";
|
||||
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
|
||||
import {
|
||||
NOTIFY_PERSON_HIDDEN,
|
||||
NOTIFY_UNKNOWN_PERSON,
|
||||
@@ -273,7 +273,7 @@ export default class ActivityListItem extends Vue {
|
||||
|
||||
isHiddenDid = isHiddenDid;
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
$notify!: (notification: any, timeout?: number) => void;
|
||||
$notify!: NotifyFunction;
|
||||
|
||||
created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
@@ -288,8 +288,7 @@ export default class ActivityListItem extends Vue {
|
||||
}
|
||||
|
||||
get fetchAmount(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as any)?.claim || this.record.fullClaim;
|
||||
const claim = this.record.fullClaim;
|
||||
|
||||
const amount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
@@ -299,8 +298,7 @@ export default class ActivityListItem extends Vue {
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as any)?.claim || this.record.fullClaim;
|
||||
const claim = this.record.fullClaim;
|
||||
|
||||
return `${claim?.description || ""}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- similar to UserNameDialog -->
|
||||
<template>
|
||||
<div v-if="visible" :class="overlayClasses">
|
||||
<div :class="dialogClasses">
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 :class="titleClasses">{{ title }}</h1>
|
||||
{{ message }}
|
||||
Note that their name is only stored on this device.
|
||||
@@ -61,20 +61,6 @@ export default class ContactNameDialog extends Vue {
|
||||
title = "Contact Name";
|
||||
visible = false;
|
||||
|
||||
/**
|
||||
* CSS classes for the modal overlay backdrop
|
||||
*/
|
||||
get overlayClasses(): string {
|
||||
return "z-index-50 fixed top-0 left-0 right-0 bottom-0 bg-black/50 flex justify-center items-center p-6";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS classes for the modal dialog container
|
||||
*/
|
||||
get dialogClasses(): string {
|
||||
return "bg-white p-4 rounded-lg w-full max-w-[500px]";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS classes for the dialog title
|
||||
*/
|
||||
|
||||
@@ -171,6 +171,8 @@ export default class DataExportSection extends Vue {
|
||||
* @throws {Error} If export fails
|
||||
*/
|
||||
public async exportDatabase(): Promise<void> {
|
||||
// Note that similar code is in ContactsView.vue exportContactData()
|
||||
|
||||
if (this.isExporting) {
|
||||
return; // Prevent multiple simultaneous exports
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
<!-- "Unnamed" entity -->
|
||||
<SpecialEntityCard
|
||||
entity-type="unnamed"
|
||||
label="Unnamed"
|
||||
:label="unnamedEntityName"
|
||||
icon="circle-question"
|
||||
:entity-data="unnamedEntityData"
|
||||
:notify="notify"
|
||||
@@ -83,6 +83,7 @@ import ShowAllCard from "./ShowAllCard.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* EntityGrid - Unified grid layout for displaying people or projects
|
||||
@@ -159,6 +160,10 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ default: "other party" })
|
||||
conflictContext!: string;
|
||||
|
||||
/** Whether to hide the "Show All" navigation */
|
||||
@Prop({ default: false })
|
||||
hideShowAll!: boolean;
|
||||
|
||||
/**
|
||||
* Function to determine which entities to display (allows parent control)
|
||||
*
|
||||
@@ -245,7 +250,9 @@ export default class EntityGrid extends Vue {
|
||||
* Whether to show the "Show All" navigation
|
||||
*/
|
||||
get shouldShowAll(): boolean {
|
||||
return this.entities.length > 0 && this.showAllRoute !== "";
|
||||
return (
|
||||
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,10 +278,17 @@ export default class EntityGrid extends Vue {
|
||||
get unnamedEntityData(): { did: string; name: string } {
|
||||
return {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
name: UNNAMED_ENTITY_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unnamed entity name constant
|
||||
*/
|
||||
get unnamedEntityName(): string {
|
||||
return UNNAMED_ENTITY_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a person DID is conflicted
|
||||
*/
|
||||
@@ -304,16 +318,13 @@ export default class EntityGrid extends Vue {
|
||||
|
||||
/**
|
||||
* Handle special entity selection from SpecialEntityCard
|
||||
* Treat "You" and "Unnamed" as person entities
|
||||
*/
|
||||
handleEntitySelected(event: {
|
||||
type: string;
|
||||
entityType: string;
|
||||
data: { did?: string; name: string };
|
||||
}): void {
|
||||
handleEntitySelected(event: { data: { did?: string; name: string } }): void {
|
||||
// Convert special entities to person entities since they represent people
|
||||
this.emitEntitySelected({
|
||||
type: "special",
|
||||
entityType: event.entityType,
|
||||
data: event.data,
|
||||
type: "person",
|
||||
data: event.data as Contact,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -321,13 +332,11 @@ export default class EntityGrid extends Vue {
|
||||
|
||||
@Emit("entity-selected")
|
||||
emitEntitySelected(data: {
|
||||
type: "person" | "project" | "special";
|
||||
entityType?: string;
|
||||
data: Contact | PlanData | { did?: string; name: string };
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
}): {
|
||||
type: "person" | "project" | "special";
|
||||
entityType?: string;
|
||||
data: Contact | PlanData | { did?: string; name: string };
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
} {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ Matthew Raymer */
|
||||
:show-all-query-params="showAllQueryParams"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:hide-show-all="hideShowAll"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
@@ -55,9 +56,8 @@ interface EntityData {
|
||||
* Entity selection event data structure
|
||||
*/
|
||||
interface EntitySelectionEvent {
|
||||
type: "person" | "project" | "special";
|
||||
entityType?: string;
|
||||
data: Contact | PlanData | EntityData;
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
|
||||
@Prop()
|
||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/** Whether to hide the "Show All" navigation */
|
||||
@Prop({ default: false })
|
||||
hideShowAll!: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes for the cancel button
|
||||
*/
|
||||
|
||||
@@ -42,8 +42,8 @@ computed CSS properties * * @author Matthew Raymer */
|
||||
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
|
||||
{{ label }}
|
||||
</p>
|
||||
<h3 class="font-semibold truncate">
|
||||
{{ entity?.name || "Unnamed" }}
|
||||
<h3 :class="nameClasses">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* Entity interface for both person and project entities
|
||||
@@ -138,6 +139,38 @@ export default class EntitySummaryButton extends Vue {
|
||||
return this.editable ? "text-blue-500" : "text-slate-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the entity name
|
||||
*/
|
||||
get nameClasses(): string {
|
||||
const baseClasses = "font-semibold truncate";
|
||||
|
||||
// Add italic styling for special "Unnamed" or entities without set names
|
||||
if (!this.entity?.name || this.entity?.did === "") {
|
||||
return `${baseClasses} italic text-slate-500`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed display name for the entity
|
||||
*/
|
||||
get displayName(): string {
|
||||
// If the entity has a set name, use that name
|
||||
if (this.entity?.name) {
|
||||
return this.entity.name;
|
||||
}
|
||||
|
||||
// If the entity is the special "Unnamed", use "Unnamed"
|
||||
if (this.entity?.did === "") {
|
||||
return UNNAMED_ENTITY_NAME;
|
||||
}
|
||||
|
||||
// If the entity does not have a set name, but is not the special "Unnamed", use their DID
|
||||
return this.entity?.did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click event - only call function prop if editable
|
||||
* Allows parent to control edit behavior and validation
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -119,11 +120,13 @@ export default class FeedFilters extends Vue {
|
||||
isNearby = false;
|
||||
settingChanged = false;
|
||||
visible = false;
|
||||
activeDid = "";
|
||||
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
async open(onCloseIfChanged: () => void, activeDid: string) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
this.activeDid = activeDid;
|
||||
|
||||
const settings = await this.$settings();
|
||||
const settings = await this.$accountSettings(activeDid);
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible;
|
||||
this.isNearby = !!settings.filterFeedByNearby;
|
||||
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
@@ -137,6 +140,7 @@ export default class FeedFilters extends Vue {
|
||||
async toggleHasVisibleDid() {
|
||||
this.settingChanged = true;
|
||||
this.hasVisibleDid = !this.hasVisibleDid;
|
||||
|
||||
await this.$updateSettings({
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
@@ -145,9 +149,18 @@ export default class FeedFilters extends Vue {
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
|
||||
logger.debug("[FeedFilters] 🔄 Toggling nearby filter:", {
|
||||
newValue: this.isNearby,
|
||||
settingChanged: this.settingChanged,
|
||||
activeDid: this.activeDid,
|
||||
});
|
||||
|
||||
await this.$updateSettings({
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
|
||||
logger.debug("[FeedFilters] ✅ Nearby filter updated in settings");
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
@@ -179,43 +192,27 @@ export default class FeedFilters extends Vue {
|
||||
}
|
||||
|
||||
close() {
|
||||
logger.debug("[FeedFilters] 🚪 Closing dialog:", {
|
||||
settingChanged: this.settingChanged,
|
||||
hasCallback: !!this.onCloseIfChanged,
|
||||
});
|
||||
|
||||
if (this.settingChanged) {
|
||||
logger.debug("[FeedFilters] 🔄 Settings changed, calling callback");
|
||||
this.onCloseIfChanged();
|
||||
}
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
done() {
|
||||
logger.debug("[FeedFilters] ✅ Done button clicked");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#dialogFeedFilters.dialog-overlay {
|
||||
z-index: 100;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
:unit-code="unitCode"
|
||||
:offer-id="offerId"
|
||||
:notify="$notify"
|
||||
:hide-show-all="hideShowAll"
|
||||
@entity-selected="handleEntitySelected"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
@@ -80,13 +81,14 @@ import EntitySelectionStep from "../components/EntitySelectionStep.vue";
|
||||
import GiftDetailsStep from "../components/GiftDetailsStep.vue";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
|
||||
import {
|
||||
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
|
||||
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
|
||||
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
|
||||
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
|
||||
} from "@/constants/notifications";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -98,7 +100,7 @@ import {
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class GiftedDialog extends Vue {
|
||||
$notify!: (notification: any, timeout?: number) => void;
|
||||
$notify!: NotifyFunction;
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
/**
|
||||
@@ -114,6 +116,7 @@ export default class GiftedDialog extends Vue {
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop() isFromProjectView = false;
|
||||
@Prop() hideShowAll = false;
|
||||
@Prop({ default: "person" }) giverEntityType = "person" as
|
||||
| "person"
|
||||
| "project";
|
||||
@@ -224,15 +227,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
if (this.giver && !this.giver.name) {
|
||||
this.giver.name = didInfo(
|
||||
this.giver.did,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.giverEntityType === "project" ||
|
||||
this.recipientEntityType === "project"
|
||||
@@ -455,14 +449,14 @@ export default class GiftedDialog extends Vue {
|
||||
if (contact) {
|
||||
this.giver = {
|
||||
did: contact.did,
|
||||
name: contact.name || contact.did,
|
||||
name: contact.name,
|
||||
};
|
||||
} else {
|
||||
// Only set to "Unnamed" if no giver is currently set
|
||||
if (!this.giver || !this.giver.did) {
|
||||
this.giver = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
name: UNNAMED_ENTITY_NAME,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -517,14 +511,14 @@ export default class GiftedDialog extends Vue {
|
||||
if (contact) {
|
||||
this.receiver = {
|
||||
did: contact.did,
|
||||
name: contact.name || contact.did,
|
||||
name: contact.name,
|
||||
};
|
||||
} else {
|
||||
// Only set to "Unnamed" if no receiver is currently set
|
||||
if (!this.receiver || !this.receiver.did) {
|
||||
this.receiver = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
name: UNNAMED_ENTITY_NAME,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -566,20 +560,21 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
/**
|
||||
* Handle entity selection from EntitySelectionStep
|
||||
* @param entity - The selected entity (person, project, or special) with stepType
|
||||
* @param entity - The selected entity (person or project) with stepType
|
||||
*/
|
||||
handleEntitySelected(entity: {
|
||||
type: "person" | "project" | "special";
|
||||
entityType?: string;
|
||||
data: Contact | PlanData | { did?: string; name: string };
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
stepType: string;
|
||||
}) {
|
||||
if (entity.type === "person") {
|
||||
const contact = entity.data as Contact;
|
||||
// Apply DID-based logic for person entities
|
||||
const processedContact = this.processPersonEntity(contact);
|
||||
if (entity.stepType === "giver") {
|
||||
this.selectGiver(contact);
|
||||
this.selectGiver(processedContact);
|
||||
} else {
|
||||
this.selectRecipient(contact);
|
||||
this.selectRecipient(processedContact);
|
||||
}
|
||||
} else if (entity.type === "project") {
|
||||
const project = entity.data as PlanData;
|
||||
@@ -588,33 +583,22 @@ export default class GiftedDialog extends Vue {
|
||||
} else {
|
||||
this.selectRecipientProject(project);
|
||||
}
|
||||
} else if (entity.type === "special") {
|
||||
// Handle special entities like "You" and "Unnamed"
|
||||
if (entity.entityType === "you") {
|
||||
// "You" entity selected
|
||||
const youEntity = {
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
};
|
||||
if (entity.stepType === "giver") {
|
||||
this.giver = youEntity;
|
||||
} else {
|
||||
this.receiver = youEntity;
|
||||
}
|
||||
this.firstStep = false;
|
||||
} else if (entity.entityType === "unnamed") {
|
||||
// "Unnamed" entity selected
|
||||
const unnamedEntity = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
};
|
||||
if (entity.stepType === "giver") {
|
||||
this.giver = unnamedEntity;
|
||||
} else {
|
||||
this.receiver = unnamedEntity;
|
||||
}
|
||||
this.firstStep = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes person entities using DID-based logic for "You" and "Unnamed"
|
||||
*/
|
||||
private processPersonEntity(contact: Contact): Contact {
|
||||
if (contact.did === this.activeDid) {
|
||||
// If DID matches active DID, create "You" entity
|
||||
return { ...contact, name: "You" };
|
||||
} else if (!contact.did || contact.did === "") {
|
||||
// If DID is empty/null, create "Unnamed" entity
|
||||
return { ...contact, name: UNNAMED_ENTITY_NAME };
|
||||
} else {
|
||||
// Return the contact as-is
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +606,10 @@ export default class GiftedDialog extends Vue {
|
||||
* Handle edit entity request from GiftDetailsStep
|
||||
* @param data - Object containing entityType and currentEntity
|
||||
*/
|
||||
handleEditEntity(data: { entityType: string; currentEntity: any }) {
|
||||
handleEditEntity(data: {
|
||||
entityType: string;
|
||||
currentEntity: { did: string; name: string };
|
||||
}) {
|
||||
this.goBackToStep1(data.entityType);
|
||||
}
|
||||
|
||||
@@ -662,27 +649,3 @@ export default class GiftedDialog extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -291,27 +291,3 @@ export default class GivenPrompts extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||
<div v-if="isOpen" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
v-if="iconData"
|
||||
:class="svgClass"
|
||||
:fill="fill"
|
||||
:stroke="stroke"
|
||||
:viewBox="viewBox"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path v-for="(path, index) in iconData.paths" :key="index" v-bind="path" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import icons from "../assets/icons.json";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Icon path interface
|
||||
*/
|
||||
interface IconPath {
|
||||
d: string;
|
||||
fillRule?: string;
|
||||
clipRule?: string;
|
||||
strokeLinecap?: string;
|
||||
strokeLinejoin?: string;
|
||||
strokeWidth?: string | number;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon data interface
|
||||
*/
|
||||
interface IconData {
|
||||
paths: IconPath[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Icons JSON structure
|
||||
*/
|
||||
interface IconsJson {
|
||||
[key: string]: IconPath | IconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon Renderer Component
|
||||
*
|
||||
* This component loads SVG icon definitions from a JSON file and renders them
|
||||
* as SVG elements. It provides a clean way to use icons without cluttering
|
||||
* templates with long SVG path definitions.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
* @since 2024
|
||||
*/
|
||||
@Component({
|
||||
name: "IconRenderer",
|
||||
})
|
||||
export default class IconRenderer extends Vue {
|
||||
@Prop({ required: true }) readonly iconName!: string;
|
||||
@Prop({ default: "h-5 w-5" }) readonly svgClass!: string;
|
||||
@Prop({ default: "none" }) readonly fill!: string;
|
||||
@Prop({ default: "currentColor" }) readonly stroke!: string;
|
||||
@Prop({ default: "0 0 24 24" }) readonly viewBox!: string;
|
||||
|
||||
/**
|
||||
* Get the icon data for the specified icon name
|
||||
*
|
||||
* @returns {IconData | null} The icon data object or null if not found
|
||||
*/
|
||||
get iconData(): IconData | null {
|
||||
const icon = (icons as IconsJson)[this.iconName];
|
||||
if (!icon) {
|
||||
logger.warn(`Icon "${this.iconName}" not found in icons.json`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert single path to array format for consistency
|
||||
if ("d" in icon) {
|
||||
return {
|
||||
paths: [icon as IconPath],
|
||||
};
|
||||
}
|
||||
|
||||
return icon as IconData;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
@@ -282,7 +282,7 @@ import {
|
||||
NOTIFY_IMAGE_DIALOG_UNSUPPORTED_FORMAT,
|
||||
createImageDialogCameraErrorMessage,
|
||||
} from "../constants/notifications";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
||||
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "../utils/notify";
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
|
||||
@@ -291,7 +291,7 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: (notification: any, timeout?: number) => void;
|
||||
$notify!: NotifyFunction;
|
||||
$router!: Router;
|
||||
notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
@@ -931,32 +931,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Add styles for diagnostic panel */
|
||||
.diagnostic-panel {
|
||||
font-family: monospace;
|
||||
|
||||
@@ -93,27 +93,3 @@ export default class InviteDialog extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
:weight="2"
|
||||
color="#3b82f6"
|
||||
fill-color="#3b82f6"
|
||||
fill-opacity="0.2"
|
||||
:fill-opacity="0.2"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ member.name || "Unnamed Member" }}
|
||||
{{ member.name || unnamedMember }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!getContactFor(member.did) && member.did !== activeDid"
|
||||
@@ -177,6 +177,7 @@ import {
|
||||
NOTIFY_ADD_CONTACT_FIRST,
|
||||
NOTIFY_CONTINUE_WITHOUT_ADDING,
|
||||
} from "@/constants/notifications";
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities";
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
@@ -220,6 +221,13 @@ export default class MembersList extends Vue {
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
|
||||
/**
|
||||
* Get the unnamed member constant
|
||||
*/
|
||||
get unnamedMember(): string {
|
||||
return SOMEONE_UNNAMED;
|
||||
}
|
||||
|
||||
async created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
|
||||
@@ -312,28 +312,3 @@ export default class OfferDialog extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -307,27 +307,3 @@ export default class OnboardingDialog extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 40;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@ conflict detection. * * @author Matthew Raymer */
|
||||
</div>
|
||||
|
||||
<h3 :class="nameClasses">
|
||||
{{ person.name || person.did || "Unnamed" }}
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
</li>
|
||||
</template>
|
||||
@@ -98,9 +98,27 @@ export default class PersonCard extends Vue {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
}
|
||||
|
||||
// Add italic styling for entities without set names
|
||||
if (!this.person.name) {
|
||||
return `${baseClasses} italic text-slate-500`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed display name for the person
|
||||
*/
|
||||
get displayName(): string {
|
||||
// If the entity has a set name, use that name
|
||||
if (this.person.name) {
|
||||
return this.person.name;
|
||||
}
|
||||
|
||||
// If the entity does not have a set name
|
||||
return this.person.did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle card click - emit if selectable and not conflicted, show warning if conflicted
|
||||
*/
|
||||
@@ -114,7 +132,7 @@ export default class PersonCard extends Vue {
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Cannot Select",
|
||||
text: `You cannot select "${this.person.name || this.person.did || "Unnamed"}" because they are already selected as the ${this.conflictContext}.`,
|
||||
text: `You cannot select "${this.displayName}" because they are already selected as the ${this.conflictContext}.`,
|
||||
},
|
||||
3000,
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
|
||||
PhotoDialog.vue */
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div id="ViewHeading" :class="headingClasses">
|
||||
@@ -628,34 +628,6 @@ export default class PhotoDialog extends Vue {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Dialog overlay styling */
|
||||
.dialog-overlay {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Dialog container styling */
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Camera preview styling */
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
|
||||
@@ -15,7 +15,7 @@ issuer information. * * @author Matthew Raymer */
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ project.name || "Unnamed Project" }}
|
||||
{{ project.name || unnamedProject }}
|
||||
</h3>
|
||||
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
@@ -31,6 +31,7 @@ import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { didInfo } from "../libs/endorserServer";
|
||||
import { UNNAMED_PROJECT } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* ProjectCard - Displays a project entity with selection capability
|
||||
@@ -63,6 +64,13 @@ export default class ProjectCard extends Vue {
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
|
||||
/**
|
||||
* Get the unnamed project constant
|
||||
*/
|
||||
get unnamedProject(): string {
|
||||
return UNNAMED_PROJECT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed display name for the project issuer
|
||||
*/
|
||||
|
||||
@@ -115,6 +115,7 @@ import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
// Example interface for error
|
||||
interface ErrorResponse {
|
||||
@@ -602,7 +603,7 @@ export default class PushNotificationPermission extends Vue {
|
||||
* Returns the default message for direct push
|
||||
*/
|
||||
get notificationMessagePlaceholder(): string {
|
||||
return "Click to share some gratitude with the world -- even if they're unnamed.";
|
||||
return `Click to share some gratitude with the world -- even if they're ${UNNAMED_ENTITY_NAME.toLowerCase()}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- QUICK NAV -->
|
||||
<nav
|
||||
id="QuickNav"
|
||||
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
|
||||
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px))]"
|
||||
>
|
||||
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
|
||||
<!-- Home Feed -->
|
||||
|
||||
@@ -1,33 +1,154 @@
|
||||
/** * @file RegistrationNotice.vue * @description Reusable component for
|
||||
displaying user registration status and related actions. * Shows registration
|
||||
notice when user is not registered, with options to show identifier info * or
|
||||
access advanced options. * * @author Jose Olarte III * @version 1.0.0 * @created
|
||||
2025-08-21T17:25:28-08:00 */
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isRegistered && show"
|
||||
id="noticeBeforeAnnounce"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
id="noticeSomeoneMustRegisterYou"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4"
|
||||
>
|
||||
<p class="mb-4">
|
||||
Before you can publicly announce a new project or time commitment, a
|
||||
friend needs to register you.
|
||||
</p>
|
||||
<button
|
||||
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
@click="shareInfo"
|
||||
>
|
||||
Share Your Info
|
||||
</button>
|
||||
<p class="mb-4">{{ message }}</p>
|
||||
<div class="grid grid-cols-1 gap-2 sm:flex sm:justify-center">
|
||||
<button
|
||||
class="inline-block text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
@click="showNameThenIdDialog"
|
||||
>
|
||||
Show them {{ passkeysEnabled ? "default" : "your" }} identifier info
|
||||
</button>
|
||||
<button
|
||||
class="inline-block text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
@click="openAdvancedOptions"
|
||||
>
|
||||
See advanced options
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import UserNameDialog from "./UserNameDialog.vue";
|
||||
import ChoiceButtonDialog from "./ChoiceButtonDialog.vue";
|
||||
|
||||
@Component({ name: "RegistrationNotice" })
|
||||
/**
|
||||
* RegistrationNotice Component
|
||||
*
|
||||
* Displays registration status notice and provides actions for unregistered users.
|
||||
* Handles all registration-related flows internally without requiring parent component intervention.
|
||||
*
|
||||
* Template Usage:
|
||||
* ```vue
|
||||
* <RegistrationNotice
|
||||
* v-if="!isUserRegistered"
|
||||
* :passkeys-enabled="PASSKEYS_ENABLED"
|
||||
* :given-name="givenName"
|
||||
* message="Custom registration message here"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* Component Dependencies:
|
||||
* - UserNameDialog: Dialog for entering user name
|
||||
* - ChoiceButtonDialog: Dialog for sharing method selection
|
||||
*/
|
||||
@Component({
|
||||
name: "RegistrationNotice",
|
||||
components: {
|
||||
UserNameDialog,
|
||||
ChoiceButtonDialog,
|
||||
},
|
||||
})
|
||||
export default class RegistrationNotice extends Vue {
|
||||
@Prop({ required: true }) isRegistered!: boolean;
|
||||
@Prop({ required: true }) show!: boolean;
|
||||
$router!: Router;
|
||||
|
||||
@Emit("share-info")
|
||||
shareInfo() {}
|
||||
/**
|
||||
* Whether passkeys are enabled in the application
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
passkeysEnabled!: boolean;
|
||||
|
||||
/**
|
||||
* User's given name for dialog pre-population
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
givenName!: string;
|
||||
|
||||
/**
|
||||
* Custom message to display in the registration notice
|
||||
* Defaults to "To share, someone must register you."
|
||||
*/
|
||||
@Prop({ default: "To share, someone must register you." })
|
||||
message!: string;
|
||||
|
||||
/**
|
||||
* Shows name input dialog if needed
|
||||
* Handles the full flow internally without requiring parent component intervention
|
||||
*/
|
||||
showNameThenIdDialog() {
|
||||
this.openUserNameDialog(() => {
|
||||
this.promptForShareMethod();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens advanced options page
|
||||
* Navigates directly to the start page
|
||||
*/
|
||||
openAdvancedOptions() {
|
||||
this.$router.push({ name: "start" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows dialog for sharing method selection
|
||||
* Provides options for different sharing scenarios
|
||||
*/
|
||||
promptForShareMethod() {
|
||||
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
||||
title: "How can you share your info?",
|
||||
text: "",
|
||||
option1Text: "We are nearby with cameras",
|
||||
option2Text: "Someone created a meeting room",
|
||||
option3Text: "We will share some other way",
|
||||
onOption1: () => {
|
||||
this.handleQRCodeClick();
|
||||
},
|
||||
onOption2: () => {
|
||||
this.$router.push({ name: "onboard-meeting-list" });
|
||||
},
|
||||
onOption3: () => {
|
||||
this.$router.push({ name: "share-my-contact-info" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles QR code sharing based on platform
|
||||
* Navigates to appropriate QR code page
|
||||
*/
|
||||
private handleQRCodeClick() {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the user name dialog if needed
|
||||
*
|
||||
* @param callback Function to call after name is entered
|
||||
*/
|
||||
openUserNameDialog(callback: () => void) {
|
||||
if (!this.givenName) {
|
||||
(this.$refs.userNameDialog as UserNameDialog).open(callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,8 +124,6 @@ export default class SpecialEntityCard extends Vue {
|
||||
handleClick(): void {
|
||||
if (this.selectable && !this.conflicted) {
|
||||
this.emitEntitySelected({
|
||||
type: "special",
|
||||
entityType: this.entityType,
|
||||
data: this.entityData,
|
||||
});
|
||||
} else if (this.conflicted && this.notify) {
|
||||
@@ -145,13 +143,7 @@ export default class SpecialEntityCard extends Vue {
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("entity-selected")
|
||||
emitEntitySelected(data: {
|
||||
type: string;
|
||||
entityType: string;
|
||||
data: { did?: string; name: string };
|
||||
}): {
|
||||
type: string;
|
||||
entityType: string;
|
||||
emitEntitySelected(data: { data: { did?: string; name: string } }): {
|
||||
data: { did?: string; name: string };
|
||||
} {
|
||||
return data;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
|
||||
<div
|
||||
class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))]"
|
||||
>
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
@@ -18,6 +20,7 @@ import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
mixins: [PlatformServiceMixin],
|
||||
@@ -42,26 +45,49 @@ export default class TopMessage extends Vue {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
try {
|
||||
// Ultra-concise cached settings loading - replaces 50+ lines of logic!
|
||||
const settings = await this.$accountSettings(undefined, {
|
||||
activeDid: undefined,
|
||||
apiServer: AppString.PROD_ENDORSER_API_SERVER,
|
||||
// Load settings without overriding database values - fixes settings inconsistency
|
||||
logger.debug("[TopMessage] 📥 Loading settings without overrides...");
|
||||
const settings = await this.$accountSettings();
|
||||
|
||||
logger.debug("[TopMessage] 📊 Settings loaded:", {
|
||||
activeDid: settings.activeDid,
|
||||
apiServer: settings.apiServer,
|
||||
warnIfTestServer: settings.warnIfTestServer,
|
||||
warnIfProdServer: settings.warnIfProdServer,
|
||||
component: "TopMessage",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Only show warnings if the user has explicitly enabled them
|
||||
if (
|
||||
settings.warnIfTestServer &&
|
||||
settings.apiServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're not using prod, user " + didPrefix;
|
||||
logger.debug("[TopMessage] ⚠️ Test server warning displayed:", {
|
||||
apiServer: settings.apiServer,
|
||||
didPrefix: didPrefix,
|
||||
});
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You are using prod, user " + didPrefix;
|
||||
logger.debug("[TopMessage] ⚠️ Production server warning displayed:", {
|
||||
apiServer: settings.apiServer,
|
||||
didPrefix: didPrefix,
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
"[TopMessage] ℹ️ No warnings displayed - conditions not met",
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error("[TopMessage] ❌ Error loading settings:", err);
|
||||
this.notify.error(JSON.stringify(err), TIMEOUTS.MODAL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { EndorserRateLimits, ImageRateLimits } from "@/interfaces/limits";
|
||||
|
||||
@Component({
|
||||
name: "UsageLimitsSection",
|
||||
@@ -94,8 +95,8 @@ export default class UsageLimitsSection extends Vue {
|
||||
@Prop({ required: true }) loadingLimits!: boolean;
|
||||
@Prop({ required: true }) limitsMessage!: string;
|
||||
@Prop({ required: false }) activeDid?: string;
|
||||
@Prop({ required: false }) endorserLimits?: any;
|
||||
@Prop({ required: false }) imageLimits?: any;
|
||||
@Prop({ required: false }) endorserLimits?: EndorserRateLimits;
|
||||
@Prop({ required: false }) imageLimits?: ImageRateLimits;
|
||||
@Prop({ required: true }) onRecheckLimits!: () => void;
|
||||
|
||||
mounted() {
|
||||
|
||||
@@ -84,7 +84,8 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async open(aCallback?: (name?: string) => void) {
|
||||
this.callback = aCallback || this.callback;
|
||||
const settings = await this.$settings();
|
||||
// Load from account-specific settings instead of master settings
|
||||
const settings = await this.$accountSettings();
|
||||
this.givenName = settings.firstName || "";
|
||||
this.visible = true;
|
||||
}
|
||||
@@ -95,7 +96,18 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async onClickSaveChanges() {
|
||||
try {
|
||||
await this.$updateSettings({ firstName: this.givenName });
|
||||
// Get the current active DID to save to user-specific settings
|
||||
const settings = await this.$accountSettings();
|
||||
const activeDid = settings.activeDid;
|
||||
|
||||
if (activeDid) {
|
||||
// Save to user-specific settings for the current identity
|
||||
await this.$saveUserSettings(activeDid, { firstName: this.givenName });
|
||||
} else {
|
||||
// Fallback to master settings if no active DID
|
||||
await this.$saveSettings({ firstName: this.givenName });
|
||||
}
|
||||
|
||||
this.visible = false;
|
||||
this.callback(this.givenName);
|
||||
} catch (error) {
|
||||
@@ -134,27 +146,3 @@ export default class UserNameDialog extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
14
src/constants/entities.ts
Normal file
14
src/constants/entities.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Constants for entity-related strings, particularly for unnamed/unknown person entities
|
||||
*/
|
||||
|
||||
// Core unnamed entity names
|
||||
export const UNNAMED_ENTITY_NAME = "Unnamed";
|
||||
|
||||
// Descriptive phrases for unnamed entities
|
||||
export const SOMEONE_UNNAMED = "Someone Unnamed";
|
||||
export const THAT_UNNAMED_PERSON = "That unnamed person";
|
||||
export const UNNAMED_PERSON = "unnamed person";
|
||||
|
||||
// Project-related unnamed entities
|
||||
export const UNNAMED_PROJECT = "Unnamed Project";
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { THAT_UNNAMED_PERSON } from "./entities";
|
||||
|
||||
// Notification message constants for user-facing notifications
|
||||
// Add new notification messages here as needed
|
||||
@@ -846,6 +847,12 @@ export const NOTIFY_CONTACTS_ADDED = {
|
||||
message: "They were added.",
|
||||
};
|
||||
|
||||
// Used in: ContactsView.vue (addContact method - export data prompt after contact addition)
|
||||
export const NOTIFY_EXPORT_DATA_PROMPT = {
|
||||
title: "Export Your Data",
|
||||
message: "Would you like to export your contact data as a backup?",
|
||||
};
|
||||
|
||||
// Used in: ContactsView.vue (showCopySelectionsInfo method - info about copying contacts)
|
||||
export const NOTIFY_CONTACT_INFO_COPY = {
|
||||
title: "Info",
|
||||
@@ -867,7 +874,7 @@ export const NOTIFY_CONTACT_LINK_COPIED = {
|
||||
// Template for registration success message
|
||||
// Used in: ContactsView.vue (register method - registration success with contact name)
|
||||
export const getRegisterPersonSuccessMessage = (name?: string): string =>
|
||||
`${name || "That unnamed person"} ${NOTIFY_REGISTER_PERSON_SUCCESS.message}`;
|
||||
`${name || THAT_UNNAMED_PERSON} ${NOTIFY_REGISTER_PERSON_SUCCESS.message}`;
|
||||
|
||||
// Template for visibility success message
|
||||
// Used in: ContactsView.vue (setVisibility method - visibility success with contact name)
|
||||
@@ -1372,7 +1379,7 @@ export function createQRContactAddedMessage(hasVisibility: boolean): string {
|
||||
export function createQRRegistrationSuccessMessage(
|
||||
contactName: string,
|
||||
): string {
|
||||
return `${contactName || "That unnamed person"}${NOTIFY_QR_REGISTRATION_SUCCESS.message}`;
|
||||
return `${contactName || THAT_UNNAMED_PERSON}${NOTIFY_QR_REGISTRATION_SUCCESS.message}`;
|
||||
}
|
||||
|
||||
// ContactQRScanShowView.vue timeout constants
|
||||
@@ -1588,7 +1595,7 @@ export function createImageDialogCameraErrorMessage(error: Error): string {
|
||||
|
||||
// Helper function for dynamic upload error messages
|
||||
// Used in: ImageMethodDialog.vue (uploadImage method - dynamic upload error message)
|
||||
export function createImageDialogUploadErrorMessage(error: any): string {
|
||||
export function createImageDialogUploadErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
@@ -1682,3 +1689,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
|
||||
title: "They're Added To Your List",
|
||||
message: "Would you like to go to the main page now?",
|
||||
};
|
||||
|
||||
// ImportAccountView.vue specific constants
|
||||
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
|
||||
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
|
||||
title: "Account Already Imported",
|
||||
message:
|
||||
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
|
||||
};
|
||||
|
||||
@@ -60,9 +60,13 @@ export interface AxiosErrorResponse {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
config?: unknown;
|
||||
};
|
||||
config?: unknown;
|
||||
config?: {
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -98,3 +102,81 @@ export interface VerifiableCredentialClaim {
|
||||
credentialSubject: ClaimObject;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database constraint error types for consistent error handling
|
||||
*/
|
||||
export interface DatabaseConstraintError extends Error {
|
||||
name: "ConstraintError";
|
||||
message: string;
|
||||
constraint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database storage error types for IndexedDB/SQLite operations
|
||||
*/
|
||||
export interface DatabaseStorageError extends Error {
|
||||
name: "StorageError";
|
||||
message: string;
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Dexie error types for migration compatibility
|
||||
*/
|
||||
export interface DexieError extends Error {
|
||||
name: string;
|
||||
message: string;
|
||||
inner?: unknown;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for database constraint errors
|
||||
*/
|
||||
export function isDatabaseConstraintError(
|
||||
error: unknown,
|
||||
): error is DatabaseConstraintError {
|
||||
return error instanceof Error && error.name === "ConstraintError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for database storage errors
|
||||
*/
|
||||
export function isDatabaseStorageError(
|
||||
error: unknown,
|
||||
): error is DatabaseStorageError {
|
||||
return error instanceof Error && error.name === "StorageError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for legacy Dexie errors
|
||||
*/
|
||||
export function isDexieError(error: unknown): error is DexieError {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === "DexieError" ||
|
||||
error.message.includes("Key already exists in the object store") ||
|
||||
error.message.includes("ConstraintError"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified error type for database operations
|
||||
*/
|
||||
export type DatabaseError =
|
||||
| DatabaseConstraintError
|
||||
| DatabaseStorageError
|
||||
| DexieError;
|
||||
|
||||
/**
|
||||
* Type guard for any database error
|
||||
*/
|
||||
export function isDatabaseError(error: unknown): error is DatabaseError {
|
||||
return (
|
||||
isDatabaseConstraintError(error) ||
|
||||
isDatabaseStorageError(error) ||
|
||||
isDexieError(error)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Parameter validation schemas for each route type
|
||||
export const deepLinkSchemas = {
|
||||
export const deepLinkPathSchemas = {
|
||||
claim: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
@@ -60,7 +60,7 @@ export const deepLinkSchemas = {
|
||||
jwt: z.string().optional(),
|
||||
}),
|
||||
"onboard-meeting-members": z.object({
|
||||
id: z.string(),
|
||||
groupId: z.string(),
|
||||
}),
|
||||
project: z.object({
|
||||
id: z.string(),
|
||||
@@ -70,6 +70,17 @@ export const deepLinkSchemas = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const deepLinkQuerySchemas = {
|
||||
"onboard-meeting-members": z.object({
|
||||
password: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = Object.keys(
|
||||
deepLinkPathSchemas,
|
||||
) as readonly (keyof typeof deepLinkPathSchemas)[];
|
||||
|
||||
// Create a type from the array
|
||||
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
|
||||
|
||||
@@ -80,14 +91,13 @@ export const baseUrlSchema = z.object({
|
||||
queryParams: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = Object.keys(
|
||||
deepLinkSchemas,
|
||||
) as readonly (keyof typeof deepLinkSchemas)[];
|
||||
// export type DeepLinkPathParams = {
|
||||
// [K in keyof typeof deepLinkPathSchemas]: z.infer<(typeof deepLinkPathSchemas)[K]>;
|
||||
// };
|
||||
|
||||
export type DeepLinkParams = {
|
||||
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
|
||||
};
|
||||
// export type DeepLinkQueryParams = {
|
||||
// [K in keyof typeof deepLinkQuerySchemas]: z.infer<(typeof deepLinkQuerySchemas)[K]>;
|
||||
// };
|
||||
|
||||
export interface DeepLinkError extends Error {
|
||||
code: string;
|
||||
|
||||
@@ -60,6 +60,7 @@ import { PlanSummaryRecord } from "../interfaces/records";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* Standard context for schema.org data
|
||||
@@ -309,7 +310,7 @@ export function didInfoForContact(
|
||||
showDidForVisible: boolean = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): { known: boolean; displayName: string; profileImageUrl?: string } {
|
||||
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
|
||||
if (!did) return { displayName: SOMEONE_UNNAMED, known: false };
|
||||
if (did === activeDid) {
|
||||
return { displayName: "You", known: true };
|
||||
} else if (contact) {
|
||||
@@ -485,6 +486,15 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
||||
max: 500,
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks in-flight requests to prevent duplicate API calls for the same plan
|
||||
* @constant {Map}
|
||||
*/
|
||||
const inFlightRequests = new Map<
|
||||
string,
|
||||
Promise<PlanSummaryRecord | undefined>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Retrieves plan data from cache or server
|
||||
* @param {string} handleId - Plan handle ID
|
||||
@@ -504,40 +514,140 @@ export async function getPlanFromCache(
|
||||
if (!handleId) {
|
||||
return undefined;
|
||||
}
|
||||
let cred = planCache.get(handleId);
|
||||
if (!cred) {
|
||||
const url =
|
||||
apiServer +
|
||||
"/api/v2/report/plans?handleId=" +
|
||||
encodeURIComponent(handleId);
|
||||
const headers = await getHeaders(requesterDid);
|
||||
try {
|
||||
const resp = await axios.get(url, { headers });
|
||||
if (resp.status === 200 && resp.data?.data?.length > 0) {
|
||||
cred = resp.data.data[0];
|
||||
planCache.set(handleId, cred);
|
||||
} else {
|
||||
// Use debug level for development to reduce console noise
|
||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
||||
const log = isDevelopment ? logger.debug : logger.log;
|
||||
|
||||
log(
|
||||
"[EndorserServer] Plan cache is empty for handle",
|
||||
handleId,
|
||||
" Got data:",
|
||||
JSON.stringify(resp.data),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[EndorserServer] Failed to load plan with handle",
|
||||
handleId,
|
||||
" Got error:",
|
||||
JSON.stringify(error),
|
||||
);
|
||||
}
|
||||
// Check cache first (existing behavior)
|
||||
const cred = planCache.get(handleId);
|
||||
if (cred) {
|
||||
return cred;
|
||||
}
|
||||
|
||||
// Check if request is already in flight (NEW: request deduplication)
|
||||
if (inFlightRequests.has(handleId)) {
|
||||
logger.debug(
|
||||
"[Plan Loading] 🔄 Request already in flight, reusing promise:",
|
||||
{
|
||||
handleId,
|
||||
requesterDid,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return inFlightRequests.get(handleId);
|
||||
}
|
||||
|
||||
// Create new request promise (NEW: request coordination)
|
||||
const requestPromise = performPlanRequest(
|
||||
handleId,
|
||||
axios,
|
||||
apiServer,
|
||||
requesterDid,
|
||||
);
|
||||
inFlightRequests.set(handleId, requestPromise);
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Clean up in-flight request tracking (NEW: cleanup)
|
||||
inFlightRequests.delete(handleId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual plan request to the server
|
||||
* @param {string} handleId - Plan handle ID
|
||||
* @param {Axios} axios - Axios instance
|
||||
* @param {string} apiServer - API server URL
|
||||
* @param {string} [requesterDid] - Optional requester DID for private info
|
||||
* @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found
|
||||
*
|
||||
* @throws {Error} If server request fails
|
||||
*/
|
||||
async function performPlanRequest(
|
||||
handleId: string,
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
requesterDid?: string,
|
||||
): Promise<PlanSummaryRecord | undefined> {
|
||||
const url =
|
||||
apiServer + "/api/v2/report/plans?handleId=" + encodeURIComponent(handleId);
|
||||
const headers = await getHeaders(requesterDid);
|
||||
|
||||
// Enhanced diagnostic logging for plan loading
|
||||
const requestId = `plan_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[Plan Loading] 🔍 Loading plan from server:", {
|
||||
requestId,
|
||||
handleId,
|
||||
apiServer,
|
||||
endpoint: url,
|
||||
requesterDid,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await axios.get(url, { headers });
|
||||
|
||||
logger.debug("[Plan Loading] ✅ Plan loaded successfully:", {
|
||||
requestId,
|
||||
handleId,
|
||||
status: resp.status,
|
||||
hasData: !!resp.data?.data,
|
||||
dataLength: resp.data?.data?.length || 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (resp.status === 200 && resp.data?.data?.length > 0) {
|
||||
const cred = resp.data.data[0];
|
||||
planCache.set(handleId, cred);
|
||||
|
||||
logger.debug("[Plan Loading] 💾 Plan cached:", {
|
||||
requestId,
|
||||
handleId,
|
||||
planName: cred?.name,
|
||||
planIssuer: cred?.issuerDid,
|
||||
});
|
||||
|
||||
return cred;
|
||||
} else {
|
||||
// Use debug level for development to reduce console noise
|
||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
||||
const log = isDevelopment ? logger.debug : logger.log;
|
||||
|
||||
log(
|
||||
"[Plan Loading] ⚠️ Plan cache is empty for handle",
|
||||
handleId,
|
||||
" Got data:",
|
||||
JSON.stringify(resp.data),
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced error logging for plan loading failures
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: unknown;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
logger.error("[Plan Loading] ❌ Failed to load plan:", {
|
||||
requestId,
|
||||
handleId,
|
||||
apiServer,
|
||||
endpoint: url,
|
||||
requesterDid,
|
||||
errorStatus: axiosError.response?.status,
|
||||
errorStatusText: axiosError.response?.statusText,
|
||||
errorData: axiosError.response?.data,
|
||||
errorMessage: axiosError.message || String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
return cred;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1018,19 +1128,82 @@ export async function createAndSubmitClaim(
|
||||
|
||||
const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload);
|
||||
|
||||
// Enhanced diagnostic logging for claim submission
|
||||
const requestId = `claim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.info("[Claim Submission] 🚀 Starting claim submission:", {
|
||||
requestId,
|
||||
apiServer,
|
||||
requesterDid: issuerDid,
|
||||
endpoint: `${apiServer}/api/v2/claim`,
|
||||
timestamp: new Date().toISOString(),
|
||||
jwtLength: vcJwt.length,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = `${apiServer}/api/v2/claim`;
|
||||
|
||||
logger.debug("[Claim Submission] 📡 Making API request:", {
|
||||
requestId,
|
||||
url,
|
||||
payloadSize: payload.length,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("[Claim Submission] ✅ Claim submitted successfully:", {
|
||||
requestId,
|
||||
status: response.status,
|
||||
handleId: response.data?.handleId,
|
||||
responseSize: JSON.stringify(response.data).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { success: true, handleId: response.data?.handleId };
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error submitting claim:", error);
|
||||
// Enhanced error logging with comprehensive context
|
||||
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
config?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
logger.error("[Claim Submission] ❌ Claim submission failed:", {
|
||||
requestId,
|
||||
apiServer,
|
||||
requesterDid: issuerDid,
|
||||
endpoint: `${apiServer}/api/v2/claim`,
|
||||
errorCode: axiosError.response?.data?.error?.code,
|
||||
errorMessage: axiosError.response?.data?.error?.message,
|
||||
httpStatus: axiosError.response?.status,
|
||||
httpStatusText: axiosError.response?.statusText,
|
||||
responseHeaders: axiosError.response?.headers,
|
||||
requestConfig: {
|
||||
url: axiosError.config?.url,
|
||||
method: axiosError.config?.method,
|
||||
headers: axiosError.config?.headers,
|
||||
},
|
||||
originalError: axiosError.message || String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorMessage: string =
|
||||
serverMessageForUser(error) ||
|
||||
(error && typeof error === "object" && "message" in error
|
||||
@@ -1515,14 +1688,56 @@ export async function fetchEndorserRateLimits(
|
||||
) {
|
||||
const url = `${apiServer}/api/report/rateLimits`;
|
||||
const headers = await getHeaders(issuerDid);
|
||||
|
||||
// Enhanced diagnostic logging for user registration tracking
|
||||
logger.debug("[User Registration] Checking user status on server:", {
|
||||
did: issuerDid,
|
||||
server: apiServer,
|
||||
endpoint: url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Log successful registration check
|
||||
logger.debug("[User Registration] User registration check successful:", {
|
||||
did: issuerDid,
|
||||
server: apiServer,
|
||||
status: response.status,
|
||||
isRegistered: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Enhanced error logging with user registration context
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
const errorCode = axiosError.response?.data?.error?.code;
|
||||
const errorMessage = axiosError.response?.data?.error?.message;
|
||||
const httpStatus = axiosError.response?.status;
|
||||
|
||||
logger.warn("[User Registration] User not registered on server:", {
|
||||
did: issuerDid,
|
||||
server: apiServer,
|
||||
errorCode: errorCode,
|
||||
errorMessage: errorMessage,
|
||||
httpStatus: httpStatus,
|
||||
needsRegistration: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Log the original error for debugging
|
||||
logger.error(
|
||||
`[fetchEndorserRateLimits] Error for DID ${issuerDid}:`,
|
||||
errorStringForLog(error),
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1535,8 +1750,53 @@ export async function fetchEndorserRateLimits(
|
||||
* @param {string} issuerDid - The DID for which to check rate limits.
|
||||
* @returns {Promise<AxiosResponse>} The Axios response object.
|
||||
*/
|
||||
export async function fetchImageRateLimits(axios: Axios, issuerDid: string) {
|
||||
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
|
||||
export async function fetchImageRateLimits(
|
||||
axios: Axios,
|
||||
issuerDid: string,
|
||||
imageServer?: string,
|
||||
) {
|
||||
const server = imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||
const url = server + "/image-limits";
|
||||
const headers = await getHeaders(issuerDid);
|
||||
return await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Enhanced diagnostic logging for image server calls
|
||||
logger.debug("[Image Server] Checking image rate limits:", {
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
endpoint: url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Log successful image server call
|
||||
logger.debug("[Image Server] Image rate limits check successful:", {
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
status: response.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Enhanced error logging for image server failures
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
|
||||
logger.warn("[Image Server] Image rate limits check failed:", {
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
errorCode: axiosError.response?.data?.error?.code,
|
||||
errorMessage: axiosError.response?.data?.error?.message,
|
||||
httpStatus: axiosError.response?.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faChartLine,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCirclePlus,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGear,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
@@ -58,6 +61,7 @@ import {
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faInfo,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
@@ -72,8 +76,8 @@ import {
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRightFromBracket,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
@@ -106,6 +110,7 @@ library.add(
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faChartLine,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
@@ -114,6 +119,7 @@ library.add(
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCirclePlus,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
@@ -135,6 +141,7 @@ library.add(
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGear,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
@@ -144,6 +151,7 @@ library.add(
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faInfo,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
|
||||
164
src/libs/util.ts
164
src/libs/util.ts
@@ -33,6 +33,7 @@ import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
|
||||
import { UNNAMED_PERSON } from "@/constants/entities";
|
||||
|
||||
// Consolidate this with src/utils/PlatformServiceMixin.mapQueryResultToValues
|
||||
function mapQueryResultToValues(
|
||||
@@ -227,7 +228,7 @@ export const nameForContact = (
|
||||
): string => {
|
||||
return (
|
||||
(contact?.name as string) ||
|
||||
(capitalize ? "This" : "this") + " unnamed user"
|
||||
(capitalize ? "This" : "this") + " " + UNNAMED_PERSON
|
||||
);
|
||||
};
|
||||
|
||||
@@ -648,57 +649,64 @@ export const retrieveAllAccountsMetadata = async (): Promise<
|
||||
return result;
|
||||
};
|
||||
|
||||
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
|
||||
|
||||
/**
|
||||
* Saves a new identity to both SQL and Dexie databases
|
||||
* Saves a new identity to SQL database
|
||||
*/
|
||||
export async function saveNewIdentity(
|
||||
identity: IIdentifier,
|
||||
mnemonic: string,
|
||||
derivationPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// add to the new sql db
|
||||
const platformService = await getPlatformService();
|
||||
// add to the new sql db
|
||||
const platformService = await getPlatformService();
|
||||
|
||||
const secrets = await platformService.dbQuery(
|
||||
`SELECT secretBase64 FROM secret`,
|
||||
);
|
||||
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
||||
throw new Error(
|
||||
"No initial encryption supported. We recommend you clear your data and start over.",
|
||||
);
|
||||
}
|
||||
// Check if account already exists before attempting to save
|
||||
const existingAccount = await platformService.dbQuery(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[identity.did],
|
||||
);
|
||||
|
||||
const secretBase64 = secrets.values[0][0] as string;
|
||||
|
||||
const secret = base64ToArrayBuffer(secretBase64);
|
||||
const identityStr = JSON.stringify(identity);
|
||||
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
||||
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
||||
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
||||
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
||||
|
||||
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const params = [
|
||||
new Date().toISOString(),
|
||||
derivationPath,
|
||||
identity.did,
|
||||
encryptedIdentityBase64,
|
||||
encryptedMnemonicBase64,
|
||||
identity.keys[0].publicKeyHex,
|
||||
];
|
||||
await platformService.dbExec(sql, params);
|
||||
|
||||
await platformService.updateDefaultSettings({ activeDid: identity.did });
|
||||
|
||||
await platformService.insertDidSpecificSettings(identity.did);
|
||||
} catch (error) {
|
||||
logger.error("Failed to update default settings:", error);
|
||||
if (existingAccount?.values?.length) {
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
|
||||
);
|
||||
}
|
||||
|
||||
const secrets = await platformService.dbQuery(
|
||||
`SELECT secretBase64 FROM secret`,
|
||||
);
|
||||
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
||||
throw new Error(
|
||||
"No initial encryption supported. We recommend you clear your data and start over.",
|
||||
);
|
||||
}
|
||||
|
||||
const secretBase64 = secrets.values[0][0] as string;
|
||||
|
||||
const secret = base64ToArrayBuffer(secretBase64);
|
||||
const identityStr = JSON.stringify(identity);
|
||||
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
||||
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
||||
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
||||
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
||||
|
||||
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const params = [
|
||||
new Date().toISOString(),
|
||||
derivationPath,
|
||||
identity.did,
|
||||
encryptedIdentityBase64,
|
||||
encryptedMnemonicBase64,
|
||||
identity.keys[0].publicKeyHex,
|
||||
];
|
||||
await platformService.dbExec(sql, params);
|
||||
|
||||
await platformService.updateDefaultSettings({ activeDid: identity.did });
|
||||
|
||||
await platformService.insertNewDidIntoSettings(identity.did);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -989,7 +997,7 @@ export async function importFromMnemonic(
|
||||
|
||||
try {
|
||||
// First, ensure the DID-specific settings record exists
|
||||
await platformService.insertDidSpecificSettings(newId.did);
|
||||
await platformService.insertNewDidIntoSettings(newId.did);
|
||||
|
||||
// Then update with Test User #0 specific settings
|
||||
await platformService.updateDidSpecificSettings(newId.did, {
|
||||
@@ -1008,13 +1016,16 @@ export async function importFromMnemonic(
|
||||
const firstName = settings[0];
|
||||
const isRegistered = settings[1];
|
||||
|
||||
logger.info("[importFromMnemonic] Test User #0 settings verification", {
|
||||
did: newId.did,
|
||||
firstName,
|
||||
isRegistered,
|
||||
expectedFirstName: "User Zero",
|
||||
expectedIsRegistered: true,
|
||||
});
|
||||
logger.debug(
|
||||
"[importFromMnemonic] Test User #0 settings verification",
|
||||
{
|
||||
did: newId.did,
|
||||
firstName,
|
||||
isRegistered,
|
||||
expectedFirstName: "User Zero",
|
||||
expectedIsRegistered: true,
|
||||
},
|
||||
);
|
||||
|
||||
// If settings weren't saved correctly, try individual updates
|
||||
if (firstName !== "User Zero" || isRegistered !== 1) {
|
||||
@@ -1040,7 +1051,7 @@ export async function importFromMnemonic(
|
||||
|
||||
if (retryResult?.values?.length) {
|
||||
const retrySettings = retryResult.values[0];
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[importFromMnemonic] Test User #0 settings after retry",
|
||||
{
|
||||
firstName: retrySettings[0],
|
||||
@@ -1063,3 +1074,58 @@ export async function importFromMnemonic(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an account with the given DID already exists in the database
|
||||
*
|
||||
* @param did - The DID to check for duplicates
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
* @throws Error if database query fails
|
||||
*/
|
||||
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Checks if an account with the given DID already exists in the database
|
||||
*
|
||||
* @param mnemonic - The mnemonic phrase to derive DID from
|
||||
* @param derivationPath - The derivation path to use
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
* @throws Error if database query fails
|
||||
*/
|
||||
export async function checkForDuplicateAccount(
|
||||
mnemonic: string,
|
||||
derivationPath: string,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Implementation of checkForDuplicateAccount with overloaded signatures
|
||||
*/
|
||||
export async function checkForDuplicateAccount(
|
||||
didOrMnemonic: string,
|
||||
derivationPath?: string,
|
||||
): Promise<boolean> {
|
||||
let didToCheck: string;
|
||||
|
||||
if (derivationPath) {
|
||||
// Derive the DID from mnemonic and derivation path
|
||||
const [address, privateHex, publicHex] = deriveAddress(
|
||||
didOrMnemonic.trim().toLowerCase(),
|
||||
derivationPath,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
|
||||
didToCheck = newId.did;
|
||||
} else {
|
||||
// Use the provided DID directly
|
||||
didToCheck = didOrMnemonic;
|
||||
}
|
||||
|
||||
// Check if an account with this DID already exists
|
||||
const platformService = await getPlatformService();
|
||||
const existingAccount = await platformService.dbQuery(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[didToCheck],
|
||||
);
|
||||
|
||||
return (existingAccount?.values?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -29,14 +29,15 @@
|
||||
*/
|
||||
|
||||
import { initializeApp } from "./main.common";
|
||||
import { App } from "./libs/capacitor/app";
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { DeepLinkHandler } from "./services/deepLinks";
|
||||
import { logger, safeStringify } from "./utils/logger";
|
||||
import "./utils/safeAreaInset";
|
||||
|
||||
logger.log("[Capacitor] Starting initialization");
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
|
||||
const app = initializeApp();
|
||||
@@ -67,23 +68,123 @@ const deepLinkHandler = new DeepLinkHandler(router);
|
||||
* @throws {Error} If URL format is invalid
|
||||
*/
|
||||
const handleDeepLink = async (data: { url: string }) => {
|
||||
const { url } = data;
|
||||
logger.info(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||
|
||||
try {
|
||||
// Wait for router to be ready
|
||||
logger.info(`[Main] ⏳ Waiting for router to be ready...`);
|
||||
await router.isReady();
|
||||
await deepLinkHandler.handleDeepLink(data.url);
|
||||
logger.info(`[Main] ✅ Router is ready, processing deeplink`);
|
||||
|
||||
// Process the deeplink
|
||||
logger.info(`[Main] 🚀 Starting deeplink processing`);
|
||||
await deepLinkHandler.handleDeepLink(url);
|
||||
logger.info(`[Main] ✅ Deeplink processed successfully`);
|
||||
} catch (error) {
|
||||
logger.error("[DeepLink] Error handling deep link: ", error);
|
||||
logger.error(`[Main] ❌ Deeplink processing failed:`, {
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Log additional context for debugging
|
||||
logger.error(`[Main] 🔍 Debug context:`, {
|
||||
routerReady: router.isReady(),
|
||||
currentRoute: router.currentRoute.value,
|
||||
appMounted: app._instance?.isMounted,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Fallback to original error handling
|
||||
let message: string =
|
||||
error instanceof Error ? error.message : safeStringify(error);
|
||||
if (data.url) {
|
||||
message += `\nURL: ${data.url}`;
|
||||
if (url) {
|
||||
message += `\nURL: ${url}`;
|
||||
}
|
||||
handleApiError({ message } as AxiosError, "deep-link");
|
||||
}
|
||||
};
|
||||
|
||||
// Register deep link handler with Capacitor
|
||||
App.addListener("appUrlOpen", handleDeepLink);
|
||||
// Function to register the deeplink listener
|
||||
const registerDeepLinkListener = async () => {
|
||||
try {
|
||||
logger.info(
|
||||
`[Main] 🔗 Attempting to register deeplink handler with Capacitor`,
|
||||
);
|
||||
|
||||
logger.log("[Capacitor] Mounting app");
|
||||
// Check if Capacitor App plugin is available
|
||||
logger.info(`[Main] 🔍 Checking Capacitor App plugin availability...`);
|
||||
if (!CapacitorApp) {
|
||||
throw new Error("Capacitor App plugin not available");
|
||||
}
|
||||
logger.info(`[Main] ✅ Capacitor App plugin is available`);
|
||||
|
||||
// Check available methods on CapacitorApp
|
||||
logger.info(
|
||||
`[Main] 🔍 Capacitor App plugin methods:`,
|
||||
Object.getOwnPropertyNames(CapacitorApp),
|
||||
);
|
||||
logger.info(
|
||||
`[Main] 🔍 Capacitor App plugin addListener method:`,
|
||||
typeof CapacitorApp.addListener,
|
||||
);
|
||||
|
||||
// Wait for router to be ready first
|
||||
await router.isReady();
|
||||
logger.info(
|
||||
`[Main] ✅ Router is ready, proceeding with listener registration`,
|
||||
);
|
||||
|
||||
// Try to register the listener
|
||||
logger.info(`[Main] 🧪 Attempting to register appUrlOpen listener...`);
|
||||
const listenerHandle = await CapacitorApp.addListener(
|
||||
"appUrlOpen",
|
||||
handleDeepLink,
|
||||
);
|
||||
logger.info(
|
||||
`[Main] ✅ appUrlOpen listener registered successfully with handle:`,
|
||||
listenerHandle,
|
||||
);
|
||||
|
||||
// Test the listener registration by checking if it's actually registered
|
||||
logger.info(`[Main] 🧪 Verifying listener registration...`);
|
||||
|
||||
return listenerHandle;
|
||||
} catch (error) {
|
||||
logger.error(`[Main] ❌ Failed to register deeplink listener:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
logger.log("[Capacitor] 🚀 Mounting app");
|
||||
app.mount("#app");
|
||||
logger.log("[Capacitor] App mounted");
|
||||
logger.info(`[Main] ✅ App mounted successfully`);
|
||||
|
||||
// Register deeplink listener after app is mounted
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logger.info(
|
||||
`[Main] ⏳ Delaying listener registration to ensure Capacitor is ready...`,
|
||||
);
|
||||
await registerDeepLinkListener();
|
||||
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
||||
} catch (error) {
|
||||
logger.error(`[Main] ❌ Deep link system initialization failed:`, error);
|
||||
}
|
||||
}, 2000); // 2 second delay to ensure Capacitor is fully ready
|
||||
|
||||
// Log app initialization status
|
||||
setTimeout(() => {
|
||||
logger.info(`[Main] 📊 App initialization status:`, {
|
||||
routerReady: router.isReady(),
|
||||
currentRoute: router.currentRoute.value,
|
||||
appMounted: app._instance?.isMounted,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file Dynamic Main Entry Point
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This file dynamically loads the appropriate platform-specific main entry point
|
||||
* based on the current environment and build configuration.
|
||||
*/
|
||||
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
// Check the platform from environment variables
|
||||
const platform = process.env.VITE_PLATFORM || "web";
|
||||
|
||||
logger.info(`[Main] 🚀 Loading TimeSafari for platform: ${platform}`);
|
||||
|
||||
// Log all relevant environment variables for boot-time debugging
|
||||
logger.info("[Main] 🌍 Boot-time environment configuration:", {
|
||||
platform: process.env.VITE_PLATFORM,
|
||||
defaultEndorserApiServer: process.env.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
defaultPartnerApiServer: process.env.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Dynamically import the appropriate main entry point
|
||||
if (platform === "capacitor") {
|
||||
logger.info(`[Main] 📱 Loading Capacitor-specific entry point`);
|
||||
import("./main.capacitor");
|
||||
} else if (platform === "electron") {
|
||||
logger.info(`[Main] 💻 Loading Electron-specific entry point`);
|
||||
import("./main.electron");
|
||||
} else {
|
||||
logger.info(`[Main] 🌐 Loading Web-specific entry point`);
|
||||
import("./main.web");
|
||||
}
|
||||
@@ -321,25 +321,38 @@ const errorHandler = (
|
||||
router.onError(errorHandler); // Assign the error handler to the router instance
|
||||
|
||||
/**
|
||||
* Global navigation guard to ensure user identity exists
|
||||
*
|
||||
* This guard checks if the user has any identities before navigating to most routes.
|
||||
* If no identity exists, it automatically creates one using the default seed-based method.
|
||||
*
|
||||
* Routes that are excluded from this check:
|
||||
* - /start - Manual identity creation selection
|
||||
* - /new-identifier - Manual seed-based creation
|
||||
* - /import-account - Manual import flow
|
||||
* - /import-derive - Manual derivation flow
|
||||
* - /database-migration - Migration utilities
|
||||
* - /deep-link-error - Error page
|
||||
*
|
||||
* Navigation guard to ensure user has an identity before accessing protected routes
|
||||
* @param to - Target route
|
||||
* @param from - Source route
|
||||
* @param _from - Source route (unused)
|
||||
* @param next - Navigation function
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
logger.debug(`[Router] 🧭 Navigation guard triggered:`, {
|
||||
from: _from?.path || "none",
|
||||
to: to.path,
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// Log boot-time configuration on first navigation
|
||||
if (!_from) {
|
||||
logger.info(
|
||||
"[Router] 🚀 First navigation detected - logging boot-time configuration:",
|
||||
{
|
||||
platform: process.env.VITE_PLATFORM,
|
||||
defaultEndorserApiServer:
|
||||
process.env.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
defaultPartnerApiServer: process.env.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
targetRoute: to.path,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Skip identity check for routes that handle identity creation manually
|
||||
const skipIdentityRoutes = [
|
||||
"/start",
|
||||
@@ -351,32 +364,67 @@ router.beforeEach(async (to, _from, next) => {
|
||||
];
|
||||
|
||||
if (skipIdentityRoutes.includes(to.path)) {
|
||||
logger.debug(`[Router] ⏭️ Skipping identity check for route: ${to.path}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.debug(`[Router] 🔍 Checking user identity for route: ${to.path}`);
|
||||
|
||||
// Check if user has any identities
|
||||
const allMyDids = await retrieveAccountDids();
|
||||
logger.debug(`[Router] 📋 Found ${allMyDids.length} user identities`);
|
||||
|
||||
if (allMyDids.length === 0) {
|
||||
logger.info("[Router] No identities found, creating default identity");
|
||||
logger.info("[Router] ⚠️ No identities found, creating default identity");
|
||||
|
||||
// Create identity automatically using seed-based method
|
||||
await generateSaveAndActivateIdentity();
|
||||
|
||||
logger.info("[Router] Default identity created successfully");
|
||||
logger.info("[Router] ✅ Default identity created successfully");
|
||||
} else {
|
||||
logger.debug(
|
||||
`[Router] ✅ User has ${allMyDids.length} identities, proceeding`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`[Router] ✅ Navigation guard passed for: ${to.path}`);
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[Router] Identity creation failed in navigation guard:",
|
||||
error,
|
||||
);
|
||||
logger.error("[Router] ❌ Identity creation failed in navigation guard:", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
route: to.path,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Redirect to start page if identity creation fails
|
||||
// This allows users to manually create an identity or troubleshoot
|
||||
logger.info(
|
||||
`[Router] 🔄 Redirecting to /start due to identity creation failure`,
|
||||
);
|
||||
next("/start");
|
||||
}
|
||||
});
|
||||
|
||||
// Add navigation success logging
|
||||
router.afterEach((to, from) => {
|
||||
logger.debug(`[Router] ✅ Navigation completed:`, {
|
||||
from: from?.path || "none",
|
||||
to: to.path,
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Add error logging
|
||||
router.onError((error) => {
|
||||
logger.error(`[Router] ❌ Navigation error:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
185
src/services/ClipboardService.ts
Normal file
185
src/services/ClipboardService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Clipboard } from "@capacitor/clipboard";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
/**
|
||||
* Platform-agnostic clipboard service that handles both web and native platforms
|
||||
* Provides reliable clipboard functionality across all platforms including iOS
|
||||
*/
|
||||
export class ClipboardService {
|
||||
private static instance: ClipboardService | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance of ClipboardService
|
||||
*/
|
||||
public static getInstance(): ClipboardService {
|
||||
if (!ClipboardService.instance) {
|
||||
ClipboardService.instance = new ClipboardService();
|
||||
}
|
||||
return ClipboardService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with platform-specific handling
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise that resolves when copy is complete
|
||||
* @throws Error if copy operation fails
|
||||
*/
|
||||
public async copyToClipboard(text: string): Promise<void> {
|
||||
const platform = Capacitor.getPlatform();
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
logger.debug("[ClipboardService] Copying to clipboard:", {
|
||||
text: text.substring(0, 50) + (text.length > 50 ? "..." : ""),
|
||||
platform,
|
||||
isNative,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (isNative && (platform === "ios" || platform === "android")) {
|
||||
// Use native Capacitor clipboard for mobile platforms
|
||||
await this.copyNative(text);
|
||||
} else {
|
||||
// Use web clipboard API for web/desktop platforms
|
||||
await this.copyWeb(text);
|
||||
}
|
||||
|
||||
logger.debug("[ClipboardService] Copy successful", {
|
||||
platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[ClipboardService] Copy failed:", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text using native Capacitor clipboard API
|
||||
*
|
||||
* @param text - The text to copy
|
||||
* @returns Promise that resolves when copy is complete
|
||||
*/
|
||||
private async copyNative(text: string): Promise<void> {
|
||||
try {
|
||||
await Clipboard.write({
|
||||
string: text,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[ClipboardService] Native copy failed:", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw new Error(
|
||||
`Native clipboard copy failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text using web clipboard API with fallback
|
||||
*
|
||||
* @param text - The text to copy
|
||||
* @returns Promise that resolves when copy is complete
|
||||
*/
|
||||
private async copyWeb(text: string): Promise<void> {
|
||||
try {
|
||||
// Try VueUse clipboard first (handles some edge cases)
|
||||
const { copy } = useClipboard();
|
||||
await copy(text);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
"[ClipboardService] VueUse clipboard failed, trying native API:",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Fallback to native navigator.clipboard
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
throw new Error("Clipboard API not supported in this browser");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text from clipboard (platform-specific)
|
||||
*
|
||||
* @returns Promise that resolves to the clipboard text
|
||||
* @throws Error if read operation fails
|
||||
*/
|
||||
public async readFromClipboard(): Promise<string> {
|
||||
const platform = Capacitor.getPlatform();
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
try {
|
||||
if (isNative && (platform === "ios" || platform === "android")) {
|
||||
// Use native Capacitor clipboard for mobile platforms
|
||||
const result = await Clipboard.read();
|
||||
return result.value || "";
|
||||
} else {
|
||||
// Use web clipboard API for web/desktop platforms
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
} else {
|
||||
throw new Error("Clipboard read API not supported in this browser");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[ClipboardService] Read from clipboard failed:", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clipboard is supported on current platform
|
||||
*
|
||||
* @returns boolean indicating if clipboard is supported
|
||||
*/
|
||||
public isSupported(): boolean {
|
||||
const platform = Capacitor.getPlatform();
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
if (isNative && (platform === "ios" || platform === "android")) {
|
||||
return true; // Capacitor clipboard should work on native platforms
|
||||
}
|
||||
|
||||
// Check web clipboard support
|
||||
return !!(navigator.clipboard && navigator.clipboard.writeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to copy text to clipboard
|
||||
* Uses the singleton ClipboardService instance
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise that resolves when copy is complete
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
return ClipboardService.getInstance().copyToClipboard(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to read text from clipboard
|
||||
* Uses the singleton ClipboardService instance
|
||||
*
|
||||
* @returns Promise that resolves to the clipboard text
|
||||
*/
|
||||
export async function readFromClipboard(): Promise<string> {
|
||||
return ClipboardService.getInstance().readFromClipboard();
|
||||
}
|
||||
@@ -175,11 +175,11 @@ export interface PlatformService {
|
||||
updateDefaultSettings(settings: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Inserts DID-specific settings into the database.
|
||||
* Inserts a new DID into the settings table.
|
||||
* @param did - The DID to associate with the settings
|
||||
* @returns Promise that resolves when the insertion is complete
|
||||
*/
|
||||
insertDidSpecificSettings(did: string): Promise<void>;
|
||||
insertNewDidIntoSettings(did: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates DID-specific settings in the database.
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* ProfileService - Handles user profile operations and API calls
|
||||
* Extracted from AccountViewView.vue to improve separation of concerns
|
||||
*/
|
||||
|
||||
import { AxiosInstance, AxiosError } from "axios";
|
||||
import { UserProfile } from "@/libs/partnerServer";
|
||||
import { UserProfileResponse } from "@/interfaces/accountView";
|
||||
import { getHeaders, errorStringForLog } from "@/libs/endorserServer";
|
||||
import { handleApiError } from "./api";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||
|
||||
/**
|
||||
* Profile data interface
|
||||
*/
|
||||
export interface ProfileData {
|
||||
description: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
includeLocation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile service class
|
||||
*/
|
||||
export class ProfileService {
|
||||
private axios: AxiosInstance;
|
||||
private partnerApiServer: string;
|
||||
|
||||
constructor(axios: AxiosInstance, partnerApiServer: string) {
|
||||
this.axios = axios;
|
||||
this.partnerApiServer = partnerApiServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns ProfileData or null if profile doesn't exist
|
||||
*/
|
||||
async loadProfile(activeDid: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await this.axios.get<UserProfileResponse>(
|
||||
`${this.partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.data.data;
|
||||
const profileData: ProfileData = {
|
||||
description: data.description || "",
|
||||
latitude: data.locLat || 0,
|
||||
longitude: data.locLon || 0,
|
||||
includeLocation: !!(data.locLat && data.locLon),
|
||||
};
|
||||
return profileData;
|
||||
} else {
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.UNABLE_TO_LOAD_PROFILE);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isApiError(error) && error.response?.status === 404) {
|
||||
// Profile doesn't exist yet - this is normal
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.error("Error loading profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfileForIssuer");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user profile to the server
|
||||
* @param activeDid - The user's DID
|
||||
* @param profileData - The profile data to save
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
async saveProfile(
|
||||
activeDid: string,
|
||||
profileData: ProfileData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const payload: UserProfile = {
|
||||
description: profileData.description,
|
||||
issuerDid: activeDid,
|
||||
};
|
||||
|
||||
// Add location data if location is included
|
||||
if (
|
||||
profileData.includeLocation &&
|
||||
profileData.latitude &&
|
||||
profileData.longitude
|
||||
) {
|
||||
payload.locLat = profileData.latitude;
|
||||
payload.locLon = profileData.longitude;
|
||||
}
|
||||
|
||||
const response = await this.axios.post(
|
||||
`${this.partnerApiServer}/api/partner/userProfile`,
|
||||
payload,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 201) {
|
||||
return true;
|
||||
} else {
|
||||
logger.error("Error saving profile:", response);
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_SAVED);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
async deleteProfile(activeDid: string): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await this.axios.delete(
|
||||
`${this.partnerApiServer}/api/partner/userProfile`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_DELETED);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error deleting profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile location
|
||||
* @param profileData - Current profile data
|
||||
* @param latitude - New latitude
|
||||
* @param longitude - New longitude
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
updateProfileLocation(
|
||||
profileData: ProfileData,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude,
|
||||
longitude,
|
||||
includeLocation: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle location inclusion in profile
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
toggleProfileLocation(profileData: ProfileData): ProfileData {
|
||||
const includeLocation = !profileData.includeLocation;
|
||||
return {
|
||||
...profileData,
|
||||
latitude: includeLocation ? profileData.latitude : 0,
|
||||
longitude: includeLocation ? profileData.longitude : 0,
|
||||
includeLocation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear profile location
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
clearProfileLocation(profileData: ProfileData): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset profile to default state
|
||||
* @returns Default profile data
|
||||
*/
|
||||
getDefaultProfile(): ProfileData {
|
||||
return {
|
||||
description: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for API errors
|
||||
*/
|
||||
private isApiError(
|
||||
error: unknown,
|
||||
): error is { response?: { status?: number } } {
|
||||
return typeof error === "object" && error !== null && "response" in error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a ProfileService instance
|
||||
*/
|
||||
export function createProfileService(
|
||||
axios: AxiosInstance,
|
||||
partnerApiServer: string,
|
||||
): ProfileService {
|
||||
return new ProfileService(axios, partnerApiServer);
|
||||
}
|
||||
99
src/services/QRNavigationService.ts
Normal file
99
src/services/QRNavigationService.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { PlatformServiceFactory } from "./PlatformServiceFactory";
|
||||
import { PlatformService } from "./PlatformService";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
/**
|
||||
* QR Navigation Service
|
||||
*
|
||||
* Handles platform-specific routing logic for QR scanning operations.
|
||||
* Removes coupling between views and routing logic by centralizing
|
||||
* navigation decisions based on platform capabilities.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
export class QRNavigationService {
|
||||
private static instance: QRNavigationService | null = null;
|
||||
private platformService: PlatformService;
|
||||
|
||||
private constructor() {
|
||||
this.platformService = PlatformServiceFactory.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance of QRNavigationService
|
||||
*/
|
||||
public static getInstance(): QRNavigationService {
|
||||
if (!QRNavigationService.instance) {
|
||||
QRNavigationService.instance = new QRNavigationService();
|
||||
}
|
||||
return QRNavigationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate QR scanner route based on platform
|
||||
*
|
||||
* @returns Object with route name and parameters for QR scanning
|
||||
*/
|
||||
public getQRScannerRoute(): {
|
||||
name: string;
|
||||
params?: Record<string, string | number>;
|
||||
} {
|
||||
const isCapacitor = this.platformService.isCapacitor();
|
||||
|
||||
logger.debug("QR Navigation - Platform detection:", {
|
||||
isCapacitor,
|
||||
platform: this.platformService.getCapabilities(),
|
||||
});
|
||||
|
||||
if (isCapacitor) {
|
||||
// Use native scanner on mobile platforms
|
||||
return { name: "contact-qr-scan-full" };
|
||||
} else {
|
||||
// Use web scanner on other platforms
|
||||
return { name: "contact-qr" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate QR display route based on platform
|
||||
*
|
||||
* @returns Object with route name and parameters for QR display
|
||||
*/
|
||||
public getQRDisplayRoute(): {
|
||||
name: string;
|
||||
params?: Record<string, string | number>;
|
||||
} {
|
||||
const isCapacitor = this.platformService.isCapacitor();
|
||||
|
||||
logger.debug("QR Navigation - Display route detection:", {
|
||||
isCapacitor,
|
||||
platform: this.platformService.getCapabilities(),
|
||||
});
|
||||
|
||||
if (isCapacitor) {
|
||||
// Use dedicated display view on mobile
|
||||
return { name: "contact-qr-scan-show" };
|
||||
} else {
|
||||
// Use combined view on web
|
||||
return { name: "contact-qr" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if native QR scanning is available on current platform
|
||||
*
|
||||
* @returns true if native scanning is available, false otherwise
|
||||
*/
|
||||
public isNativeScanningAvailable(): boolean {
|
||||
return this.platformService.isCapacitor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform capabilities for QR operations
|
||||
*
|
||||
* @returns Platform capabilities object
|
||||
*/
|
||||
public getPlatformCapabilities() {
|
||||
return this.platformService.getCapabilities();
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
// Generate a short random ID for this scanner instance
|
||||
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
this.options = options ?? {};
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
|
||||
{
|
||||
...this.options,
|
||||
@@ -49,7 +49,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.context = this.canvas.getContext("2d", { willReadFrequently: true });
|
||||
this.video = document.createElement("video");
|
||||
this.video.setAttribute("playsinline", "true"); // Required for iOS
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] DOM elements created successfully`,
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.cameraStateListeners.forEach((listener) => {
|
||||
try {
|
||||
listener.onStateChange(state, message);
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
|
||||
{
|
||||
state,
|
||||
@@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
this.updateCameraState("initializing", "Checking camera permissions...");
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
const permissions = await navigator.permissions.query({
|
||||
name: "camera" as PermissionName,
|
||||
});
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
|
||||
permissions.state,
|
||||
);
|
||||
@@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
"initializing",
|
||||
"Requesting camera permissions...",
|
||||
);
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
(device) => device.kind === "videoinput",
|
||||
);
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
|
||||
count: videoDevices.length,
|
||||
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })),
|
||||
userAgent: navigator.userAgent,
|
||||
@@ -188,7 +188,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
}
|
||||
|
||||
// Try to get a stream with specific constraints
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`,
|
||||
{
|
||||
facingMode: "environment",
|
||||
@@ -210,7 +210,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Stop the test stream immediately
|
||||
stream.getTracks().forEach((track) => {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
|
||||
kind: track.kind,
|
||||
label: track.label,
|
||||
readyState: track.readyState,
|
||||
@@ -275,12 +275,12 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
try {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Checking browser support...`,
|
||||
);
|
||||
// Check for secure context first
|
||||
if (!window.isSecureContext) {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`,
|
||||
);
|
||||
return false;
|
||||
@@ -300,7 +300,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
(device) => device.kind === "videoinput",
|
||||
);
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Device support check:`, {
|
||||
hasSecureContext: window.isSecureContext,
|
||||
hasMediaDevices: !!navigator.mediaDevices,
|
||||
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia,
|
||||
@@ -379,7 +379,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Log scan attempt every 100 frames or 1 second
|
||||
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
|
||||
attempt: this.scanAttempts,
|
||||
dimensions: {
|
||||
width: this.canvas.width,
|
||||
@@ -421,7 +421,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
!code.data ||
|
||||
code.data.length === 0;
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
|
||||
data: code.data,
|
||||
location: code.location,
|
||||
attempts: this.scanAttempts,
|
||||
@@ -512,13 +512,13 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.scanAttempts = 0;
|
||||
this.lastScanTime = Date.now();
|
||||
this.updateCameraState("initializing", "Starting camera...");
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
|
||||
this.options,
|
||||
);
|
||||
|
||||
// Get camera stream with options
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
|
||||
);
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -531,7 +531,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
this.updateCameraState("active", "Camera is active");
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
|
||||
tracks: this.stream.getTracks().map((t) => ({
|
||||
kind: t.kind,
|
||||
label: t.label,
|
||||
@@ -550,14 +550,14 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.video.style.display = "none";
|
||||
}
|
||||
await this.video.play();
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Video element started playing`,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit stream to component
|
||||
this.events.emit("stream", this.stream);
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
|
||||
|
||||
// Start QR code scanning
|
||||
this.scanQRCode();
|
||||
@@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
}
|
||||
|
||||
try {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
|
||||
scanAttempts: this.scanAttempts,
|
||||
duration: Date.now() - this.lastScanTime,
|
||||
});
|
||||
@@ -604,7 +604,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`,
|
||||
);
|
||||
}
|
||||
@@ -613,13 +613,13 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
if (this.video) {
|
||||
this.video.pause();
|
||||
this.video.srcObject = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`);
|
||||
}
|
||||
|
||||
// Stop all tracks in the stream
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
|
||||
kind: track.kind,
|
||||
label: track.label,
|
||||
readyState: track.readyState,
|
||||
@@ -631,7 +631,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Emit stream stopped event
|
||||
this.events.emit("stream", null);
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -643,17 +643,17 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
throw error;
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: ScanListener): void {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
|
||||
this.scanListener = listener;
|
||||
}
|
||||
|
||||
onStream(callback: (stream: MediaStream | null) => void): void {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Adding stream event listener`,
|
||||
);
|
||||
this.events.on("stream", callback);
|
||||
@@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
try {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
|
||||
await this.stopScan();
|
||||
this.events.removeAllListeners();
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
|
||||
|
||||
// Clean up DOM elements
|
||||
if (this.video) {
|
||||
this.video.remove();
|
||||
this.video = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.remove();
|
||||
this.canvas = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
|
||||
}
|
||||
this.context = null;
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
/**
|
||||
* @file Deep Link Handler Service
|
||||
* DeepLinks Service
|
||||
*
|
||||
* Handles deep link processing and routing for the TimeSafari application.
|
||||
* Supports both path parameters and query parameters with comprehensive validation.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This service handles the processing and routing of deep links in the TimeSafari app.
|
||||
* It provides a type-safe interface between the raw deep links and the application router.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. DeepLinkHandler class encapsulates all deep link processing logic
|
||||
* 2. Uses Zod schemas from interfaces/deepLinks for parameter validation
|
||||
* 3. Provides consistent error handling and logging
|
||||
* 4. Maps validated parameters to Vue router calls
|
||||
*
|
||||
* Error Handling Strategy:
|
||||
* - All errors are wrapped in DeepLinkError interface
|
||||
* - Errors include error codes for systematic handling
|
||||
* - Detailed error information is logged for debugging
|
||||
* - Errors are propagated to the global error handler
|
||||
*
|
||||
* Validation Strategy:
|
||||
* - URL structure validation
|
||||
* - Route-specific parameter validation using Zod schemas
|
||||
* - Query parameter validation and sanitization
|
||||
* - Type-safe parameter passing to router
|
||||
*
|
||||
* Deep Link Format:
|
||||
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
|
||||
*
|
||||
* Supported Routes:
|
||||
* - claim: View claim
|
||||
* - claim-add-raw: Add raw claim
|
||||
* - claim-cert: View claim certificate
|
||||
* - confirm-gift
|
||||
* - contact-import: Import contacts
|
||||
* - did: View DID
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - onboard-meeting-members
|
||||
* - project: View project details
|
||||
* - user-profile: View user profile
|
||||
*
|
||||
* @example
|
||||
* const handler = new DeepLinkHandler(router);
|
||||
* await handler.handleDeepLink("timesafari://claim/123?view=details");
|
||||
* @version 2.0.0
|
||||
* @since 2025-01-25
|
||||
*/
|
||||
|
||||
import { Router } from "vue-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
deepLinkSchemas,
|
||||
baseUrlSchema,
|
||||
deepLinkPathSchemas,
|
||||
routeSchema,
|
||||
DeepLinkRoute,
|
||||
deepLinkQuerySchemas,
|
||||
} from "../interfaces/deepLinks";
|
||||
import type { DeepLinkError } from "../interfaces/deepLinks";
|
||||
import { logger } from "../utils/logger";
|
||||
@@ -74,7 +40,7 @@ function getFirstKeyFromZodObject(
|
||||
* because "router.replace" expects the right parameter name for the route.
|
||||
*/
|
||||
export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
|
||||
Object.entries(deepLinkSchemas).reduce(
|
||||
Object.entries(deepLinkPathSchemas).reduce(
|
||||
(acc, [routeName, schema]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
|
||||
@@ -103,83 +69,152 @@ export class DeepLinkHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* Parses deep link URL into path, params and query components.
|
||||
* Validates URL structure using Zod schemas.
|
||||
*
|
||||
* @param url - The deep link URL to parse (format: scheme://path[?query])
|
||||
* @throws {DeepLinkError} If URL format is invalid
|
||||
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
|
||||
* Main entry point for processing deep links
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If validation fails or route is invalid
|
||||
*/
|
||||
private parseDeepLink(url: string) {
|
||||
const parts = url.split("://");
|
||||
if (parts.length !== 2) {
|
||||
throw { code: "INVALID_URL", message: "Invalid URL format" };
|
||||
}
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
logger.debug(`[DeepLink] 🚀 Starting deeplink processing for URL: ${url}`);
|
||||
|
||||
// Validate base URL structure
|
||||
baseUrlSchema.parse({
|
||||
scheme: parts[0],
|
||||
path: parts[1],
|
||||
queryParams: {}, // Will be populated below
|
||||
});
|
||||
try {
|
||||
logger.debug(`[DeepLink] 📍 Parsing URL: ${url}`);
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, ...pathParams] = path.split("/");
|
||||
|
||||
// Validate route exists before proceeding
|
||||
if (!ROUTE_MAP[routePath]) {
|
||||
throw {
|
||||
code: "INVALID_ROUTE",
|
||||
message: `Invalid route path: ${routePath}`,
|
||||
details: { routePath },
|
||||
};
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
if (queryString) {
|
||||
new URLSearchParams(queryString).forEach((value, key) => {
|
||||
query[key] = value;
|
||||
logger.debug(`[DeepLink] ✅ URL parsed successfully:`, {
|
||||
path,
|
||||
params: Object.keys(params),
|
||||
query: Object.keys(query),
|
||||
fullParams: params,
|
||||
fullQuery: query,
|
||||
});
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (pathParams) {
|
||||
// Now we know routePath exists in ROUTE_MAP
|
||||
const routeConfig = ROUTE_MAP[routePath];
|
||||
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
|
||||
}
|
||||
// Sanitize parameters (remove undefined values)
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
|
||||
// logConsoleAndDb(
|
||||
// `[DeepLink] Debug: Route Path: ${routePath} Path Params: ${JSON.stringify(params)} Query String: ${JSON.stringify(query)}`,
|
||||
// false,
|
||||
// );
|
||||
return { path: routePath, params, query };
|
||||
logger.debug(`[DeepLink] 🧹 Parameters sanitized:`, sanitizedParams);
|
||||
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
logger.debug(`[DeepLink] 🎯 Deeplink processing completed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`[DeepLink] ❌ Deeplink processing failed:`, {
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
throw deepLinkError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the deep link to appropriate view with validated parameters.
|
||||
* Validates route and parameters using Zod schemas before routing.
|
||||
*
|
||||
* @param path - The route path from the deep link
|
||||
* @param params - URL parameters
|
||||
* @param query - Query string parameters
|
||||
* @throws {DeepLinkError} If validation fails or route is invalid
|
||||
* Parse a deep link URL into its components
|
||||
* @param url - The deep link URL
|
||||
* @returns Parsed components
|
||||
*/
|
||||
private parseDeepLink(url: string): {
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
} {
|
||||
logger.debug(`[DeepLink] 🔍 Parsing deep link: ${url}`);
|
||||
|
||||
try {
|
||||
const parts = url.split("://");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, ...pathParams] = path.split("/");
|
||||
|
||||
// Parse path parameters using route-specific configuration
|
||||
const params: Record<string, string> = {};
|
||||
if (pathParams.length > 0) {
|
||||
// Get the correct parameter key for this route
|
||||
const routeConfig = ROUTE_MAP[routePath];
|
||||
if (routeConfig?.paramKey) {
|
||||
params[routeConfig.paramKey] = pathParams[0];
|
||||
logger.debug(
|
||||
`[DeepLink] 📍 Path parameter extracted: ${routeConfig.paramKey}=${pathParams[0]}`,
|
||||
);
|
||||
} else {
|
||||
// Fallback to 'id' for backward compatibility
|
||||
params.id = pathParams[0];
|
||||
logger.debug(
|
||||
`[DeepLink] 📍 Path parameter extracted: id=${pathParams[0]} (fallback)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const query: Record<string, string> = {};
|
||||
if (queryString) {
|
||||
const queryParams = new URLSearchParams(queryString);
|
||||
for (const [key, value] of queryParams.entries()) {
|
||||
query[key] = value;
|
||||
}
|
||||
logger.debug(`[DeepLink] 🔗 Query parameters extracted:`, query);
|
||||
}
|
||||
|
||||
logger.debug(`[DeepLink] ✅ Parse completed:`, {
|
||||
routePath,
|
||||
pathParams: pathParams.length,
|
||||
queryParams: Object.keys(query).length,
|
||||
});
|
||||
|
||||
return { path: routePath, params, query };
|
||||
} catch (error) {
|
||||
logger.error(`[DeepLink] ❌ Parse failed:`, {
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and route the deep link
|
||||
* @param path - The route path
|
||||
* @param params - Path parameters
|
||||
* @param query - Query parameters
|
||||
*/
|
||||
private async validateAndRoute(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
query: Record<string, string>,
|
||||
): Promise<void> {
|
||||
logger.debug(
|
||||
`[DeepLink] 🎯 Starting validation and routing for path: ${path}`,
|
||||
);
|
||||
|
||||
// First try to validate the route path
|
||||
let routeName: string;
|
||||
|
||||
try {
|
||||
logger.debug(`[DeepLink] 🔍 Validating route path: ${path}`);
|
||||
// Validate route exists
|
||||
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
|
||||
routeName = ROUTE_MAP[validRoute].name;
|
||||
logger.debug(`[DeepLink] ✅ Route validation passed: ${validRoute}`);
|
||||
|
||||
// Get route configuration
|
||||
const routeConfig = ROUTE_MAP[validRoute];
|
||||
logger.debug(`[DeepLink] 📋 Route config retrieved:`, routeConfig);
|
||||
|
||||
if (!routeConfig) {
|
||||
logger.error(`[DeepLink] ❌ No route config found for: ${validRoute}`);
|
||||
throw new Error(`Route configuration missing for: ${validRoute}`);
|
||||
}
|
||||
|
||||
routeName = routeConfig.name;
|
||||
logger.debug(`[DeepLink] 🎯 Route name resolved: ${routeName}`);
|
||||
} catch (error) {
|
||||
logger.error(`[DeepLink] Invalid route path: ${path}`);
|
||||
logger.error(`[DeepLink] ❌ Route validation failed:`, {
|
||||
path,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Redirect to error page with information about the invalid link
|
||||
await this.router.replace({
|
||||
@@ -193,21 +228,66 @@ export class DeepLinkHandler {
|
||||
},
|
||||
});
|
||||
|
||||
// This previously threw an error but we're redirecting so there's no need.
|
||||
logger.debug(
|
||||
`[DeepLink] 🔄 Redirected to error page for invalid route: ${path}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with parameter validation as before...
|
||||
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
|
||||
// Continue with parameter validation
|
||||
logger.debug(
|
||||
`[DeepLink] 🔍 Starting parameter validation for route: ${routeName}`,
|
||||
);
|
||||
|
||||
const pathSchema =
|
||||
deepLinkPathSchemas[path as keyof typeof deepLinkPathSchemas];
|
||||
const querySchema =
|
||||
deepLinkQuerySchemas[path as keyof typeof deepLinkQuerySchemas];
|
||||
|
||||
logger.debug(`[DeepLink] 📋 Schemas found:`, {
|
||||
hasPathSchema: !!pathSchema,
|
||||
hasQuerySchema: !!querySchema,
|
||||
pathSchemaType: pathSchema ? typeof pathSchema : "none",
|
||||
querySchemaType: querySchema ? typeof querySchema : "none",
|
||||
});
|
||||
|
||||
let validatedPathParams: Record<string, string> = {};
|
||||
let validatedQueryParams: Record<string, string> = {};
|
||||
|
||||
let validatedParams;
|
||||
try {
|
||||
validatedParams = await schema.parseAsync(params);
|
||||
if (pathSchema) {
|
||||
logger.debug(`[DeepLink] 🔍 Validating path parameters:`, params);
|
||||
validatedPathParams = await pathSchema.parseAsync(params);
|
||||
logger.debug(
|
||||
`[DeepLink] ✅ Path parameters validated:`,
|
||||
validatedPathParams,
|
||||
);
|
||||
} else {
|
||||
logger.debug(`[DeepLink] ⚠️ No path schema found for: ${path}`);
|
||||
validatedPathParams = params;
|
||||
}
|
||||
|
||||
if (querySchema) {
|
||||
logger.debug(`[DeepLink] 🔍 Validating query parameters:`, query);
|
||||
validatedQueryParams = await querySchema.parseAsync(query);
|
||||
logger.debug(
|
||||
`[DeepLink] ✅ Query parameters validated:`,
|
||||
validatedQueryParams,
|
||||
);
|
||||
} else {
|
||||
logger.debug(`[DeepLink] ⚠️ No query schema found for: ${path}`);
|
||||
validatedQueryParams = query;
|
||||
}
|
||||
} catch (error) {
|
||||
// For parameter validation errors, provide specific error feedback
|
||||
logger.error(
|
||||
`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`,
|
||||
);
|
||||
logger.error(`[DeepLink] ❌ Parameter validation failed:`, {
|
||||
routeName,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorDetails: JSON.stringify(error),
|
||||
});
|
||||
|
||||
await this.router.replace({
|
||||
name: "deep-link-error",
|
||||
params,
|
||||
@@ -219,58 +299,52 @@ export class DeepLinkHandler {
|
||||
},
|
||||
});
|
||||
|
||||
// This previously threw an error but we're redirecting so there's no need.
|
||||
logger.debug(
|
||||
`[DeepLink] 🔄 Redirected to error page for invalid parameters`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt navigation
|
||||
try {
|
||||
logger.debug(`[DeepLink] 🚀 Attempting navigation:`, {
|
||||
routeName,
|
||||
pathParams: validatedPathParams,
|
||||
queryParams: validatedQueryParams,
|
||||
});
|
||||
|
||||
await this.router.replace({
|
||||
name: routeName,
|
||||
params: validatedParams,
|
||||
params: validatedPathParams,
|
||||
query: validatedQueryParams,
|
||||
});
|
||||
|
||||
logger.debug(`[DeepLink] ✅ Navigation successful to: ${routeName}`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)}`,
|
||||
);
|
||||
// For parameter validation errors, provide specific error feedback
|
||||
logger.error(`[DeepLink] ❌ Navigation failed:`, {
|
||||
routeName,
|
||||
path,
|
||||
validatedPathParams,
|
||||
validatedQueryParams,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorDetails: JSON.stringify(error),
|
||||
});
|
||||
|
||||
// Redirect to error page for navigation failures
|
||||
await this.router.replace({
|
||||
name: "deep-link-error",
|
||||
params: validatedParams,
|
||||
params: validatedPathParams,
|
||||
query: {
|
||||
originalPath: path,
|
||||
errorCode: "ROUTING_ERROR",
|
||||
errorMessage: `Error routing to ${routeName}: ${JSON.stringify(error)}`,
|
||||
errorMessage: `Error routing to ${routeName}: ${(error as Error).message}`,
|
||||
...validatedQueryParams,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
try {
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
logger.debug(
|
||||
`[DeepLink] 🔄 Redirected to error page for navigation failure`,
|
||||
);
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
} catch (error) {
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
logger.error(
|
||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.details}`,
|
||||
);
|
||||
|
||||
throw {
|
||||
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||
message: deepLinkError.message,
|
||||
details: deepLinkError.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1319,7 +1319,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
await this.dbExec(sql, params);
|
||||
}
|
||||
|
||||
async insertDidSpecificSettings(did: string): Promise<void> {
|
||||
async insertNewDidIntoSettings(did: string): Promise<void> {
|
||||
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
|
||||
}
|
||||
|
||||
|
||||
@@ -681,7 +681,7 @@ export class WebPlatformService implements PlatformService {
|
||||
await this.dbExec(sql, params);
|
||||
}
|
||||
|
||||
async insertDidSpecificSettings(did: string): Promise<void> {
|
||||
async insertNewDidIntoSettings(did: string): Promise<void> {
|
||||
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ const TEST_USER_0_MNEMONIC =
|
||||
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
|
||||
|
||||
export async function testBecomeUser0() {
|
||||
const [addr, privateHex, publicHex, deriPath] = deriveAddress(TEST_USER_0_MNEMONIC);
|
||||
const [addr, privateHex, publicHex, deriPath] =
|
||||
deriveAddress(TEST_USER_0_MNEMONIC);
|
||||
|
||||
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
|
||||
await saveNewIdentity(identity0, TEST_USER_0_MNEMONIC, DEFAULT_ROOT_DERIVATION_PATH);
|
||||
await saveNewIdentity(
|
||||
identity0,
|
||||
TEST_USER_0_MNEMONIC,
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
);
|
||||
const platformService = await PlatformServiceFactory.getInstance();
|
||||
await platformService.updateDidSpecificSettings(identity0.did, {
|
||||
isRegistered: true,
|
||||
@@ -35,7 +40,8 @@ export async function testBecomeUser0() {
|
||||
* @throws Error if registration fails or database access fails
|
||||
*/
|
||||
export async function testServerRegisterUser() {
|
||||
const [addr, privateHex, publicHex, deriPath] = deriveAddress(TEST_USER_0_MNEMONIC);
|
||||
const [addr, privateHex, publicHex, deriPath] =
|
||||
deriveAddress(TEST_USER_0_MNEMONIC);
|
||||
|
||||
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
|
||||
|
||||
@@ -44,6 +50,10 @@ export async function testServerRegisterUser() {
|
||||
"@/db/databaseUtil"
|
||||
);
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const currentDid = settings?.activeDid;
|
||||
if (!currentDid) {
|
||||
throw new Error("No active DID found");
|
||||
}
|
||||
|
||||
// Make a claim
|
||||
const vcClaim = {
|
||||
@@ -51,7 +61,7 @@ export async function testServerRegisterUser() {
|
||||
"@type": "RegisterAction",
|
||||
agent: { identifier: identity0.did },
|
||||
object: SERVICE_ID,
|
||||
participant: { identifier: settings.activeDid },
|
||||
participant: { identifier: currentDid },
|
||||
};
|
||||
|
||||
// Make a payload for the claim
|
||||
@@ -88,5 +98,12 @@ export async function testServerRegisterUser() {
|
||||
|
||||
const resp = await axios.post(url, payload, { headers });
|
||||
logger.log("User registration result:", resp);
|
||||
|
||||
const platformService = await PlatformServiceFactory.getInstance();
|
||||
await platformService.updateDefaultSettings({ activeDid: currentDid });
|
||||
await platformService.updateDidSpecificSettings(currentDid!, {
|
||||
isRegistered: true,
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -437,17 +437,17 @@ export const PlatformServiceMixin = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Utility method for retrieving and parsing settings
|
||||
* Utility method for retrieving master settings
|
||||
* Common pattern used across many components
|
||||
*/
|
||||
async $getSettings(
|
||||
key: string,
|
||||
async $getMasterSettings(
|
||||
fallback: Settings | null = null,
|
||||
): Promise<Settings | null> {
|
||||
try {
|
||||
// Master settings: query by id
|
||||
const result = await this.$dbQuery(
|
||||
"SELECT * FROM settings WHERE id = ? OR accountDid = ?",
|
||||
[key, key],
|
||||
"SELECT * FROM settings WHERE id = ?",
|
||||
[MASTER_SETTINGS_KEY],
|
||||
);
|
||||
|
||||
if (!result?.values?.length) {
|
||||
@@ -472,8 +472,7 @@ export const PlatformServiceMixin = {
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error(`[Settings Trace] ❌ Failed to get settings:`, {
|
||||
key,
|
||||
logger.error(`[Settings Trace] ❌ Failed to get master settings:`, {
|
||||
error,
|
||||
});
|
||||
return fallback;
|
||||
@@ -491,10 +490,7 @@ export const PlatformServiceMixin = {
|
||||
): Promise<Settings> {
|
||||
try {
|
||||
// Get default settings
|
||||
const defaultSettings = await this.$getSettings(
|
||||
defaultKey,
|
||||
defaultFallback,
|
||||
);
|
||||
const defaultSettings = await this.$getMasterSettings(defaultFallback);
|
||||
|
||||
// If no account DID, return defaults
|
||||
if (!accountDid) {
|
||||
@@ -757,19 +753,20 @@ export const PlatformServiceMixin = {
|
||||
* @returns Fresh settings object from database
|
||||
*/
|
||||
async $settings(defaults: Settings = {}): Promise<Settings> {
|
||||
const settings = await this.$getSettings(MASTER_SETTINGS_KEY, defaults);
|
||||
const settings = await this.$getMasterSettings(defaults);
|
||||
|
||||
if (!settings) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// **ELECTRON-SPECIFIC FIX**: Apply platform-specific API server override
|
||||
// This ensures Electron always uses production endpoints regardless of cached settings
|
||||
if (process.env.VITE_PLATFORM === "electron") {
|
||||
// FIXED: Remove forced override - respect user preferences
|
||||
// Only set default if no user preference exists
|
||||
if (!settings.apiServer && process.env.VITE_PLATFORM === "electron") {
|
||||
// Import constants dynamically to get platform-specific values
|
||||
const { DEFAULT_ENDORSER_API_SERVER } = await import(
|
||||
"../constants/app"
|
||||
);
|
||||
// Only set if user hasn't specified a preference
|
||||
settings.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
||||
}
|
||||
|
||||
@@ -789,10 +786,7 @@ export const PlatformServiceMixin = {
|
||||
): Promise<Settings> {
|
||||
try {
|
||||
// Get default settings first
|
||||
const defaultSettings = await this.$getSettings(
|
||||
MASTER_SETTINGS_KEY,
|
||||
defaults,
|
||||
);
|
||||
const defaultSettings = await this.$getMasterSettings(defaults);
|
||||
|
||||
if (!defaultSettings) {
|
||||
return defaults;
|
||||
@@ -813,14 +807,17 @@ export const PlatformServiceMixin = {
|
||||
defaultSettings,
|
||||
);
|
||||
|
||||
// **ELECTRON-SPECIFIC FIX**: Force production API endpoints for Electron
|
||||
// This ensures Electron doesn't use localhost development servers that might be saved in user settings
|
||||
if (process.env.VITE_PLATFORM === "electron") {
|
||||
// FIXED: Remove forced override - respect user preferences
|
||||
// Only set default if no user preference exists
|
||||
if (
|
||||
!mergedSettings.apiServer &&
|
||||
process.env.VITE_PLATFORM === "electron"
|
||||
) {
|
||||
// Import constants dynamically to get platform-specific values
|
||||
const { DEFAULT_ENDORSER_API_SERVER } = await import(
|
||||
"../constants/app"
|
||||
);
|
||||
|
||||
// Only set if user hasn't specified a preference
|
||||
mergedSettings.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
||||
}
|
||||
|
||||
@@ -1574,10 +1571,7 @@ export const PlatformServiceMixin = {
|
||||
async $debugMergedSettings(did: string): Promise<void> {
|
||||
try {
|
||||
// Get default settings
|
||||
const defaultSettings = await this.$getSettings(
|
||||
MASTER_SETTINGS_KEY,
|
||||
{},
|
||||
);
|
||||
const defaultSettings = await this.$getMasterSettings({});
|
||||
logger.info(
|
||||
`[PlatformServiceMixin] Default settings:`,
|
||||
defaultSettings,
|
||||
@@ -1624,10 +1618,7 @@ export interface IPlatformServiceMixin {
|
||||
): Promise<QueryExecResult | undefined>;
|
||||
$dbExec(sql: string, params?: unknown[]): Promise<DatabaseExecResult>;
|
||||
$dbGetOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
|
||||
$getSettings(
|
||||
key: string,
|
||||
fallback?: Settings | null,
|
||||
): Promise<Settings | null>;
|
||||
$getMasterSettings(fallback?: Settings | null): Promise<Settings | null>;
|
||||
$getMergedSettings(
|
||||
defaultKey: string,
|
||||
accountDid?: string,
|
||||
@@ -1749,10 +1740,7 @@ declare module "@vue/runtime-core" {
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<unknown[] | undefined>;
|
||||
$getSettings(
|
||||
key: string,
|
||||
defaults?: Settings | null,
|
||||
): Promise<Settings | null>;
|
||||
$getMasterSettings(defaults?: Settings | null): Promise<Settings | null>;
|
||||
$getMergedSettings(
|
||||
key: string,
|
||||
did?: string,
|
||||
|
||||
298
src/utils/errorHandler.ts
Normal file
298
src/utils/errorHandler.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Standardized Error Handler
|
||||
*
|
||||
* Provides consistent error handling patterns across the TimeSafari codebase
|
||||
* to improve debugging, user experience, and maintainability.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Standard error context for consistent logging
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
component: string;
|
||||
operation: string;
|
||||
timestamp: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error information for better debugging
|
||||
*/
|
||||
export interface EnhancedErrorInfo {
|
||||
errorType: "AxiosError" | "NetworkError" | "ValidationError" | "UnknownError";
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
errorData?: unknown;
|
||||
errorMessage: string;
|
||||
errorStack?: string;
|
||||
requestContext?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error handler for API operations
|
||||
*
|
||||
* @param error - The error that occurred
|
||||
* @param context - Context information about the operation
|
||||
* @param operation - Description of the operation being performed
|
||||
* @returns Enhanced error information for consistent handling
|
||||
*/
|
||||
export function handleApiError(
|
||||
error: unknown,
|
||||
context: ErrorContext,
|
||||
operation: string,
|
||||
): EnhancedErrorInfo {
|
||||
const baseContext = {
|
||||
...context,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const axiosError = error as AxiosError;
|
||||
const status = axiosError.response?.status;
|
||||
const statusText = axiosError.response?.statusText;
|
||||
const errorData = axiosError.response?.data;
|
||||
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "AxiosError",
|
||||
status,
|
||||
statusText,
|
||||
errorData,
|
||||
errorMessage: axiosError.message,
|
||||
errorStack: axiosError.stack,
|
||||
requestContext: {
|
||||
url: axiosError.config?.url,
|
||||
method: axiosError.config?.method,
|
||||
headers: axiosError.config?.headers,
|
||||
},
|
||||
};
|
||||
|
||||
// Log with consistent format
|
||||
logger.error(
|
||||
`[${context.component}] ❌ ${operation} failed (AxiosError):`,
|
||||
{
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
},
|
||||
);
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "UnknownError",
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
};
|
||||
|
||||
logger.error(`[${context.component}] ❌ ${operation} failed (Error):`, {
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
});
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
// Handle unknown error types
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "UnknownError",
|
||||
errorMessage: String(error),
|
||||
};
|
||||
|
||||
logger.error(`[${context.component}] ❌ ${operation} failed (Unknown):`, {
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
});
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from various error response formats
|
||||
*
|
||||
* @param errorData - Error response data
|
||||
* @returns Human-readable error message
|
||||
*/
|
||||
export function extractErrorMessage(errorData: unknown): string {
|
||||
if (typeof errorData === "string") {
|
||||
return errorData;
|
||||
}
|
||||
|
||||
if (typeof errorData === "object" && errorData !== null) {
|
||||
const obj = errorData as Record<string, unknown>;
|
||||
|
||||
// Try common error message fields
|
||||
if (obj.message && typeof obj.message === "string") {
|
||||
return obj.message;
|
||||
}
|
||||
|
||||
if (obj.error && typeof obj.error === "string") {
|
||||
return obj.error;
|
||||
}
|
||||
|
||||
if (obj.detail && typeof obj.detail === "string") {
|
||||
return obj.detail;
|
||||
}
|
||||
|
||||
if (obj.reason && typeof obj.reason === "string") {
|
||||
return obj.reason;
|
||||
}
|
||||
|
||||
// Fallback to stringified object
|
||||
return JSON.stringify(errorData);
|
||||
}
|
||||
|
||||
return String(errorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-friendly error message from enhanced error info
|
||||
*
|
||||
* @param errorInfo - Enhanced error information
|
||||
* @param fallbackMessage - Fallback message if error details are insufficient
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function createUserMessage(
|
||||
errorInfo: EnhancedErrorInfo,
|
||||
fallbackMessage: string,
|
||||
): string {
|
||||
if (errorInfo.errorType === "AxiosError") {
|
||||
const status = errorInfo.status;
|
||||
const statusText = errorInfo.statusText;
|
||||
const errorMessage = extractErrorMessage(errorInfo.errorData);
|
||||
|
||||
if (status && statusText) {
|
||||
if (errorMessage && errorMessage !== "{}") {
|
||||
return `${fallbackMessage}: ${status} ${statusText} - ${errorMessage}`;
|
||||
}
|
||||
return `${fallbackMessage}: ${status} ${statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
errorInfo.errorMessage &&
|
||||
errorInfo.errorMessage !== "Request failed with status code 0"
|
||||
) {
|
||||
return `${fallbackMessage}: ${errorInfo.errorMessage}`;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle specific HTTP status codes with appropriate user messages
|
||||
*
|
||||
* @param status - HTTP status code
|
||||
* @param errorData - Error response data
|
||||
* @param operation - Description of the operation
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function handleHttpStatus(
|
||||
status: number,
|
||||
errorData: unknown,
|
||||
operation: string,
|
||||
): string {
|
||||
const errorMessage = extractErrorMessage(errorData);
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return errorMessage || `${operation} failed: Bad request`;
|
||||
case 401:
|
||||
return `${operation} failed: Authentication required`;
|
||||
case 403:
|
||||
return `${operation} failed: Access denied`;
|
||||
case 404:
|
||||
return errorMessage || `${operation} failed: Resource not found`;
|
||||
case 409:
|
||||
return errorMessage || `${operation} failed: Conflict with existing data`;
|
||||
case 422:
|
||||
return errorMessage || `${operation} failed: Validation error`;
|
||||
case 429:
|
||||
return `${operation} failed: Too many requests. Please try again later.`;
|
||||
case 500:
|
||||
return `${operation} failed: Server error. Please try again later.`;
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return `${operation} failed: Service temporarily unavailable. Please try again later.`;
|
||||
default:
|
||||
return errorMessage || `${operation} failed: HTTP ${status}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network-related error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is network-related
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof AxiosError) {
|
||||
return !error.response && !error.request;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("network") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("connection") ||
|
||||
message.includes("fetch")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a timeout error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is a timeout
|
||||
*/
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof AxiosError) {
|
||||
return (
|
||||
error.code === "ECONNABORTED" ||
|
||||
error.message.toLowerCase().includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message.toLowerCase().includes("timeout");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized error context for components
|
||||
*
|
||||
* @param component - Component name
|
||||
* @param operation - Operation being performed
|
||||
* @param additionalContext - Additional context information
|
||||
* @returns Standardized error context
|
||||
*/
|
||||
export function createErrorContext(
|
||||
component: string,
|
||||
operation: string,
|
||||
additionalContext: Record<string, unknown> = {},
|
||||
): ErrorContext {
|
||||
return {
|
||||
component,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
...additionalContext,
|
||||
};
|
||||
}
|
||||
482
src/utils/performanceOptimizer.ts
Normal file
482
src/utils/performanceOptimizer.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Performance Optimizer
|
||||
*
|
||||
* Provides utilities for optimizing API calls, database queries, and component
|
||||
* rendering to improve TimeSafari application performance.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Batch operation configuration
|
||||
*/
|
||||
export interface BatchConfig {
|
||||
maxBatchSize: number;
|
||||
maxWaitTime: number;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default batch configuration
|
||||
*/
|
||||
export const DEFAULT_BATCH_CONFIG: BatchConfig = {
|
||||
maxBatchSize: 10,
|
||||
maxWaitTime: 100, // milliseconds
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000, // milliseconds
|
||||
};
|
||||
|
||||
/**
|
||||
* Batched operation item
|
||||
*/
|
||||
export interface BatchItem<T, R> {
|
||||
id: string;
|
||||
data: T;
|
||||
resolve: (value: R) => void;
|
||||
reject: (error: Error) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processor for API operations
|
||||
*
|
||||
* Groups multiple similar operations into batches to reduce
|
||||
* the number of API calls and improve performance.
|
||||
*/
|
||||
export class BatchProcessor<T, R> {
|
||||
private items: BatchItem<T, R>[] = [];
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private processing = false;
|
||||
private config: BatchConfig;
|
||||
|
||||
constructor(
|
||||
private batchHandler: (items: T[]) => Promise<R[]>,
|
||||
private itemIdExtractor: (item: T) => string,
|
||||
config: Partial<BatchConfig> = {},
|
||||
) {
|
||||
this.config = { ...DEFAULT_BATCH_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the batch
|
||||
*
|
||||
* @param data - Data to process
|
||||
* @returns Promise that resolves when the item is processed
|
||||
*/
|
||||
async add(data: T): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item: BatchItem<T, R> = {
|
||||
id: this.itemIdExtractor(data),
|
||||
data,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
// Start timer if this is the first item
|
||||
if (this.items.length === 1) {
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
// Process immediately if batch is full
|
||||
if (this.items.length >= this.config.maxBatchSize) {
|
||||
this.processBatch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the batch timer
|
||||
*/
|
||||
private startTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.processBatch();
|
||||
}, this.config.maxWaitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the current batch
|
||||
*/
|
||||
private async processBatch(): Promise<void> {
|
||||
if (this.processing || this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
// Clear timer
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
// Get current batch
|
||||
const currentItems = [...this.items];
|
||||
this.items = [];
|
||||
|
||||
try {
|
||||
logger.debug("[BatchProcessor] 🔄 Processing batch:", {
|
||||
batchSize: currentItems.length,
|
||||
itemIds: currentItems.map((item) => item.id),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Process batch
|
||||
const results = await this.batchHandler(
|
||||
currentItems.map((item) => item.data),
|
||||
);
|
||||
|
||||
// Map results back to items
|
||||
const resultMap = new Map<string, R>();
|
||||
results.forEach((result, index) => {
|
||||
const item = currentItems[index];
|
||||
if (item) {
|
||||
resultMap.set(item.id, result);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve promises
|
||||
currentItems.forEach((item) => {
|
||||
const result = resultMap.get(item.id);
|
||||
if (result !== undefined) {
|
||||
item.resolve(result);
|
||||
} else {
|
||||
item.reject(new Error(`No result found for item ${item.id}`));
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug("[BatchProcessor] ✅ Batch processed successfully:", {
|
||||
batchSize: currentItems.length,
|
||||
resultsCount: results.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[BatchProcessor] ❌ Batch processing failed:", {
|
||||
batchSize: currentItems.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Reject all items in the batch
|
||||
currentItems.forEach((item) => {
|
||||
item.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
} finally {
|
||||
this.processing = false;
|
||||
|
||||
// Start timer for remaining items if any
|
||||
if (this.items.length > 0) {
|
||||
this.startTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current batch status
|
||||
*/
|
||||
getStatus(): {
|
||||
pendingItems: number;
|
||||
isProcessing: boolean;
|
||||
hasTimer: boolean;
|
||||
} {
|
||||
return {
|
||||
pendingItems: this.items.length,
|
||||
isProcessing: this.processing,
|
||||
hasTimer: this.timer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending items
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
// Reject all pending items
|
||||
this.items.forEach((item) => {
|
||||
item.reject(new Error("Batch processor cleared"));
|
||||
});
|
||||
|
||||
this.items = [];
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database query optimizer
|
||||
*
|
||||
* Provides utilities for optimizing database queries and reducing
|
||||
* the number of database operations.
|
||||
*/
|
||||
export class DatabaseOptimizer {
|
||||
/**
|
||||
* Batch multiple SELECT queries into a single query
|
||||
*
|
||||
* @param baseQuery - Base SELECT query
|
||||
* @param ids - Array of IDs to query
|
||||
* @param idColumn - Name of the ID column
|
||||
* @returns Optimized query string
|
||||
*/
|
||||
static batchSelectQuery(
|
||||
baseQuery: string,
|
||||
ids: (string | number)[],
|
||||
idColumn: string,
|
||||
): string {
|
||||
if (ids.length === 0) {
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
return `${baseQuery} WHERE ${idColumn} = ?`;
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
return `${baseQuery} WHERE ${idColumn} IN (${placeholders})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a query plan for multiple operations
|
||||
*
|
||||
* @param operations - Array of database operations
|
||||
* @returns Optimized query plan
|
||||
*/
|
||||
static createQueryPlan(
|
||||
operations: Array<{
|
||||
type: "SELECT" | "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
priority: number;
|
||||
}>,
|
||||
): Array<{
|
||||
type: "SELECT" | "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
priority: number;
|
||||
batchable: boolean;
|
||||
}> {
|
||||
return operations
|
||||
.map((op) => ({
|
||||
...op,
|
||||
batchable: op.type === "SELECT" || op.type === "INSERT",
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Sort by priority first, then by type
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
|
||||
// SELECT operations first, then INSERT, UPDATE, DELETE
|
||||
const typeOrder = { SELECT: 0, INSERT: 1, UPDATE: 2, DELETE: 3 };
|
||||
return typeOrder[a.type] - typeOrder[b.type];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component rendering optimizer
|
||||
*
|
||||
* Provides utilities for optimizing Vue component rendering
|
||||
* and reducing unnecessary re-renders.
|
||||
*/
|
||||
export class ComponentOptimizer {
|
||||
/**
|
||||
* Debounce function calls to prevent excessive execution
|
||||
*
|
||||
* @param func - Function to debounce
|
||||
* @param wait - Wait time in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
static debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function calls to limit execution frequency
|
||||
*
|
||||
* @param func - Function to throttle
|
||||
* @param limit - Time limit in milliseconds
|
||||
* @returns Throttled function
|
||||
*/
|
||||
static throttle<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
limit: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoize function results to avoid redundant computation
|
||||
*
|
||||
* @param func - Function to memoize
|
||||
* @param keyGenerator - Function to generate cache keys
|
||||
* @returns Memoized function
|
||||
*/
|
||||
static memoize<T extends (...args: unknown[]) => unknown, K>(
|
||||
func: T,
|
||||
keyGenerator: (...args: Parameters<T>) => K,
|
||||
): T {
|
||||
const cache = new Map<K, unknown>();
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
const key = keyGenerator(...args);
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const result = func(...args);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}) as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring utility
|
||||
*
|
||||
* Tracks and reports performance metrics for optimization analysis.
|
||||
*/
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private metrics = new Map<
|
||||
string,
|
||||
Array<{ timestamp: number; duration: number }>
|
||||
>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing an operation
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @returns Function to call when operation completes
|
||||
*/
|
||||
startTiming(operationName: string): () => void {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const duration = performance.now() - startTime;
|
||||
this.recordMetric(operationName, duration);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @param duration - Duration in milliseconds
|
||||
*/
|
||||
private recordMetric(operationName: string, duration: number): void {
|
||||
if (!this.metrics.has(operationName)) {
|
||||
this.metrics.set(operationName, []);
|
||||
}
|
||||
|
||||
const operationMetrics = this.metrics.get(operationName)!;
|
||||
operationMetrics.push({
|
||||
timestamp: Date.now(),
|
||||
duration,
|
||||
});
|
||||
|
||||
// Keep only last 100 metrics per operation
|
||||
if (operationMetrics.length > 100) {
|
||||
operationMetrics.splice(0, operationMetrics.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance summary for an operation
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @returns Performance statistics
|
||||
*/
|
||||
getPerformanceSummary(operationName: string): {
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
recentAverage: number;
|
||||
} | null {
|
||||
const metrics = this.metrics.get(operationName);
|
||||
if (!metrics || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durations = metrics.map((m) => m.duration);
|
||||
const recentMetrics = metrics.slice(-10); // Last 10 metrics
|
||||
|
||||
return {
|
||||
count: metrics.length,
|
||||
average: durations.reduce((a, b) => a + b, 0) / durations.length,
|
||||
min: Math.min(...durations),
|
||||
max: Math.max(...durations),
|
||||
recentAverage:
|
||||
recentMetrics.reduce((a, b) => a + b.duration, 0) /
|
||||
recentMetrics.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all performance metrics
|
||||
*/
|
||||
getAllMetrics(): Map<string, Array<{ timestamp: number; duration: number }>> {
|
||||
return new Map(this.metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all performance metrics
|
||||
*/
|
||||
clearMetrics(): void {
|
||||
this.metrics.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the performance monitor
|
||||
*/
|
||||
export const getPerformanceMonitor = (): PerformanceMonitor => {
|
||||
return PerformanceMonitor.getInstance();
|
||||
};
|
||||
226
src/utils/safeAreaInset.js
Normal file
226
src/utils/safeAreaInset.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Safe Area Inset Injection for Android WebView
|
||||
*
|
||||
* This script injects safe area inset values into CSS environment variables
|
||||
* when running in Android WebView, since Android doesn't natively support
|
||||
* CSS env(safe-area-inset-*) variables like iOS does.
|
||||
*/
|
||||
|
||||
// Check if we're running in Android WebView with Capacitor
|
||||
const isAndroidWebView = () => {
|
||||
// Check if we're on iOS - if so, skip this script entirely
|
||||
const isIOS =
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
|
||||
if (isIOS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're on Android
|
||||
const isAndroid = /Android/.test(navigator.userAgent);
|
||||
|
||||
// Check if we have Capacitor (required for Android WebView)
|
||||
const hasCapacitor = window.Capacitor !== undefined;
|
||||
|
||||
// Only run on Android with Capacitor
|
||||
return isAndroid && hasCapacitor;
|
||||
};
|
||||
|
||||
// Wait for Capacitor to be available
|
||||
const waitForCapacitor = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (window.Capacitor) {
|
||||
resolve(window.Capacitor);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Capacitor to be available
|
||||
const checkCapacitor = () => {
|
||||
if (window.Capacitor) {
|
||||
resolve(window.Capacitor);
|
||||
} else {
|
||||
setTimeout(checkCapacitor, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkCapacitor();
|
||||
});
|
||||
};
|
||||
|
||||
// Inject safe area inset values into CSS custom properties
|
||||
const injectSafeAreaInsets = async () => {
|
||||
try {
|
||||
// Wait for Capacitor to be available
|
||||
const Capacitor = await waitForCapacitor();
|
||||
|
||||
// Try to get safe area insets using StatusBar plugin (which is already available)
|
||||
|
||||
let top = 0,
|
||||
bottom = 0,
|
||||
left = 0,
|
||||
right = 0;
|
||||
|
||||
try {
|
||||
// Use StatusBar plugin to get status bar height
|
||||
if (Capacitor.Plugins.StatusBar) {
|
||||
const statusBarInfo = await Capacitor.Plugins.StatusBar.getInfo();
|
||||
// Status bar height is typically the top safe area inset
|
||||
top = statusBarInfo.overlays ? 0 : statusBarInfo.height || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
// Status bar info not available, will use fallback
|
||||
}
|
||||
|
||||
// Detect navigation bar and gesture bar heights
|
||||
const detectNavigationBar = () => {
|
||||
const screenHeight = window.screen.height;
|
||||
const screenWidth = window.screen.width;
|
||||
const windowHeight = window.innerHeight;
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
// Calculate navigation bar height
|
||||
let navBarHeight = 0;
|
||||
|
||||
// Method 1: Direct comparison (most reliable)
|
||||
if (windowHeight < screenHeight) {
|
||||
navBarHeight = screenHeight - windowHeight;
|
||||
}
|
||||
|
||||
// Method 2: Check for gesture navigation indicators
|
||||
if (navBarHeight === 0) {
|
||||
// Look for common gesture navigation patterns
|
||||
const isTallDevice = screenHeight > 2000;
|
||||
const isModernDevice = screenHeight > 1800;
|
||||
const hasHighDensity = devicePixelRatio >= 2.5;
|
||||
|
||||
if (isTallDevice && hasHighDensity) {
|
||||
// Modern gesture-based device
|
||||
navBarHeight = 12; // Typical gesture bar height
|
||||
} else if (isModernDevice) {
|
||||
// Modern device with traditional navigation
|
||||
navBarHeight = 48; // Traditional navigation bar height
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check visual viewport (more accurate for WebView)
|
||||
if (navBarHeight === 0) {
|
||||
if (window.visualViewport) {
|
||||
const visualHeight = window.visualViewport.height;
|
||||
|
||||
if (visualHeight < windowHeight) {
|
||||
navBarHeight = windowHeight - visualHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Device-specific estimation based on screen dimensions
|
||||
if (navBarHeight === 0) {
|
||||
// Common Android navigation bar heights in pixels
|
||||
const commonNavBarHeights = {
|
||||
"1080x2400": 48, // Common 1080p devices
|
||||
"1440x3200": 64, // QHD devices
|
||||
"720x1600": 32, // HD devices
|
||||
};
|
||||
|
||||
const resolution = `${screenWidth}x${screenHeight}`;
|
||||
const estimatedHeight = commonNavBarHeights[resolution];
|
||||
|
||||
if (estimatedHeight) {
|
||||
navBarHeight = estimatedHeight;
|
||||
} else {
|
||||
// Fallback: estimate based on screen height
|
||||
navBarHeight = screenHeight > 2000 ? 48 : 32;
|
||||
}
|
||||
}
|
||||
|
||||
return navBarHeight;
|
||||
};
|
||||
|
||||
// Get navigation bar height
|
||||
bottom = detectNavigationBar();
|
||||
|
||||
// If we still don't have a top value, estimate it
|
||||
if (top === 0) {
|
||||
const screenHeight = window.screen.height;
|
||||
// Common status bar heights: 24dp (48px) for most devices, 32dp (64px) for some
|
||||
top = screenHeight > 1920 ? 64 : 48;
|
||||
}
|
||||
|
||||
// Left/right safe areas are rare on Android
|
||||
left = 0;
|
||||
right = 0;
|
||||
|
||||
// Create CSS custom properties
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
:root {
|
||||
--safe-area-inset-top: ${top}px;
|
||||
--safe-area-inset-bottom: ${bottom}px;
|
||||
--safe-area-inset-left: ${left}px;
|
||||
--safe-area-inset-right: ${right}px;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject the style into the document head
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Also set CSS environment variables if supported
|
||||
if (CSS.supports("env(safe-area-inset-top)")) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--env-safe-area-inset-top",
|
||||
`${top}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--env-safe-area-inset-bottom",
|
||||
`${bottom}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--env-safe-area-inset-left",
|
||||
`${left}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--env-safe-area-inset-right",
|
||||
`${right}px`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error injecting safe area insets, will use fallback values
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
const initializeSafeArea = () => {
|
||||
// Check if we should run this script at all
|
||||
if (!isAndroidWebView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a small delay to ensure WebView is fully initialized
|
||||
setTimeout(() => {
|
||||
injectSafeAreaInsets();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initializeSafeArea);
|
||||
} else {
|
||||
initializeSafeArea();
|
||||
}
|
||||
|
||||
// Re-inject on orientation change (only on Android)
|
||||
window.addEventListener("orientationchange", () => {
|
||||
if (isAndroidWebView()) {
|
||||
setTimeout(() => injectSafeAreaInsets(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-inject on resize (only on Android)
|
||||
window.addEventListener("resize", () => {
|
||||
if (isAndroidWebView()) {
|
||||
setTimeout(() => injectSafeAreaInsets(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { injectSafeAreaInsets, isAndroidWebView };
|
||||
@@ -55,9 +55,13 @@
|
||||
|
||||
<!-- Registration notice -->
|
||||
<RegistrationNotice
|
||||
:is-registered="isRegistered"
|
||||
:show="showRegistrationNotice"
|
||||
@share-info="onShareInfo"
|
||||
v-if="!isRegistered"
|
||||
:passkeys-enabled="PASSKEYS_ENABLED"
|
||||
:given-name="givenName"
|
||||
:message="
|
||||
`Before you can publicly announce a new project or time commitment, ` +
|
||||
`a friend needs to register you.`
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Notifications -->
|
||||
@@ -174,11 +178,12 @@
|
||||
:aria-busy="loadingProfile || savingProfile"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center mb-4" @click="toggleUserProfileLocation">
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
v-model="includeUserProfileLocation"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
@change="onLocationCheckboxChange"
|
||||
/>
|
||||
<label for="includeUserProfileLocation">Include Location</label>
|
||||
</div>
|
||||
@@ -194,6 +199,7 @@
|
||||
class="!z-40 rounded-md"
|
||||
@click="onProfileMapClick"
|
||||
@ready="onMapReady"
|
||||
@mounted="onMapMounted"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
@@ -748,9 +754,11 @@ import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { Buffer } from "buffer/";
|
||||
import "dexie-export-import";
|
||||
|
||||
// @ts-expect-error - they aren't exporting it but it's there
|
||||
import { ImportProgress } from "dexie-export-import";
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import * as L from "leaflet";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { ref } from "vue";
|
||||
@@ -778,6 +786,7 @@ import {
|
||||
DEFAULT_PUSH_SERVER,
|
||||
IMAGE_TYPE_PROFILE,
|
||||
NotificationIface,
|
||||
PASSKEYS_ENABLED,
|
||||
} from "../constants/app";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
@@ -807,11 +816,13 @@ import {
|
||||
isApiError,
|
||||
ImportContent,
|
||||
} from "@/interfaces/accountView";
|
||||
import {
|
||||
ProfileService,
|
||||
createProfileService,
|
||||
ProfileData,
|
||||
} from "@/services/ProfileService";
|
||||
// Profile data interface (inlined from ProfileService)
|
||||
interface ProfileData {
|
||||
description: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
includeLocation: boolean;
|
||||
}
|
||||
|
||||
const inputImportFileNameRef = ref<Blob>();
|
||||
|
||||
@@ -848,6 +859,7 @@ export default class AccountViewView extends Vue {
|
||||
readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER;
|
||||
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
|
||||
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
|
||||
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
||||
|
||||
// Identity and settings properties
|
||||
activeDid: string = "";
|
||||
@@ -902,17 +914,37 @@ export default class AccountViewView extends Vue {
|
||||
warnIfProdServer: boolean = false;
|
||||
warnIfTestServer: boolean = false;
|
||||
zoom: number = 2;
|
||||
isMapReady: boolean = false;
|
||||
|
||||
// Limits and validation properties
|
||||
endorserLimits: EndorserRateLimits | null = null;
|
||||
imageLimits: ImageRateLimits | null = null;
|
||||
limitsMessage: string = "";
|
||||
|
||||
private profileService!: ProfileService;
|
||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
// Fix Leaflet icon issues in modern bundlers
|
||||
// This prevents the "Cannot read properties of undefined (reading 'Default')" error
|
||||
if (L.Icon.Default) {
|
||||
// Type-safe way to handle Leaflet icon prototype
|
||||
const iconDefault = L.Icon.Default.prototype as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if ("_getIconUrl" in iconDefault) {
|
||||
delete iconDefault._getIconUrl;
|
||||
}
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl:
|
||||
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
|
||||
shadowUrl:
|
||||
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -923,26 +955,36 @@ export default class AccountViewView extends Vue {
|
||||
* @throws Will display specific messages to the user based on different errors.
|
||||
*/
|
||||
async mounted(): Promise<void> {
|
||||
this.profileService = createProfileService(
|
||||
this.axios,
|
||||
this.partnerApiServer,
|
||||
);
|
||||
try {
|
||||
await this.initializeState();
|
||||
await this.processIdentity();
|
||||
|
||||
// Profile service logic now inlined - no need for external service
|
||||
logger.debug(
|
||||
"[AccountViewView] Profile logic ready with partnerApiServer:",
|
||||
{
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
},
|
||||
);
|
||||
|
||||
if (this.isRegistered) {
|
||||
try {
|
||||
const profile = await this.profileService.loadProfile(this.activeDid);
|
||||
const profile = await this.loadProfile(this.activeDid);
|
||||
if (profile) {
|
||||
this.userProfileDesc = profile.description;
|
||||
this.userProfileLatitude = profile.latitude;
|
||||
this.userProfileLongitude = profile.longitude;
|
||||
this.includeUserProfileLocation = profile.includeLocation;
|
||||
|
||||
// Initialize map ready state if location is included
|
||||
if (profile.includeLocation) {
|
||||
this.isMapReady = false; // Will be set to true when map is ready
|
||||
}
|
||||
} else {
|
||||
// Profile not created yet; leave defaults
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error loading profile:", error);
|
||||
this.notify.error(
|
||||
ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_AVAILABLE,
|
||||
);
|
||||
@@ -1380,21 +1422,24 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
async checkLimits(): Promise<void> {
|
||||
this.loadingLimits = true;
|
||||
const did = this.activeDid;
|
||||
if (!did) {
|
||||
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const did = this.activeDid;
|
||||
|
||||
if (!did) {
|
||||
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$saveUserSettings(did, {
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
webPushServer: this.webPushServer,
|
||||
});
|
||||
|
||||
const imageResp = await fetchImageRateLimits(this.axios, did);
|
||||
const imageResp = await fetchImageRateLimits(
|
||||
this.axios,
|
||||
did,
|
||||
this.DEFAULT_IMAGE_API_SERVER,
|
||||
);
|
||||
|
||||
if (imageResp.status === 200) {
|
||||
this.imageLimits = imageResp.data;
|
||||
@@ -1420,7 +1465,26 @@ export default class AccountViewView extends Vue {
|
||||
} catch (error) {
|
||||
this.limitsMessage =
|
||||
ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS;
|
||||
logger.error("Error retrieving limits: ", error);
|
||||
|
||||
// Enhanced error logging with server context
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
logger.error("[Server Limits] Error retrieving limits:", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
did: did,
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
errorCode: axiosError?.response?.data?.error?.code,
|
||||
errorMessage: axiosError?.response?.data?.error?.message,
|
||||
httpStatus: axiosError?.response?.status,
|
||||
needsUserMigration: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
|
||||
} finally {
|
||||
this.loadingLimits = false;
|
||||
@@ -1428,24 +1492,70 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
|
||||
async onClickSaveApiServer(): Promise<void> {
|
||||
await this.$saveSettings({
|
||||
apiServer: this.apiServerInput,
|
||||
// Enhanced diagnostic logging for claim URL changes
|
||||
const previousApiServer = this.apiServer;
|
||||
const newApiServer = this.apiServerInput;
|
||||
|
||||
logger.debug("[Server Switching] Claim URL change initiated:", {
|
||||
did: this.activeDid,
|
||||
previousServer: previousApiServer,
|
||||
newServer: newApiServer,
|
||||
changeType: "apiServer",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.apiServer = this.apiServerInput;
|
||||
|
||||
await this.$saveSettings({
|
||||
apiServer: newApiServer,
|
||||
});
|
||||
this.apiServer = newApiServer;
|
||||
|
||||
// Add this line to save to user-specific settings
|
||||
await this.$saveUserSettings(this.activeDid, {
|
||||
apiServer: this.apiServer,
|
||||
});
|
||||
|
||||
// Log successful server switch
|
||||
logger.debug("[Server Switching] Claim URL change completed:", {
|
||||
did: this.activeDid,
|
||||
previousServer: previousApiServer,
|
||||
newServer: newApiServer,
|
||||
changeType: "apiServer",
|
||||
settingsSaved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async onClickSavePartnerServer(): Promise<void> {
|
||||
await this.$saveSettings({
|
||||
partnerApiServer: this.partnerApiServerInput,
|
||||
// Enhanced diagnostic logging for partner server changes
|
||||
const previousPartnerServer = this.partnerApiServer;
|
||||
const newPartnerServer = this.partnerApiServerInput;
|
||||
|
||||
logger.debug("[Server Switching] Partner server change initiated:", {
|
||||
did: this.activeDid,
|
||||
previousServer: previousPartnerServer,
|
||||
newServer: newPartnerServer,
|
||||
changeType: "partnerApiServer",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.partnerApiServer = this.partnerApiServerInput;
|
||||
|
||||
await this.$saveSettings({
|
||||
partnerApiServer: newPartnerServer,
|
||||
});
|
||||
this.partnerApiServer = newPartnerServer;
|
||||
|
||||
await this.$saveUserSettings(this.activeDid, {
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
});
|
||||
|
||||
// Log successful partner server switch
|
||||
logger.debug("[Server Switching] Partner server change completed:", {
|
||||
did: this.activeDid,
|
||||
previousServer: previousPartnerServer,
|
||||
newServer: newPartnerServer,
|
||||
changeType: "partnerApiServer",
|
||||
settingsSaved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async onClickSavePushServer(): Promise<void> {
|
||||
@@ -1518,9 +1628,46 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
|
||||
onMapReady(map: L.Map): void {
|
||||
// doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup
|
||||
const zoom = this.userProfileLatitude && this.userProfileLongitude ? 12 : 2;
|
||||
map.setView([this.userProfileLatitude, this.userProfileLongitude], zoom);
|
||||
try {
|
||||
// doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup
|
||||
const zoom =
|
||||
this.userProfileLatitude && this.userProfileLongitude ? 12 : 2;
|
||||
const lat = this.userProfileLatitude || 0;
|
||||
const lng = this.userProfileLongitude || 0;
|
||||
map.setView([lat, lng], zoom);
|
||||
this.isMapReady = true;
|
||||
logger.debug(
|
||||
"Map ready state set to true, coordinates:",
|
||||
[lat, lng],
|
||||
"zoom:",
|
||||
zoom,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error in onMapReady:", error);
|
||||
this.isMapReady = true; // Set to true even on error to prevent infinite loading
|
||||
}
|
||||
}
|
||||
|
||||
onMapMounted(): void {
|
||||
logger.debug("Map component mounted");
|
||||
// Check if map ref is available
|
||||
const mapRef = this.$refs.profileMap;
|
||||
logger.debug("Map ref:", mapRef);
|
||||
|
||||
// Try to set map ready after component is mounted
|
||||
setTimeout(() => {
|
||||
this.isMapReady = true;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Fallback method to handle map initialization failures
|
||||
private handleMapInitFailure(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.isMapReady) {
|
||||
logger.warn("Map failed to initialize, forcing ready state");
|
||||
this.isMapReady = true;
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
}
|
||||
|
||||
showProfileInfo(): void {
|
||||
@@ -1532,14 +1679,17 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
async saveProfile(): Promise<void> {
|
||||
this.savingProfile = true;
|
||||
const profileData: ProfileData = {
|
||||
description: this.userProfileDesc,
|
||||
latitude: this.userProfileLatitude,
|
||||
longitude: this.userProfileLongitude,
|
||||
includeLocation: this.includeUserProfileLocation,
|
||||
};
|
||||
try {
|
||||
const success = await this.profileService.saveProfile(
|
||||
const profileData: ProfileData = {
|
||||
description: this.userProfileDesc,
|
||||
latitude: this.userProfileLatitude,
|
||||
longitude: this.userProfileLongitude,
|
||||
includeLocation: this.includeUserProfileLocation,
|
||||
};
|
||||
|
||||
logger.debug("Saving profile data:", profileData);
|
||||
|
||||
const success = await this.saveProfileToServer(
|
||||
this.activeDid,
|
||||
profileData,
|
||||
);
|
||||
@@ -1549,6 +1699,7 @@ export default class AccountViewView extends Vue {
|
||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", error);
|
||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
@@ -1556,15 +1707,25 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
|
||||
toggleUserProfileLocation(): void {
|
||||
const updated = this.profileService.toggleProfileLocation({
|
||||
description: this.userProfileDesc,
|
||||
latitude: this.userProfileLatitude,
|
||||
longitude: this.userProfileLongitude,
|
||||
includeLocation: this.includeUserProfileLocation,
|
||||
});
|
||||
this.userProfileLatitude = updated.latitude;
|
||||
this.userProfileLongitude = updated.longitude;
|
||||
this.includeUserProfileLocation = updated.includeLocation;
|
||||
try {
|
||||
const updated = this.toggleProfileLocation({
|
||||
description: this.userProfileDesc,
|
||||
latitude: this.userProfileLatitude,
|
||||
longitude: this.userProfileLongitude,
|
||||
includeLocation: this.includeUserProfileLocation,
|
||||
});
|
||||
this.userProfileLatitude = updated.latitude;
|
||||
this.userProfileLongitude = updated.longitude;
|
||||
this.includeUserProfileLocation = updated.includeLocation;
|
||||
|
||||
// Reset map ready state when toggling location
|
||||
if (!updated.includeLocation) {
|
||||
this.isMapReady = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in toggleUserProfileLocation:", error);
|
||||
this.notify.error("Failed to toggle location setting");
|
||||
}
|
||||
}
|
||||
|
||||
confirmEraseLatLong(): void {
|
||||
@@ -1592,18 +1753,26 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
async deleteProfile(): Promise<void> {
|
||||
try {
|
||||
const success = await this.profileService.deleteProfile(this.activeDid);
|
||||
const success = await this.deleteProfileFromServer(this.activeDid);
|
||||
if (success) {
|
||||
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_DELETED);
|
||||
this.userProfileDesc = "";
|
||||
this.userProfileLatitude = 0;
|
||||
this.userProfileLongitude = 0;
|
||||
this.includeUserProfileLocation = false;
|
||||
this.isMapReady = false; // Reset map state
|
||||
} else {
|
||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
|
||||
logger.error("Error in deleteProfile component method:", error);
|
||||
|
||||
// Show more specific error message if available
|
||||
if (error instanceof Error) {
|
||||
this.notify.error(error.message);
|
||||
} else {
|
||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,8 +1785,44 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
|
||||
onProfileMapClick(event: LeafletMouseEvent) {
|
||||
this.userProfileLatitude = event.latlng.lat;
|
||||
this.userProfileLongitude = event.latlng.lng;
|
||||
try {
|
||||
if (event && event.latlng) {
|
||||
this.userProfileLatitude = event.latlng.lat;
|
||||
this.userProfileLongitude = event.latlng.lng;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in onProfileMapClick:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onLocationCheckboxChange(): void {
|
||||
try {
|
||||
logger.debug(
|
||||
"Location checkbox changed, new value:",
|
||||
this.includeUserProfileLocation,
|
||||
);
|
||||
if (!this.includeUserProfileLocation) {
|
||||
// Location checkbox was unchecked, clean up map state
|
||||
this.isMapReady = false;
|
||||
this.userProfileLatitude = 0;
|
||||
this.userProfileLongitude = 0;
|
||||
} else {
|
||||
// Location checkbox was checked, start map initialization timeout
|
||||
this.isMapReady = false;
|
||||
logger.debug("Location checked, starting map initialization timeout");
|
||||
|
||||
// Try to set map ready after a short delay to allow Vue to render
|
||||
setTimeout(() => {
|
||||
if (!this.isMapReady) {
|
||||
this.isMapReady = true;
|
||||
}
|
||||
}, 1000); // 1 second delay
|
||||
|
||||
this.handleMapInitFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in onLocationCheckboxChange:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// IdentitySection event handlers
|
||||
@@ -1658,22 +1863,341 @@ export default class AccountViewView extends Vue {
|
||||
this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy));
|
||||
}
|
||||
|
||||
get showRegistrationNotice(): boolean {
|
||||
// Show the notice if not registered and any other conditions you want
|
||||
return !this.isRegistered;
|
||||
}
|
||||
|
||||
onShareInfo() {
|
||||
// Navigate to QR code sharing page - mobile uses full scan, web uses basic
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
}
|
||||
|
||||
onRecheckLimits() {
|
||||
this.checkLimits();
|
||||
}
|
||||
|
||||
// Inlined profile methods (previously in ProfileService)
|
||||
|
||||
/**
|
||||
* Load user profile from the partner API
|
||||
*/
|
||||
private async loadProfile(did: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const requestId = `profile_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[AccountViewView] Loading profile:", {
|
||||
requestId,
|
||||
did,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
});
|
||||
|
||||
// Get authentication headers
|
||||
const headers = await getHeaders(did);
|
||||
|
||||
const fullUrl = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`;
|
||||
|
||||
logger.debug("[AccountViewView] Making API request:", {
|
||||
requestId,
|
||||
did,
|
||||
fullUrl,
|
||||
hasAuthHeader: !!headers.Authorization,
|
||||
});
|
||||
|
||||
const response = await this.axios.get(fullUrl, { headers });
|
||||
|
||||
logger.debug("[AccountViewView] Profile loaded successfully:", {
|
||||
requestId,
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
});
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const profileData = response.data.data;
|
||||
logger.debug("[AccountViewView] Parsing profile data:", {
|
||||
requestId,
|
||||
locLat: profileData.locLat,
|
||||
locLon: profileData.locLon,
|
||||
description: profileData.description,
|
||||
});
|
||||
|
||||
const result = {
|
||||
description: profileData.description || "",
|
||||
latitude: profileData.locLat || 0,
|
||||
longitude: profileData.locLon || 0,
|
||||
includeLocation: !!(profileData.locLat && profileData.locLon),
|
||||
};
|
||||
|
||||
logger.debug("[AccountViewView] Parsed profile result:", {
|
||||
requestId,
|
||||
result,
|
||||
hasLocation: result.includeLocation,
|
||||
});
|
||||
|
||||
return result;
|
||||
} else {
|
||||
logger.debug("[AccountViewView] No profile data found in response:", {
|
||||
requestId,
|
||||
hasData: !!response.data,
|
||||
hasDataData: !!(response.data && response.data.data),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: unknown) {
|
||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
|
||||
if (axiosError.response?.status === 404) {
|
||||
logger.info(
|
||||
"[Profile] No profile found - this is normal for new users",
|
||||
{
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: 404,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (axiosError.response?.status === 400) {
|
||||
logger.warn("[Profile] Bad request - user may not be registered", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: 400,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
axiosError.response?.status === 401 ||
|
||||
axiosError.response?.status === 403
|
||||
) {
|
||||
logger.warn("[Profile] Authentication/authorization issue", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: axiosError.response.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only log full errors for unexpected issues (5xx, network errors, etc.)
|
||||
logger.error("[Profile] Unexpected error loading profile:", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw new Error("Failed to load profile");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user profile to the partner API
|
||||
*/
|
||||
private async saveProfileToServer(
|
||||
did: string,
|
||||
profileData: ProfileData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const requestId = `profile_save_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[AccountViewView] Saving profile:", {
|
||||
requestId,
|
||||
did,
|
||||
profileData,
|
||||
});
|
||||
|
||||
// Get authentication headers
|
||||
const headers = await getHeaders(did);
|
||||
|
||||
// Prepare payload in the format expected by the partner API
|
||||
const payload = {
|
||||
description: profileData.description,
|
||||
issuerDid: did,
|
||||
...(profileData.includeLocation &&
|
||||
profileData.latitude &&
|
||||
profileData.longitude
|
||||
? {
|
||||
locLat: profileData.latitude,
|
||||
locLon: profileData.longitude,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
logger.debug("[AccountViewView] Sending payload to server:", {
|
||||
requestId,
|
||||
payload,
|
||||
hasLocation: profileData.includeLocation,
|
||||
});
|
||||
|
||||
const response = await this.axios.post(
|
||||
`${this.partnerApiServer}/api/partner/userProfile`,
|
||||
payload,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
logger.debug("[AccountViewView] Profile saved successfully:", {
|
||||
requestId,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
|
||||
if (axiosError.response?.status === 400) {
|
||||
logger.warn("[Profile] Bad request saving profile", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: 400,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw new Error("Invalid profile data");
|
||||
}
|
||||
|
||||
if (
|
||||
axiosError.response?.status === 401 ||
|
||||
axiosError.response?.status === 403
|
||||
) {
|
||||
logger.warn(
|
||||
"[Profile] Authentication/authorization issue saving profile",
|
||||
{
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: axiosError.response.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
if (axiosError.response?.status === 409) {
|
||||
logger.warn("[Profile] Profile conflict - may already exist", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: 409,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw new Error("Profile already exists");
|
||||
}
|
||||
}
|
||||
|
||||
// Only log full errors for unexpected issues
|
||||
logger.error("[Profile] Unexpected error saving profile:", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw new Error("Failed to save profile");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle profile location visibility
|
||||
*/
|
||||
private toggleProfileLocation(profileData: ProfileData): ProfileData {
|
||||
const includeLocation = !profileData.includeLocation;
|
||||
return {
|
||||
...profileData,
|
||||
latitude: includeLocation ? profileData.latitude : 0,
|
||||
longitude: includeLocation ? profileData.longitude : 0,
|
||||
includeLocation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear profile location
|
||||
*/
|
||||
private clearProfileLocation(profileData: ProfileData): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default profile data
|
||||
*/
|
||||
private getDefaultProfile(): ProfileData {
|
||||
return {
|
||||
description: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user profile from the partner API
|
||||
*/
|
||||
private async deleteProfileFromServer(did: string): Promise<boolean> {
|
||||
try {
|
||||
const requestId = `profile_delete_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[AccountViewView] Deleting profile:", {
|
||||
requestId,
|
||||
did,
|
||||
});
|
||||
|
||||
// Get authentication headers
|
||||
const headers = await getHeaders(did);
|
||||
|
||||
const response = await this.axios.delete(
|
||||
`${this.partnerApiServer}/api/partner/userProfile/${did}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
logger.debug("[AccountViewView] Profile deleted successfully:", {
|
||||
requestId,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
|
||||
if (axiosError.response?.status === 404) {
|
||||
logger.info(
|
||||
"[Profile] Profile not found for deletion - may already be deleted",
|
||||
{
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: 404,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return true; // Consider it successful if already deleted
|
||||
}
|
||||
|
||||
if (
|
||||
axiosError.response?.status === 401 ||
|
||||
axiosError.response?.status === 403
|
||||
) {
|
||||
logger.warn(
|
||||
"[Profile] Authentication/authorization issue deleting profile",
|
||||
{
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
status: axiosError.response.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only log full errors for unexpected issues
|
||||
logger.error("[Profile] Unexpected error deleting profile:", {
|
||||
did,
|
||||
server: this.partnerApiServer,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,7 +124,7 @@ import {
|
||||
NOTIFY_CONFIRMATION_RESTRICTION,
|
||||
} from "../constants/notifications";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
|
||||
import { GiveSummaryRecord, GiveActionClaim } from "../interfaces";
|
||||
import { AgreeActionClaim } from "../interfaces/claims";
|
||||
import {
|
||||
@@ -223,7 +223,7 @@ export default class ContactAmountssView extends Vue {
|
||||
const contact = await this.$getContact(contactDid);
|
||||
this.contact = contact;
|
||||
|
||||
const settings = await this.$getSettings(MASTER_SETTINGS_KEY);
|
||||
const settings = await this.$getMasterSettings();
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
|
||||
@@ -17,20 +17,40 @@
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="border-t border-slate-300">
|
||||
<li class="border-b border-slate-300 py-3">
|
||||
<!-- "You" entity -->
|
||||
<li v-if="shouldShowYouEntity" class="border-b border-slate-300 py-3">
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow flex gap-2 items-center font-medium">
|
||||
<font-awesome
|
||||
icon="circle-question"
|
||||
class="text-slate-400 text-4xl"
|
||||
/>
|
||||
<span class="italic text-slate-400">(Unnamed/Unknown)</span>
|
||||
<font-awesome icon="hand" class="text-blue-500 text-4xl shrink-0" />
|
||||
<span class="text-ellipsis overflow-hidden text-blue-500">You</span>
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
@click="openDialog('Unnamed')"
|
||||
@click="openDialog({ did: activeDid, name: 'You' })"
|
||||
>
|
||||
<font-awesome icon="gift" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
</li>
|
||||
<li class="border-b border-slate-300 py-3">
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow flex gap-2 items-center font-medium">
|
||||
<font-awesome
|
||||
icon="circle-question"
|
||||
class="text-slate-400 text-4xl shrink-0"
|
||||
/>
|
||||
<span class="text-ellipsis overflow-hidden italic text-slate-500">{{
|
||||
unnamedEntityName
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
@click="openDialog({ did: '', name: unnamedEntityName })"
|
||||
>
|
||||
<font-awesome icon="gift" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
@@ -43,14 +63,22 @@
|
||||
class="border-b border-slate-300 py-3"
|
||||
>
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow flex gap-2 items-center font-medium">
|
||||
<span
|
||||
class="grow flex gap-2 items-center font-medium overflow-hidden"
|
||||
>
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:icon-size="34"
|
||||
class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden"
|
||||
class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden shrink-0"
|
||||
/>
|
||||
<span v-if="contact.name">{{ contact.name }}</span>
|
||||
<span v-else class="italic text-slate-400">(No name)</span>
|
||||
<span v-if="contact.name" class="text-ellipsis overflow-hidden">{{
|
||||
contact.name
|
||||
}}</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-ellipsis overflow-hidden italic text-slate-500"
|
||||
>{{ contact.did }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
@@ -72,6 +100,7 @@
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:hide-show-all="true"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -89,6 +118,7 @@ import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
mixins: [PlatformServiceMixin],
|
||||
@@ -188,147 +218,151 @@ export default class ContactGiftingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(contact?: GiverReceiverInputInfo | "Unnamed") {
|
||||
if (contact === "Unnamed") {
|
||||
// Special case: Handle "Unnamed" contacts for both givers and recipients
|
||||
let recipient: GiverReceiverInputInfo;
|
||||
let giver: GiverReceiverInputInfo | undefined;
|
||||
openDialog(contact?: GiverReceiverInputInfo) {
|
||||
// Determine the selected entity based on contact type
|
||||
const selectedEntity = this.createEntityFromContact(contact);
|
||||
|
||||
if (this.stepType === "giver") {
|
||||
// We're selecting a giver, so preserve the existing recipient from context
|
||||
if (this.recipientEntityType === "project") {
|
||||
recipient = {
|
||||
did: this.recipientProjectHandleId,
|
||||
name: this.recipientProjectName,
|
||||
image: this.recipientProjectImage,
|
||||
handleId: this.recipientProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
// Preserve the existing recipient from context
|
||||
if (this.recipientDid === this.activeDid) {
|
||||
// Recipient was "You"
|
||||
recipient = { did: this.activeDid, name: "You" };
|
||||
} else if (this.recipientDid) {
|
||||
// Recipient was a regular contact
|
||||
recipient = {
|
||||
did: this.recipientDid,
|
||||
name: this.recipientProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
// Fallback to "You" if no recipient was previously selected
|
||||
recipient = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
}
|
||||
giver = undefined; // Will be set to "Unnamed" in GiftedDialog
|
||||
} else {
|
||||
// We're selecting a recipient, so recipient is "Unnamed" and giver is preserved from context
|
||||
recipient = { did: "", name: "Unnamed" };
|
||||
// Create giver and recipient based on step type and selected entity
|
||||
const { giver, recipient } = this.createGiverAndRecipient(selectedEntity);
|
||||
|
||||
// Preserve the existing giver from the context
|
||||
if (this.giverEntityType === "project") {
|
||||
giver = {
|
||||
did: this.giverProjectHandleId,
|
||||
name: this.giverProjectName,
|
||||
image: this.giverProjectImage,
|
||||
handleId: this.giverProjectHandleId,
|
||||
};
|
||||
} else if (this.giverDid) {
|
||||
giver = {
|
||||
did: this.giverDid,
|
||||
name: this.giverProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
giver = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
}
|
||||
// Open the dialog
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
this.offerId,
|
||||
this.prompt,
|
||||
this.description,
|
||||
this.amountInput,
|
||||
this.unitCode,
|
||||
);
|
||||
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
this.offerId,
|
||||
this.prompt,
|
||||
this.description,
|
||||
this.amountInput,
|
||||
this.unitCode,
|
||||
);
|
||||
// Move to Step 2 - entities are already set by the open() call
|
||||
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
|
||||
}
|
||||
|
||||
// Move to Step 2 - entities are already set by the open() call
|
||||
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
|
||||
/**
|
||||
* Creates an entity object from the contact parameter
|
||||
* Uses DID-based logic to determine "You" and "Unnamed" entities
|
||||
*/
|
||||
private createEntityFromContact(
|
||||
contact?: GiverReceiverInputInfo,
|
||||
): GiverReceiverInputInfo | undefined {
|
||||
if (!contact) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle GiverReceiverInputInfo object
|
||||
if (contact.did === this.activeDid) {
|
||||
// If DID matches active DID, create "You" entity
|
||||
return { did: this.activeDid, name: "You" };
|
||||
} else if (!contact.did || contact.did === "") {
|
||||
// If DID is empty/null, create "Unnamed" entity
|
||||
return { did: "", name: UNNAMED_ENTITY_NAME };
|
||||
} else {
|
||||
// Regular case: contact is a GiverReceiverInputInfo
|
||||
let giver: GiverReceiverInputInfo;
|
||||
let recipient: GiverReceiverInputInfo;
|
||||
// Create a copy of the contact to avoid modifying the original
|
||||
return { ...contact };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stepType === "giver") {
|
||||
// We're selecting a giver, so the contact becomes the giver
|
||||
giver = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
|
||||
/**
|
||||
* Creates giver and recipient objects based on step type and selected entity
|
||||
*/
|
||||
private createGiverAndRecipient(selectedEntity?: GiverReceiverInputInfo): {
|
||||
giver: GiverReceiverInputInfo | undefined;
|
||||
recipient: GiverReceiverInputInfo;
|
||||
} {
|
||||
if (this.stepType === "giver") {
|
||||
// We're selecting a giver, so the selected entity becomes the giver
|
||||
const giver = selectedEntity;
|
||||
const recipient = this.createRecipientFromContext();
|
||||
return { giver, recipient };
|
||||
} else {
|
||||
// We're selecting a recipient, so the selected entity becomes the recipient
|
||||
const recipient = selectedEntity || {
|
||||
did: "",
|
||||
name: UNNAMED_ENTITY_NAME,
|
||||
};
|
||||
const giver = this.createGiverFromContext();
|
||||
return { giver, recipient };
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the existing recipient from the context
|
||||
if (this.recipientEntityType === "project") {
|
||||
recipient = {
|
||||
did: this.recipientProjectHandleId,
|
||||
name: this.recipientProjectName,
|
||||
image: this.recipientProjectImage,
|
||||
handleId: this.recipientProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
// Check if the preserved recipient was "You" or a regular contact
|
||||
if (this.recipientDid === this.activeDid) {
|
||||
// Recipient was "You"
|
||||
recipient = { did: this.activeDid, name: "You" };
|
||||
} else if (this.recipientDid) {
|
||||
// Recipient was a regular contact
|
||||
recipient = {
|
||||
did: this.recipientDid,
|
||||
name: this.recipientProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
// Fallback to "Unnamed"
|
||||
recipient = { did: "", name: "Unnamed" };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates recipient object from context (preserves existing recipient)
|
||||
*/
|
||||
private createRecipientFromContext(): GiverReceiverInputInfo {
|
||||
if (this.recipientEntityType === "project") {
|
||||
return {
|
||||
name: this.recipientProjectName,
|
||||
image: this.recipientProjectImage,
|
||||
handleId: this.recipientProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
if (this.recipientDid === this.activeDid) {
|
||||
return { did: this.activeDid, name: "You" };
|
||||
} else if (this.recipientDid) {
|
||||
return {
|
||||
did: this.recipientDid,
|
||||
name: this.recipientProjectName,
|
||||
};
|
||||
} else {
|
||||
// We're selecting a recipient, so the contact becomes the recipient
|
||||
recipient = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
|
||||
|
||||
// Preserve the existing giver from the context
|
||||
if (this.giverEntityType === "project") {
|
||||
giver = {
|
||||
did: this.giverProjectHandleId,
|
||||
name: this.giverProjectName,
|
||||
image: this.giverProjectImage,
|
||||
handleId: this.giverProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
// Check if the preserved giver was "You" or a regular contact
|
||||
if (this.giverDid === this.activeDid) {
|
||||
// Giver was "You"
|
||||
giver = { did: this.activeDid, name: "You" };
|
||||
} else if (this.giverDid) {
|
||||
// Giver was a regular contact
|
||||
giver = {
|
||||
did: this.giverDid,
|
||||
name: this.giverProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
// Fallback to "Unnamed"
|
||||
giver = { did: "", name: "Unnamed" };
|
||||
}
|
||||
}
|
||||
return { did: "", name: UNNAMED_ENTITY_NAME };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
this.offerId,
|
||||
this.prompt,
|
||||
this.description,
|
||||
this.amountInput,
|
||||
this.unitCode,
|
||||
);
|
||||
/**
|
||||
* Creates giver object from context (preserves existing giver)
|
||||
*/
|
||||
private createGiverFromContext(): GiverReceiverInputInfo {
|
||||
if (this.giverEntityType === "project") {
|
||||
return {
|
||||
name: this.giverProjectName,
|
||||
image: this.giverProjectImage,
|
||||
handleId: this.giverProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
if (this.giverDid === this.activeDid) {
|
||||
return { did: this.activeDid, name: "You" };
|
||||
} else if (this.giverDid) {
|
||||
return {
|
||||
did: this.giverDid,
|
||||
name: this.giverProjectName,
|
||||
};
|
||||
} else {
|
||||
return { did: "", name: UNNAMED_ENTITY_NAME };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to Step 2 - entities are already set by the open() call
|
||||
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
|
||||
/**
|
||||
* Get the unnamed entity name constant
|
||||
*/
|
||||
get unnamedEntityName(): string {
|
||||
return UNNAMED_ENTITY_NAME;
|
||||
}
|
||||
|
||||
get shouldShowYouEntity(): boolean {
|
||||
if (this.stepType === "giver") {
|
||||
// When selecting a giver, show "You" if the current recipient is not "You"
|
||||
// This prevents selecting yourself as both giver and recipient
|
||||
if (this.recipientEntityType === "project") {
|
||||
// If recipient is a project, we can select "You" as giver
|
||||
return true;
|
||||
} else {
|
||||
// If recipient is a person, check if it's not "You"
|
||||
return this.recipientDid !== this.activeDid;
|
||||
}
|
||||
} else {
|
||||
// When selecting a recipient, show "You" if the current giver is not "You"
|
||||
// This prevents selecting yourself as both giver and recipient
|
||||
if (this.giverEntityType === "project") {
|
||||
// If giver is a project, we can select "You" as recipient
|
||||
return true;
|
||||
} else {
|
||||
// If giver is a person, check if it's not "You"
|
||||
return this.giverDid !== this.activeDid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ import {
|
||||
QR_TIMEOUT_STANDARD,
|
||||
QR_TIMEOUT_LONG,
|
||||
} from "@/constants/notifications";
|
||||
import { createNotifyHelpers } from "../utils/notify";
|
||||
import { createNotifyHelpers, NotifyFunction } from "../utils/notify";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -191,7 +191,7 @@ interface IUserNameDialog {
|
||||
* @since 2024
|
||||
*/
|
||||
export default class ContactQRScanFull extends Vue {
|
||||
$notify!: (notification: any, timeout?: number) => void;
|
||||
$notify!: NotifyFunction;
|
||||
$router!: Router;
|
||||
|
||||
// Notification helper system
|
||||
@@ -220,21 +220,21 @@ export default class ContactQRScanFull extends Vue {
|
||||
* Computed property for QR code container CSS classes
|
||||
*/
|
||||
get qrContainerClasses(): string {
|
||||
return "block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4";
|
||||
return "block w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto mt-4";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for camera frame CSS classes
|
||||
*/
|
||||
get cameraFrameClasses(): string {
|
||||
return "relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
|
||||
return "relative w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for main content container CSS classes
|
||||
*/
|
||||
get mainContentClasses(): string {
|
||||
return "p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
|
||||
return "p-6 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,7 +140,7 @@ import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { QrcodeStream } from "vue-qrcode-reader";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
@@ -183,8 +183,6 @@ import {
|
||||
NOTIFY_QR_PROCESSING_ERROR,
|
||||
createQRContactAddedMessage,
|
||||
createQRRegistrationSuccessMessage,
|
||||
QR_TIMEOUT_SHORT,
|
||||
QR_TIMEOUT_MEDIUM,
|
||||
QR_TIMEOUT_STANDARD,
|
||||
QR_TIMEOUT_LONG,
|
||||
} from "@/constants/notifications";
|
||||
@@ -259,11 +257,11 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
get qrCodeContainerClasses(): string {
|
||||
return "block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4";
|
||||
return "block w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto my-4";
|
||||
}
|
||||
|
||||
get scannerContainerClasses(): string {
|
||||
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
|
||||
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
|
||||
}
|
||||
|
||||
get statusMessageClasses(): string {
|
||||
@@ -544,11 +542,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
did: contact.did,
|
||||
name: contact.name,
|
||||
});
|
||||
this.notify.toast(
|
||||
"Submitted",
|
||||
NOTIFY_QR_REGISTRATION_SUBMITTED.message,
|
||||
QR_TIMEOUT_SHORT,
|
||||
);
|
||||
this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message);
|
||||
|
||||
try {
|
||||
const regResult = await register(
|
||||
@@ -624,18 +618,15 @@ export default class ContactQRScanShow extends Vue {
|
||||
);
|
||||
|
||||
// Copy the URL to clipboard
|
||||
useClipboard()
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
this.notify.toast(
|
||||
"Copied",
|
||||
NOTIFY_QR_URL_COPIED.message,
|
||||
QR_TIMEOUT_MEDIUM,
|
||||
);
|
||||
});
|
||||
const { copyToClipboard } = await import("../services/ClipboardService");
|
||||
await copyToClipboard(jwtUrl);
|
||||
this.notify.toast(
|
||||
NOTIFY_QR_URL_COPIED.title,
|
||||
NOTIFY_QR_URL_COPIED.message,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to generate contact URL:", error);
|
||||
this.notify.error("Failed to generate contact URL. Please try again.");
|
||||
this.$logAndConsole(`Error copying URL to clipboard: ${error}`, true);
|
||||
this.notify.error("Failed to copy URL to clipboard.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,13 +634,16 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.notify.info(NOTIFY_QR_CODE_HELP.message, QR_TIMEOUT_LONG);
|
||||
}
|
||||
|
||||
onCopyDidToClipboard() {
|
||||
async onCopyDidToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
useClipboard()
|
||||
.copy(this.activeDid)
|
||||
.then(() => {
|
||||
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
|
||||
});
|
||||
try {
|
||||
const { copyToClipboard } = await import("../services/ClipboardService");
|
||||
await copyToClipboard(this.activeDid);
|
||||
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying DID to clipboard: ${error}`, true);
|
||||
this.notify.error("Failed to copy DID to clipboard.");
|
||||
}
|
||||
}
|
||||
|
||||
openUserNameDialog() {
|
||||
@@ -714,8 +708,16 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Add new contact
|
||||
// @ts-expect-error because we're just using the value to store to the DB
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
contact.contactMethods = JSON.stringify(
|
||||
(this as any)._parseJsonField(contact.contactMethods, []),
|
||||
(
|
||||
this as {
|
||||
_parseJsonField: (
|
||||
value: unknown,
|
||||
defaultValue: unknown[],
|
||||
) => unknown[];
|
||||
}
|
||||
)._parseJsonField(contact.contactMethods, []),
|
||||
);
|
||||
await this.$insertContact(contact);
|
||||
|
||||
@@ -737,7 +739,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.notify.confirm(
|
||||
"Register",
|
||||
"Do you want to register them?",
|
||||
{
|
||||
onCancel: async (stopAsking?: boolean) => {
|
||||
|
||||
@@ -130,10 +130,9 @@ import { JWTPayload } from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { copyToClipboard } from "../services/ClipboardService";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import GiftedDialog from "../components/GiftedDialog.vue";
|
||||
import OfferDialog from "../components/OfferDialog.vue";
|
||||
@@ -165,13 +164,18 @@ import { GiveSummaryRecord } from "@/interfaces/records";
|
||||
import { UserInfo } from "@/interfaces/common";
|
||||
import { VerifiableCredential } from "@/interfaces/claims-result";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||
import {
|
||||
generateSaveAndActivateIdentity,
|
||||
contactsToExportJson,
|
||||
} from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
// No longer needed - using PlatformServiceMixin methods
|
||||
// import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { isDatabaseError } from "@/interfaces/common";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { QRNavigationService } from "@/services/QRNavigationService";
|
||||
import {
|
||||
NOTIFY_CONTACT_NO_INFO,
|
||||
NOTIFY_CONTACTS_ADD_ERROR,
|
||||
@@ -197,6 +201,7 @@ import {
|
||||
NOTIFY_REGISTRATION_ERROR_FALLBACK,
|
||||
NOTIFY_REGISTRATION_ERROR_GENERIC,
|
||||
NOTIFY_VISIBILITY_ERROR_FALLBACK,
|
||||
NOTIFY_EXPORT_DATA_PROMPT,
|
||||
getRegisterPersonSuccessMessage,
|
||||
getVisibilitySuccessMessage,
|
||||
getGivesRetrievalErrorMessage,
|
||||
@@ -376,7 +381,11 @@ export default class ContactsView extends Vue {
|
||||
"",
|
||||
async (name) => {
|
||||
await this.addContact({
|
||||
did: (registration.vc.credentialSubject.agent as any).identifier,
|
||||
did: (
|
||||
registration.vc.credentialSubject.agent as {
|
||||
identifier: string;
|
||||
}
|
||||
).identifier,
|
||||
name: name,
|
||||
registered: true,
|
||||
});
|
||||
@@ -387,7 +396,11 @@ export default class ContactsView extends Vue {
|
||||
async () => {
|
||||
// on cancel, will still add the contact
|
||||
await this.addContact({
|
||||
did: (registration.vc.credentialSubject.agent as any).identifier,
|
||||
did: (
|
||||
registration.vc.credentialSubject.agent as {
|
||||
identifier: string;
|
||||
}
|
||||
).identifier,
|
||||
name: "(person who invited you)",
|
||||
registered: true,
|
||||
});
|
||||
@@ -396,8 +409,7 @@ export default class ContactsView extends Vue {
|
||||
this.showOnboardingInfo();
|
||||
},
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const fullError = "Error redeeming invite: " + errorStringForLog(error);
|
||||
this.$logAndConsole(fullError, true);
|
||||
let message = "Got an error sending the invite.";
|
||||
@@ -784,6 +796,9 @@ export default class ContactsView extends Vue {
|
||||
|
||||
// Show success notification
|
||||
this.notify.success(addedMessage);
|
||||
|
||||
// Show export data prompt after successful contact addition
|
||||
await this.showExportDataPrompt();
|
||||
} catch (err) {
|
||||
this.handleContactAddError(err);
|
||||
}
|
||||
@@ -881,20 +896,21 @@ export default class ContactsView extends Vue {
|
||||
/**
|
||||
* Handle errors during contact addition
|
||||
*/
|
||||
private handleContactAddError(err: any): void {
|
||||
private handleContactAddError(err: unknown): void {
|
||||
const fullError =
|
||||
"Error when adding contact to storage: " + errorStringForLog(err);
|
||||
this.$logAndConsole(fullError, true);
|
||||
|
||||
let message = NOTIFY_CONTACT_IMPORT_ERROR.message;
|
||||
if (
|
||||
(err as any).message?.indexOf("Key already exists in the object store.") >
|
||||
-1
|
||||
) {
|
||||
message = NOTIFY_CONTACT_IMPORT_CONFLICT.message;
|
||||
}
|
||||
if ((err as any).name === "ConstraintError") {
|
||||
message += " " + NOTIFY_CONTACT_IMPORT_CONSTRAINT.message;
|
||||
|
||||
// Use type-safe error checking with our new type guards
|
||||
if (isDatabaseError(err)) {
|
||||
if (err.message.includes("Key already exists in the object store")) {
|
||||
message = NOTIFY_CONTACT_IMPORT_CONFLICT.message;
|
||||
}
|
||||
if (err.name === "ConstraintError") {
|
||||
message += " " + NOTIFY_CONTACT_IMPORT_CONSTRAINT.message;
|
||||
}
|
||||
}
|
||||
|
||||
this.notify.error(message, TIMEOUTS.LONG);
|
||||
@@ -1175,12 +1191,14 @@ export default class ContactsView extends Vue {
|
||||
});
|
||||
// Use production URL for sharing to avoid localhost issues in development
|
||||
const contactsJwtUrl = `${APP_SERVER}/deep-link/contact-import/${contactsJwt}`;
|
||||
useClipboard()
|
||||
.copy(contactsJwtUrl)
|
||||
.then(() => {
|
||||
// Use notification helper
|
||||
this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message);
|
||||
});
|
||||
try {
|
||||
await copyToClipboard(contactsJwtUrl);
|
||||
// Use notification helper
|
||||
this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
|
||||
this.notify.error("Failed to copy to clipboard. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private showCopySelectionsInfo() {
|
||||
@@ -1246,19 +1264,76 @@ export default class ContactsView extends Vue {
|
||||
|
||||
/**
|
||||
* Handle QR code button click - route to appropriate scanner
|
||||
* Uses native scanner on mobile platforms, web scanner otherwise
|
||||
* Uses QRNavigationService to determine scanner type and route
|
||||
*/
|
||||
|
||||
public handleQRCodeClick() {
|
||||
this.$logAndConsole(
|
||||
"[ContactsView] handleQRCodeClick method called",
|
||||
false,
|
||||
);
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
const qrNavigationService = QRNavigationService.getInstance();
|
||||
const route = qrNavigationService.getQRScannerRoute();
|
||||
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show export data prompt after adding a contact
|
||||
* Prompts user to export their contact data as a backup
|
||||
*/
|
||||
private async showExportDataPrompt(): Promise<void> {
|
||||
setTimeout(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: NOTIFY_EXPORT_DATA_PROMPT.title,
|
||||
text: NOTIFY_EXPORT_DATA_PROMPT.message,
|
||||
onYes: async () => {
|
||||
await this.exportContactData();
|
||||
},
|
||||
yesText: "Export Data",
|
||||
onNo: async () => {
|
||||
// User chose not to export - no action needed
|
||||
},
|
||||
noText: "Not Now",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}, 1000); // Small delay to ensure success notification is shown first
|
||||
}
|
||||
|
||||
/**
|
||||
* Export contact data to JSON file
|
||||
* Uses platform service to handle platform-specific export logic
|
||||
*/
|
||||
private async exportContactData(): Promise<void> {
|
||||
// Note that similar code is in DataExportSection.vue exportDatabase()
|
||||
try {
|
||||
// Fetch all contacts from database
|
||||
const allContacts = await this.$contacts();
|
||||
|
||||
// Convert contacts to export format
|
||||
const exportData = contactsToExportJson(allContacts);
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Generate filename with current date
|
||||
const today = new Date();
|
||||
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
const fileName = `timesafari-backup-contacts-${dateString}.json`;
|
||||
|
||||
// Use platform service to handle export
|
||||
await this.platformService.writeAndShareFile(fileName, jsonStr);
|
||||
|
||||
this.notify.success(
|
||||
"Contact export completed successfully. Check your downloads or share dialog.",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Export Error:", error);
|
||||
this.notify.error(
|
||||
`There was an error exporting the data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,22 +71,22 @@
|
||||
contactFromDid?.seesMe && contactFromDid.did !== activeDid
|
||||
"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="They can see you"
|
||||
title="They can see your activity"
|
||||
@click="confirmSetVisibility(contactFromDid, false)"
|
||||
>
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
|
||||
"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="They cannot see you"
|
||||
title="They cannot see your activity"
|
||||
@click="confirmSetVisibility(contactFromDid, true)"
|
||||
>
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -95,11 +95,11 @@
|
||||
contactFromDid.did !== activeDid
|
||||
"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="I view their content"
|
||||
title="You watch their activity"
|
||||
@click="confirmViewContent(contactFromDid, false)"
|
||||
>
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
@@ -107,11 +107,11 @@
|
||||
contactFromDid?.did !== activeDid
|
||||
"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="I do not view their content"
|
||||
title="You do not watch their activity"
|
||||
@click="confirmViewContent(contactFromDid, true)"
|
||||
>
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -273,6 +273,7 @@ import {
|
||||
didInfoForContact,
|
||||
displayAmount,
|
||||
getHeaders,
|
||||
isDid,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
@@ -289,7 +290,9 @@ import {
|
||||
NOTIFY_REGISTRATION_ERROR,
|
||||
NOTIFY_SERVER_ACCESS_ERROR,
|
||||
NOTIFY_NO_IDENTITY_ERROR,
|
||||
NOTIFY_CONTACT_INVALID_DID,
|
||||
} from "@/constants/notifications";
|
||||
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* DIDView Component
|
||||
@@ -379,22 +382,29 @@ export default class DIDView extends Vue {
|
||||
|
||||
/**
|
||||
* Determines which DID to display based on URL parameters
|
||||
* Falls back to active DID if no parameter provided
|
||||
* Validates DID format and shows error for invalid DIDs
|
||||
*/
|
||||
private async determineDIDToDisplay() {
|
||||
const pathParam = window.location.pathname.substring("/did/".length);
|
||||
let showDid = pathParam;
|
||||
|
||||
if (!showDid) {
|
||||
// No DID provided in URL, use active DID
|
||||
showDid = this.activeDid;
|
||||
if (showDid) {
|
||||
this.notifyDefaultToActiveDID();
|
||||
this.notifyDefaultToActiveDID();
|
||||
} else {
|
||||
// DID provided in URL, validate it
|
||||
const decodedDid = decodeURIComponent(showDid);
|
||||
if (!isDid(decodedDid)) {
|
||||
// Invalid DID format - show error and redirect
|
||||
this.notify.error(NOTIFY_CONTACT_INVALID_DID.message, TIMEOUTS.LONG);
|
||||
this.$router.push({ name: "home" });
|
||||
return;
|
||||
}
|
||||
showDid = decodedDid;
|
||||
}
|
||||
|
||||
if (showDid) {
|
||||
this.viewingDid = decodeURIComponent(showDid);
|
||||
}
|
||||
this.viewingDid = showDid;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +500,8 @@ export default class DIDView extends Vue {
|
||||
message +=
|
||||
" Note that they can see your activity, so if you want to hide your activity from them then you should do that first.";
|
||||
}
|
||||
message +=
|
||||
" Note that this will also remove anyone with the same DID underneath.";
|
||||
this.notify.confirm(message, async () => {
|
||||
await this.deleteContact(contact);
|
||||
});
|
||||
@@ -549,7 +561,7 @@ export default class DIDView extends Vue {
|
||||
contact.registered = true;
|
||||
await this.$updateContact(contact.did, { registered: true });
|
||||
|
||||
const name = contact.name || "That unnamed person";
|
||||
const name = contact.name || THAT_UNNAMED_PERSON;
|
||||
this.notify.success(
|
||||
`${name} ${NOTIFY_REGISTRATION_SUCCESS.message}`,
|
||||
TIMEOUTS.LONG,
|
||||
@@ -829,26 +841,3 @@ export default class DIDView extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -91,17 +91,12 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="downloadAccount"
|
||||
>
|
||||
<IconRenderer
|
||||
<font-awesome
|
||||
v-if="isLoading"
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<IconRenderer
|
||||
v-else
|
||||
icon-name="chart"
|
||||
svg-class="-ml-1 mr-3 h-5 w-5"
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
<font-awesome v-else icon="chart-line" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Show Account Seed
|
||||
</button>
|
||||
|
||||
@@ -110,17 +105,12 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="downloadSettingsContacts"
|
||||
>
|
||||
<IconRenderer
|
||||
<font-awesome
|
||||
v-if="isLoading"
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<IconRenderer
|
||||
v-else
|
||||
icon-name="chart"
|
||||
svg-class="-ml-1 mr-3 h-5 w-5"
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
<font-awesome v-else icon="chart-line" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Download Settings & Contacts
|
||||
</button>
|
||||
|
||||
@@ -143,17 +133,12 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="compareDatabases"
|
||||
>
|
||||
<IconRenderer
|
||||
<font-awesome
|
||||
v-if="isLoading"
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<IconRenderer
|
||||
v-else
|
||||
icon-name="chart"
|
||||
svg-class="-ml-1 mr-3 h-5 w-5"
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
<font-awesome v-else icon="chart-line" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Compare Databases
|
||||
</button>
|
||||
|
||||
@@ -162,17 +147,12 @@
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="migrateAll"
|
||||
>
|
||||
<IconRenderer
|
||||
<font-awesome
|
||||
v-if="isLoading"
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<IconRenderer
|
||||
v-else
|
||||
icon-name="check"
|
||||
svg-class="-ml-1 mr-3 h-5 w-5"
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
<font-awesome v-else icon="check" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Migrate All
|
||||
</button>
|
||||
|
||||
@@ -185,10 +165,9 @@
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="warning"
|
||||
svg-class="h-5 w-5 text-red-400"
|
||||
fill="currentColor"
|
||||
<font-awesome
|
||||
icon="triangle-exclamation"
|
||||
class="h-5 w-5 text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -207,10 +186,9 @@
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="warning"
|
||||
svg-class="h-5 w-5 text-red-400"
|
||||
fill="currentColor"
|
||||
<font-awesome
|
||||
icon="triangle-exclamation"
|
||||
class="h-5 w-5 text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -229,10 +207,7 @@
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-5 w-5 text-green-400"
|
||||
/>
|
||||
<font-awesome icon="check" class="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-green-800">Success</h3>
|
||||
@@ -249,7 +224,7 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="exportComparison"
|
||||
>
|
||||
<IconRenderer icon-name="download" svg-class="-ml-1 mr-3 h-5 w-5" />
|
||||
<font-awesome icon="download" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Export Comparison
|
||||
</button>
|
||||
|
||||
@@ -258,17 +233,12 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="displayDatabases"
|
||||
>
|
||||
<IconRenderer
|
||||
<font-awesome
|
||||
v-if="isLoading"
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<IconRenderer
|
||||
v-else
|
||||
icon-name="chart"
|
||||
svg-class="-ml-1 mr-3 h-5 w-5"
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
<font-awesome v-else icon="chart-line" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Show Previous Data
|
||||
</button>
|
||||
|
||||
@@ -277,7 +247,7 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="migrateAccounts"
|
||||
>
|
||||
<IconRenderer icon-name="lock" svg-class="-ml-1 mr-3 h-5 w-5" />
|
||||
<font-awesome icon="lock" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Migrate Accounts
|
||||
</button>
|
||||
|
||||
@@ -286,7 +256,7 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="migrateSettings"
|
||||
>
|
||||
<IconRenderer icon-name="settings" svg-class="-ml-1 mr-3 h-5 w-5" />
|
||||
<font-awesome icon="gear" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Migrate Settings
|
||||
</button>
|
||||
|
||||
@@ -295,7 +265,7 @@
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="migrateContacts"
|
||||
>
|
||||
<IconRenderer icon-name="plus" svg-class="-ml-1 mr-3 h-5 w-5" />
|
||||
<font-awesome icon="plus" class="-ml-1 mr-3 h-5 w-5" />
|
||||
Migrate Contacts
|
||||
</button>
|
||||
</div>
|
||||
@@ -316,11 +286,7 @@
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="info"
|
||||
svg-class="h-5 w-5 text-blue-400"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<font-awesome icon="info" class="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
@@ -357,10 +323,9 @@
|
||||
<div
|
||||
class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-blue-500 hover:bg-blue-400 transition ease-in-out duration-150 cursor-not-allowed"
|
||||
>
|
||||
<IconRenderer
|
||||
icon-name="spinner"
|
||||
svg-class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
/>
|
||||
{{ loadingMessage }}
|
||||
</div>
|
||||
@@ -375,10 +340,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="lock"
|
||||
svg-class="h-6 w-6 text-orange-600"
|
||||
/>
|
||||
<font-awesome icon="lock" class="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
@@ -398,10 +360,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-6 w-6 text-teal-600"
|
||||
/>
|
||||
<font-awesome icon="check" class="h-6 w-6 text-teal-600" />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
@@ -422,10 +381,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="settings"
|
||||
svg-class="h-6 w-6 text-purple-600"
|
||||
/>
|
||||
<font-awesome icon="gear" class="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
@@ -445,10 +401,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-6 w-6 text-indigo-600"
|
||||
/>
|
||||
<font-awesome icon="check" class="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
@@ -469,9 +422,9 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="chart"
|
||||
svg-class="h-6 w-6 text-blue-600"
|
||||
<font-awesome
|
||||
icon="chart-line"
|
||||
class="h-6 w-6 text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
@@ -492,10 +445,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-6 w-6 text-green-600"
|
||||
/>
|
||||
<font-awesome icon="check" class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
@@ -526,9 +476,9 @@
|
||||
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="plusCircle"
|
||||
svg-class="h-5 w-5 text-blue-600 mr-2"
|
||||
<font-awesome
|
||||
icon="circle-plus"
|
||||
class="h-5 w-5 text-blue-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-blue-900">Add</span>
|
||||
</div>
|
||||
@@ -541,9 +491,9 @@
|
||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-5 w-5 text-yellow-600 mr-2"
|
||||
<font-awesome
|
||||
icon="check"
|
||||
class="h-5 w-5 text-yellow-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-yellow-900"
|
||||
>Unmodified</span
|
||||
@@ -558,9 +508,9 @@
|
||||
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="trash"
|
||||
svg-class="h-5 w-5 text-red-600 mr-2"
|
||||
<font-awesome
|
||||
icon="trash-can"
|
||||
class="h-5 w-5 text-red-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-red-900">Keep</span>
|
||||
</div>
|
||||
@@ -677,9 +627,9 @@
|
||||
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="plusCircle"
|
||||
svg-class="h-5 w-5 text-blue-600 mr-2"
|
||||
<font-awesome
|
||||
icon="circle-plus"
|
||||
class="h-5 w-5 text-blue-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-blue-900">Add</span>
|
||||
</div>
|
||||
@@ -692,9 +642,9 @@
|
||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="edit"
|
||||
svg-class="h-5 w-5 text-yellow-600 mr-2"
|
||||
<font-awesome
|
||||
icon="pen"
|
||||
class="h-5 w-5 text-yellow-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-yellow-900"
|
||||
>Modify</span
|
||||
@@ -709,9 +659,9 @@
|
||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-5 w-5 text-yellow-600 mr-2"
|
||||
<font-awesome
|
||||
icon="check"
|
||||
class="h-5 w-5 text-yellow-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-yellow-900"
|
||||
>Unmodified</span
|
||||
@@ -726,9 +676,9 @@
|
||||
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="trash"
|
||||
svg-class="h-5 w-5 text-red-600 mr-2"
|
||||
<font-awesome
|
||||
icon="trash-can"
|
||||
class="h-5 w-5 text-red-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-red-900">Keep</span>
|
||||
</div>
|
||||
@@ -868,9 +818,9 @@
|
||||
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="plusCircle"
|
||||
svg-class="h-5 w-5 text-blue-600 mr-2"
|
||||
<font-awesome
|
||||
icon="circle-plus"
|
||||
class="h-5 w-5 text-blue-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-blue-900">Add</span>
|
||||
</div>
|
||||
@@ -883,9 +833,9 @@
|
||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="edit"
|
||||
svg-class="h-5 w-5 text-yellow-600 mr-2"
|
||||
<font-awesome
|
||||
icon="pen"
|
||||
class="h-5 w-5 text-yellow-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-yellow-900"
|
||||
>Modify</span
|
||||
@@ -900,9 +850,9 @@
|
||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="check"
|
||||
svg-class="h-5 w-5 text-yellow-600 mr-2"
|
||||
<font-awesome
|
||||
icon="check"
|
||||
class="h-5 w-5 text-yellow-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-yellow-900"
|
||||
>Unmodified</span
|
||||
@@ -917,9 +867,9 @@
|
||||
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconRenderer
|
||||
icon-name="trash"
|
||||
svg-class="h-5 w-5 text-red-600 mr-2"
|
||||
<font-awesome
|
||||
icon="trash-can"
|
||||
class="h-5 w-5 text-red-600 mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-red-900">Keep</span>
|
||||
</div>
|
||||
@@ -1067,7 +1017,6 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import IconRenderer from "../components/IconRenderer.vue";
|
||||
import {
|
||||
compareDatabases,
|
||||
migrateSettings,
|
||||
@@ -1104,9 +1053,6 @@ import { logger } from "../utils/logger";
|
||||
*/
|
||||
@Component({
|
||||
name: "DatabaseMigration",
|
||||
components: {
|
||||
IconRenderer,
|
||||
},
|
||||
})
|
||||
export default class DatabaseMigration extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="deep-link-error">
|
||||
<div class="safe-area-spacer"></div>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<h1>Invalid Deep Link</h1>
|
||||
<div class="error-details">
|
||||
<div class="error-message">
|
||||
@@ -39,7 +39,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -47,7 +47,7 @@ import { computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
VALID_DEEP_LINK_ROUTES,
|
||||
deepLinkSchemas,
|
||||
deepLinkPathSchemas,
|
||||
} from "../interfaces/deepLinks";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
import { logger } from "../utils/logger";
|
||||
@@ -56,7 +56,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
// an object with the route as the key and the first param name as the value
|
||||
const deepLinkSchemaKeys = Object.fromEntries(
|
||||
Object.entries(deepLinkSchemas).map(([route, schema]) => {
|
||||
Object.entries(deepLinkPathSchemas).map(([route, schema]) => {
|
||||
const param = Object.keys(schema.shape)[0];
|
||||
return [route, param];
|
||||
}),
|
||||
@@ -114,18 +114,6 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.deep-link-error {
|
||||
padding-top: 60px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.safe-area-spacer {
|
||||
height: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #ff4444;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@@ -1,95 +1,87 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="relative w-[100vw] h-[100vh]">
|
||||
<div
|
||||
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl text-center font-semibold relative mb-4">
|
||||
Redirecting to Time Safari
|
||||
</h1>
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl text-center font-semibold relative px-7">
|
||||
Redirecting to Time Safari
|
||||
</h1>
|
||||
|
||||
<div v-if="destinationUrl" class="space-y-4">
|
||||
<!-- Platform-specific messaging -->
|
||||
<div class="text-center text-gray-600 mb-4">
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>If the app doesn't open automatically, use one of these
|
||||
options:</span
|
||||
>
|
||||
<span v-else>Choose how you'd like to open this link:</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deep Link Button -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="deepLinkUrl || '#'"
|
||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
@click="handleDeepLinkClick"
|
||||
<div v-if="destinationUrl" class="space-y-4">
|
||||
<!-- Platform-specific messaging -->
|
||||
<div class="text-center text-gray-600 mb-4">
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>If the app doesn't open automatically, use one of these
|
||||
options:</span
|
||||
>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
</a>
|
||||
</div>
|
||||
<span v-else>Choose how you'd like to open this link:</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Web Fallback Link -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="webUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
||||
@click="handleWebFallbackClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Web Browser Instead</span>
|
||||
<span v-else>Open in Web Browser</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Manual Instructions -->
|
||||
<div class="text-center text-sm text-gray-500 mt-4">
|
||||
<p v-if="isMobile">
|
||||
Or manually open:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform info for debugging -->
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="text-center text-xs text-gray-400 mt-4"
|
||||
<!-- Deep Link Button -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="deepLinkUrl || '#'"
|
||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
@click="handleDeepLinkClick"
|
||||
>
|
||||
<p>
|
||||
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||
</p>
|
||||
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
|
||||
</div>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
||||
{{ pageError }}
|
||||
<!-- Web Fallback Link -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="webUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
||||
@click="handleWebFallbackClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Web Browser Instead</span>
|
||||
<span v-else>Open in Web Browser</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-600">
|
||||
<p>Processing redirect...</p>
|
||||
<!-- Manual Instructions -->
|
||||
<div class="text-center text-sm text-gray-500 mt-4">
|
||||
<p v-if="isMobile">
|
||||
Or manually open:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform info for debugging -->
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="text-center text-xs text-gray-400 mt-4"
|
||||
>
|
||||
<p>
|
||||
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||
</p>
|
||||
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-600">
|
||||
<p>Processing redirect...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ project.name || "Unnamed Project" }}
|
||||
{{ project.name || unnamedProject }}
|
||||
</h2>
|
||||
<div class="text-sm">
|
||||
<font-awesome
|
||||
@@ -340,6 +340,7 @@ import {
|
||||
NOTIFY_DISCOVER_LOCAL_SEARCH_ERROR,
|
||||
NOTIFY_DISCOVER_MAP_SEARCH_ERROR,
|
||||
} from "@/constants/notifications";
|
||||
import { UNNAMED_PROJECT } from "@/constants/entities";
|
||||
interface Tile {
|
||||
indexLat: number;
|
||||
indexLon: number;
|
||||
@@ -370,6 +371,13 @@ export default class DiscoverView extends Vue {
|
||||
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
/**
|
||||
* Get the unnamed project constant
|
||||
*/
|
||||
get unnamedProject(): string {
|
||||
return UNNAMED_PROJECT;
|
||||
}
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
@@ -458,7 +466,9 @@ export default class DiscoverView extends Vue {
|
||||
if (this.isLocalActive) {
|
||||
await this.searchLocal();
|
||||
} else if (this.isMappedActive) {
|
||||
const mapRef = this.$refs.projectMap as any;
|
||||
const mapRef = this.$refs.projectMap as {
|
||||
leafletObject: L.Map;
|
||||
};
|
||||
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
|
||||
} else {
|
||||
await this.searchAll();
|
||||
@@ -518,11 +528,11 @@ export default class DiscoverView extends Vue {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
logger.error("Error with search all: " + errorStringForLog(e));
|
||||
this.notify.error(
|
||||
e.userMessage || NOTIFY_DISCOVER_SEARCH_ERROR.message,
|
||||
(e as { userMessage?: string })?.userMessage ||
|
||||
NOTIFY_DISCOVER_SEARCH_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Help
|
||||
<span class="text-xs text-gray-500">{{ package.version }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +200,7 @@
|
||||
</p>
|
||||
<p>
|
||||
Then you can record your appreciation for... whatever: select any contact on the home page
|
||||
(or "Unnamed") and send it. The main goal is to record what people
|
||||
(or "{{ unnamedEntityName }}") and send it. The main goal is to record what people
|
||||
have given you, to grow giving economies. You can also record your own
|
||||
ideas for projects. Each claim is recorded on a
|
||||
custom ledger.
|
||||
@@ -318,8 +319,9 @@
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
|
||||
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
|
||||
Beware that this will erase your existing contact & settings.
|
||||
click Advanced, and follow the instructions to "Import Contacts".
|
||||
(There is currently no way to import other settings, so you'll have to recreate
|
||||
by hand your search area, filters, etc.)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -335,14 +337,18 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
|
||||
<p>
|
||||
Before doing this, you may want to back up your data with the instructions above.
|
||||
Before doing this, you should back up your data with the instructions above.
|
||||
Note that this does not erase data sent to our servers (see contact info below)
|
||||
</p>
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Mobile
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Home Screen: hold down on the icon, and choose to delete it
|
||||
App Store app: hold down on the icon, then uninstall it
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Home Screen PWA: hold down on the icon, and delete it
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
|
||||
@@ -414,15 +420,6 @@
|
||||
different page.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
Where do I get help with notifications?
|
||||
</h2>
|
||||
<p>
|
||||
<router-link class="text-blue-500" to="/help-notifications"
|
||||
>Here.</router-link
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
|
||||
What can I do?
|
||||
@@ -433,10 +430,13 @@
|
||||
</p>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Drag down on the screen to refresh it; do that multiple times, because
|
||||
For mobile apps, make sure you're connected to the internet.
|
||||
</li>
|
||||
<li>
|
||||
For PWAs, drag down on the screen to refresh it; do that multiple times, because
|
||||
it sometimes takes multiple tries for the app to refresh to the latest version.
|
||||
You can see the version information at the bottom of this page; the best
|
||||
way to determine the latest version is to open this page in an incognito/private
|
||||
way to determine the latest version is to open TimeSafari.app in an incognito/private
|
||||
browser window and look at the version there.
|
||||
</li>
|
||||
<li>
|
||||
@@ -467,9 +467,6 @@
|
||||
</ul>
|
||||
Then reload Time Safari.
|
||||
</li>
|
||||
<li>
|
||||
Restart your device.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you still have problems, you can clear the cache (see "erase my data" above)
|
||||
@@ -507,16 +504,12 @@
|
||||
</p>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
If using notifications, a server stores push token data. That can be revoked at any time
|
||||
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page.
|
||||
</li>
|
||||
<li>
|
||||
If sending images, a server stores them, too. They can be removed by editing the claim
|
||||
and deleting them.
|
||||
If sending images, a server stores them. They can be removed by editing each claim
|
||||
and deleting the image.
|
||||
</li>
|
||||
<li>
|
||||
If sending other partner system data (eg. to Trustroots) a public key and message
|
||||
data are stored on a server. Those can be removed via direct personal request.
|
||||
data are stored on a server. Those can be removed via direct personal request (via contact below).
|
||||
</li>
|
||||
<li>
|
||||
For all other claim data,
|
||||
@@ -565,22 +558,22 @@
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<div v-if="Capacitor.isNativePlatform()">
|
||||
<div v-if="isCapacitor">
|
||||
<h2 class="text-xl font-semibold">
|
||||
Do I have the latest version?
|
||||
</h2>
|
||||
<p v-if="Capacitor.getPlatform() === 'ios'">
|
||||
<p v-if="capabilities.isIOS">
|
||||
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
|
||||
Check the App Store.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else-if="Capacitor.getPlatform() === 'android'">
|
||||
<p v-else-if="!capabilities.isIOS && capabilities.isMobile">
|
||||
<a href="https://play.google.com/store/apps/details?id=app.timesafari.app" target="_blank" class="text-blue-500">
|
||||
Check the Play Store.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else>
|
||||
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
|
||||
Sorry, your platform is not recognized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,12 +585,14 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
// Capacitor import removed - using QRNavigationService instead
|
||||
|
||||
import * as Package from "../../package.json";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { APP_SERVER } from "../constants/app";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { QRNavigationService } from "@/services/QRNavigationService";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* HelpView.vue - Comprehensive Help System Component
|
||||
@@ -643,7 +638,14 @@ export default class HelpView extends Vue {
|
||||
showVerifiable = false;
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
Capacitor = Capacitor;
|
||||
// Capacitor reference removed - using QRNavigationService instead
|
||||
|
||||
/**
|
||||
* Get the unnamed entity name constant
|
||||
*/
|
||||
get unnamedEntityName(): string {
|
||||
return UNNAMED_ENTITY_NAME;
|
||||
}
|
||||
|
||||
// Ideally, we put no functionality in here, especially in the setup,
|
||||
// because we never want this page to have a chance of throwing an error.
|
||||
@@ -711,11 +713,10 @@ export default class HelpView extends Vue {
|
||||
* @private
|
||||
*/
|
||||
private handleQRCodeClick(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
const qrNavigationService = QRNavigationService.getInstance();
|
||||
const route = qrNavigationService.getQRScannerRoute();
|
||||
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,33 +86,14 @@ Raymer * @version 1.0.0 */
|
||||
Identity creation is now handled by router navigation guard.
|
||||
-->
|
||||
<div class="mb-4">
|
||||
<div
|
||||
<RegistrationNotice
|
||||
v-if="!isUserRegistered"
|
||||
id="noticeSomeoneMustRegisterYou"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
To share, someone must register you.
|
||||
<div class="block text-center">
|
||||
<button
|
||||
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
@click="showNameThenIdDialog()"
|
||||
>
|
||||
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
|
||||
info
|
||||
</button>
|
||||
</div>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
<div class="flex justify-end w-full">
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
See advanced options
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
:passkeys-enabled="PASSKEYS_ENABLED"
|
||||
:given-name="givenName"
|
||||
message="To share, someone must register you."
|
||||
/>
|
||||
|
||||
<div v-else id="sectionRecordSomethingGiven">
|
||||
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
|
||||
<!-- Record Quick-Action -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
@@ -252,8 +233,6 @@ Raymer * @version 1.0.0 */
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
||||
|
||||
<ImageViewer v-model:is-open="isImageViewerOpen" :image-url="selectedImage" />
|
||||
</template>
|
||||
|
||||
@@ -261,7 +240,6 @@ Raymer * @version 1.0.0 */
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
|
||||
//import App from "../App.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
@@ -272,10 +250,9 @@ import InfiniteScroll from "../components/InfiniteScroll.vue";
|
||||
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
||||
import ImageViewer from "../components/ImageViewer.vue";
|
||||
import ActivityListItem from "../components/ActivityListItem.vue";
|
||||
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||
import {
|
||||
AppString,
|
||||
NotificationIface,
|
||||
@@ -305,6 +282,7 @@ import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
|
||||
import * as Package from "../../package.json";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
// consolidate this with GiveActionClaim in src/interfaces/claims.ts
|
||||
interface Claim {
|
||||
@@ -383,12 +361,11 @@ interface FeedError {
|
||||
GiftedPrompts,
|
||||
InfiniteScroll,
|
||||
OnboardingDialog,
|
||||
ChoiceButtonDialog,
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
UserNameDialog,
|
||||
ImageViewer,
|
||||
ActivityListItem,
|
||||
RegistrationNotice,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
@@ -476,7 +453,7 @@ export default class HomeView extends Vue {
|
||||
// Re-initialize identity with new settings (loads settings internally)
|
||||
await this.initializeIdentity();
|
||||
} else {
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[HomeView Settings Trace] 📍 DID unchanged, skipping re-initialization",
|
||||
);
|
||||
}
|
||||
@@ -591,10 +568,27 @@ export default class HomeView extends Vue {
|
||||
this.isRegistered = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced error logging with server context
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
|
||||
logger.warn(
|
||||
"[HomeView Settings Trace] ⚠️ Registration check failed",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage,
|
||||
did: this.activeDid,
|
||||
server: this.apiServer,
|
||||
errorCode: axiosError?.response?.data?.error?.code,
|
||||
errorMessage: axiosError?.response?.data?.error?.message,
|
||||
httpStatus: axiosError?.response?.status,
|
||||
needsUserMigration: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -608,8 +602,7 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures API server is correctly set for the current platform
|
||||
* For Electron, always use production endpoint regardless of saved settings
|
||||
* Ensures correct API server configuration
|
||||
*
|
||||
* @internal
|
||||
* Called after loading settings to ensure correct API endpoint
|
||||
@@ -617,12 +610,9 @@ export default class HomeView extends Vue {
|
||||
private async ensureCorrectApiServer() {
|
||||
const { DEFAULT_ENDORSER_API_SERVER } = await import("../constants/app");
|
||||
|
||||
if (process.env.VITE_PLATFORM === "electron") {
|
||||
// **CRITICAL FIX**: Always use production API server for Electron
|
||||
// This prevents the capacitor-electron:// protocol from being used for API calls
|
||||
this.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
||||
} else if (!this.apiServer) {
|
||||
// **FIX**: Set default API server for web/development if not already set
|
||||
// Only set default if no user preference exists
|
||||
if (!this.apiServer) {
|
||||
// Set default API server for any platform if not already set
|
||||
this.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
||||
}
|
||||
}
|
||||
@@ -756,17 +746,34 @@ export default class HomeView extends Vue {
|
||||
* Called by FeedFilters component when filters change
|
||||
*/
|
||||
async reloadFeedOnChange() {
|
||||
const settings = await this.$accountSettings(this.activeDid, {
|
||||
filterFeedByVisible: false,
|
||||
filterFeedByNearby: false,
|
||||
logger.debug("[HomeView] 🔄 reloadFeedOnChange() called - refreshing feed");
|
||||
|
||||
// Get current settings without overriding with defaults
|
||||
const settings = await this.$accountSettings(this.activeDid);
|
||||
|
||||
logger.debug("[HomeView] 📊 Current filter settings:", {
|
||||
filterFeedByVisible: settings.filterFeedByVisible,
|
||||
filterFeedByNearby: settings.filterFeedByNearby,
|
||||
searchBoxes: settings.searchBoxes?.length || 0,
|
||||
});
|
||||
|
||||
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
||||
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
||||
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
||||
|
||||
logger.debug("[HomeView] 🎯 Updated filter states:", {
|
||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
});
|
||||
|
||||
this.feedData = [];
|
||||
this.feedPreviousOldestId = undefined;
|
||||
|
||||
logger.debug("[HomeView] 🧹 Cleared feed data, calling updateAllFeed()");
|
||||
await this.updateAllFeed();
|
||||
|
||||
logger.debug("[HomeView] ✅ Feed refresh completed");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -845,6 +852,14 @@ export default class HomeView extends Vue {
|
||||
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
||||
*/
|
||||
async updateAllFeed() {
|
||||
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
|
||||
isFeedLoading: this.isFeedLoading,
|
||||
currentFeedDataLength: this.feedData.length,
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||
});
|
||||
|
||||
this.isFeedLoading = true;
|
||||
let endOfResults = true;
|
||||
|
||||
@@ -853,21 +868,37 @@ export default class HomeView extends Vue {
|
||||
this.apiServer,
|
||||
this.feedPreviousOldestId,
|
||||
);
|
||||
|
||||
logger.debug("[HomeView] 📡 Retrieved gives from API", {
|
||||
resultsCount: results.data.length,
|
||||
endOfResults,
|
||||
});
|
||||
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// gather any contacts that user has blocked from view
|
||||
await this.processFeedResults(results.data);
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
|
||||
logger.debug("[HomeView] 📝 Processed feed results", {
|
||||
processedCount: this.feedData.length,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[HomeView] ❌ Error in updateAllFeed:", e);
|
||||
this.handleFeedError(e);
|
||||
}
|
||||
|
||||
if (this.feedData.length === 0 && !endOfResults) {
|
||||
logger.debug("[HomeView] 🔄 No results after filtering, retrying...");
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
|
||||
this.isFeedLoading = false;
|
||||
logger.debug("[HomeView] ✅ updateAllFeed() completed", {
|
||||
finalFeedDataLength: this.feedData.length,
|
||||
isFeedLoading: this.isFeedLoading,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,12 +923,35 @@ export default class HomeView extends Vue {
|
||||
* @param records Array of feed records to process
|
||||
*/
|
||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||
logger.debug("[HomeView] 📝 Processing feed results:", {
|
||||
inputRecords: records.length,
|
||||
currentFilters: {
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||
},
|
||||
});
|
||||
|
||||
let processedCount = 0;
|
||||
let filteredCount = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const processedRecord = await this.processRecord(record);
|
||||
if (processedRecord) {
|
||||
this.feedData.push(processedRecord);
|
||||
processedCount++;
|
||||
} else {
|
||||
filteredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("[HomeView] 📊 Feed processing results:", {
|
||||
processed: processedCount,
|
||||
filtered: filteredCount,
|
||||
total: records.length,
|
||||
finalFeedLength: this.feedData.length,
|
||||
});
|
||||
|
||||
this.feedPreviousOldestId = records[records.length - 1].jwtId;
|
||||
}
|
||||
|
||||
@@ -931,7 +985,7 @@ export default class HomeView extends Vue {
|
||||
* - this.feedData (via createFeedRecord)
|
||||
*
|
||||
* @param record The record to process
|
||||
* @returns Processed record with contact info if it passes filters, null otherwise
|
||||
* @returns Processed record if it passes filters, null otherwise
|
||||
*/
|
||||
private async processRecord(
|
||||
record: GiveSummaryRecord,
|
||||
@@ -941,13 +995,28 @@ export default class HomeView extends Vue {
|
||||
const recipientDid = this.extractRecipientDid(claim);
|
||||
|
||||
const fulfillsPlan = await this.getFulfillsPlan(record);
|
||||
|
||||
// Log record details for debugging
|
||||
logger.debug("[HomeView] 🔍 Processing record:", {
|
||||
recordId: record.jwtId,
|
||||
hasFulfillsPlan: !!fulfillsPlan,
|
||||
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
||||
filters: {
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
isFeedFilteredByVisible: this.isFeedFilteredByNearby,
|
||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||
logger.debug("[HomeView] ❌ Record filtered out:", record.jwtId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.extractProvider(claim);
|
||||
const providedByPlan = await this.getProvidedByPlan(provider);
|
||||
|
||||
logger.debug("[HomeView] ✅ Record included:", record.jwtId);
|
||||
return this.createFeedRecord(
|
||||
record,
|
||||
claim,
|
||||
@@ -1096,6 +1165,26 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// Add debug logging for nearby filter
|
||||
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
||||
logger.debug("[HomeView] 🔍 Nearby filter check:", {
|
||||
recordId: record.jwtId,
|
||||
hasFulfillsPlan: !!fulfillsPlan,
|
||||
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
|
||||
location: fulfillsPlan
|
||||
? { lat: fulfillsPlan.locLat, lon: fulfillsPlan.locLon }
|
||||
: null,
|
||||
inSearchBox:
|
||||
fulfillsPlan?.locLat && fulfillsPlan?.locLon
|
||||
? this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
)
|
||||
: null,
|
||||
finalResult: anyMatch,
|
||||
});
|
||||
}
|
||||
|
||||
return anyMatch;
|
||||
}
|
||||
|
||||
@@ -1475,30 +1564,41 @@ export default class HomeView extends Vue {
|
||||
* @param giver Optional contact info for giver
|
||||
* @param description Optional gift description
|
||||
*/
|
||||
openDialog(giver?: GiverReceiverInputInfo | "Unnamed", prompt?: string) {
|
||||
if (giver === "Unnamed") {
|
||||
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
undefined,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
prompt,
|
||||
);
|
||||
// Immediately select "Unnamed" and move to Step 2
|
||||
(this.$refs.giftedDialog as GiftedDialog).selectGiver();
|
||||
openDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
|
||||
// Determine the giver entity based on DID logic
|
||||
const giverEntity = this.createGiverEntity(giver);
|
||||
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
giverEntity,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You", // In HomeView, we always use "You" as the giver
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
prompt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates giver entity using DID-based logic
|
||||
*/
|
||||
private createGiverEntity(
|
||||
giver?: GiverReceiverInputInfo,
|
||||
): GiverReceiverInputInfo | undefined {
|
||||
if (!giver) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle GiverReceiverInputInfo object
|
||||
if (giver.did === this.activeDid) {
|
||||
// If DID matches active DID, create "You" entity
|
||||
return { did: this.activeDid, name: "You" };
|
||||
} else if (!giver.did || giver.did === "") {
|
||||
// If DID is empty/null, create "Unnamed" entity
|
||||
return { did: "", name: UNNAMED_ENTITY_NAME };
|
||||
} else {
|
||||
(this.$refs.giftedDialog as GiftedDialog).open(
|
||||
giver,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
prompt,
|
||||
);
|
||||
// Return the giver as-is
|
||||
return giver;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1531,7 +1631,10 @@ export default class HomeView extends Vue {
|
||||
* Called by template click handler
|
||||
*/
|
||||
openFeedFilters() {
|
||||
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
||||
(this.$refs.feedFilters as FeedFilters).open(
|
||||
this.reloadFeedOnChange,
|
||||
this.activeDid,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1546,67 +1649,6 @@ export default class HomeView extends Vue {
|
||||
return known ? "text-slate-500" : "text-slate-100";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows name input dialog if needed
|
||||
*
|
||||
* @public
|
||||
* @callGraph
|
||||
* Called by: Template
|
||||
* Calls:
|
||||
* - UserNameDialog.open()
|
||||
* - promptForShareMethod()
|
||||
*
|
||||
* @chain
|
||||
* Template -> showNameThenIdDialog() -> promptForShareMethod()
|
||||
*
|
||||
* @requires
|
||||
* - this.$refs.userNameDialog
|
||||
* - this.givenName
|
||||
*/
|
||||
showNameThenIdDialog() {
|
||||
if (!this.givenName) {
|
||||
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
||||
this.promptForShareMethod();
|
||||
});
|
||||
} else {
|
||||
this.promptForShareMethod();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows dialog for sharing method selection
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: showNameThenIdDialog()
|
||||
* Calls: ChoiceButtonDialog.open()
|
||||
*
|
||||
* @chain
|
||||
* Template -> showNameThenIdDialog() -> promptForShareMethod()
|
||||
*
|
||||
* @requires
|
||||
* - this.$refs.choiceButtonDialog
|
||||
* - this.$router
|
||||
*/
|
||||
promptForShareMethod() {
|
||||
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
||||
title: "How can you share your info?",
|
||||
text: "",
|
||||
option1Text: "We are in a meeting together",
|
||||
option2Text: "We are nearby with cameras",
|
||||
option3Text: "We will share some other way",
|
||||
onOption1: () => {
|
||||
this.$router.push({ name: "onboard-meeting-list" });
|
||||
},
|
||||
onOption2: () => {
|
||||
this.handleQRCodeClick();
|
||||
},
|
||||
onOption3: () => {
|
||||
this.$router.push({ name: "share-my-contact-info" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens image viewer dialog
|
||||
*
|
||||
@@ -1627,10 +1669,7 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
openPersonDialog(
|
||||
giver?: GiverReceiverInputInfo | "Unnamed",
|
||||
prompt?: string,
|
||||
) {
|
||||
openPersonDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
|
||||
this.showProjectsDialog = false;
|
||||
this.openDialog(giver, prompt);
|
||||
}
|
||||
|
||||
@@ -229,12 +229,14 @@ export default class IdentitySwitcherView extends Vue {
|
||||
if (did) {
|
||||
try {
|
||||
const newSettings = await this.$accountSettings(did);
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[IdentitySwitcher Settings Trace] ✅ New account settings loaded",
|
||||
{
|
||||
did,
|
||||
settingsKeys: Object.keys(newSettings).filter(
|
||||
(k) => (newSettings as any)[k] !== undefined,
|
||||
(k) =>
|
||||
k in newSettings &&
|
||||
newSettings[k as keyof typeof newSettings] !== undefined,
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -250,7 +252,7 @@ export default class IdentitySwitcherView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[IdentitySwitcher Settings Trace] 🔄 Navigating to home to trigger watcher",
|
||||
{
|
||||
newDid: did,
|
||||
|
||||
@@ -88,9 +88,15 @@ import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
|
||||
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
|
||||
import {
|
||||
retrieveAccountCount,
|
||||
importFromMnemonic,
|
||||
checkForDuplicateAccount,
|
||||
DUPLICATE_ACCOUNT_ERROR,
|
||||
} from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
|
||||
|
||||
/**
|
||||
* Import Account View Component
|
||||
@@ -198,6 +204,19 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for duplicate account before importing
|
||||
const isDuplicate = await checkForDuplicateAccount(
|
||||
this.mnemonic,
|
||||
this.derivationPath,
|
||||
);
|
||||
if (isDuplicate) {
|
||||
this.notify.warning(
|
||||
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await importFromMnemonic(
|
||||
this.mnemonic,
|
||||
this.derivationPath,
|
||||
@@ -221,10 +240,22 @@ export default class ImportAccountView extends Vue {
|
||||
|
||||
this.notify.success("Account imported successfully!", TIMEOUTS.STANDARD);
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.$logError("Import failed: " + error);
|
||||
|
||||
// Check if this is a duplicate account error from saveNewIdentity
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
|
||||
this.notify.warning(
|
||||
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.notify.error(
|
||||
error.message || "Failed to import account.",
|
||||
errorMessage || "Failed to import account.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,11 +83,12 @@ import {
|
||||
retrieveAllAccountsMetadata,
|
||||
retrieveFullyDecryptedAccount,
|
||||
saveNewIdentity,
|
||||
checkForDuplicateAccount,
|
||||
} from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
|
||||
import {
|
||||
NOTIFY_ACCOUNT_DERIVATION_SUCCESS,
|
||||
NOTIFY_ACCOUNT_DERIVATION_ERROR,
|
||||
@@ -100,7 +101,7 @@ import {
|
||||
export default class ImportAccountView extends Vue {
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
$router!: Router;
|
||||
$notify!: (notification: any, timeout?: number) => void;
|
||||
$notify!: NotifyFunction;
|
||||
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
@@ -171,6 +172,16 @@ export default class ImportAccountView extends Vue {
|
||||
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
|
||||
|
||||
try {
|
||||
// Check for duplicate account before creating
|
||||
const isDuplicate = await checkForDuplicateAccount(newId.did);
|
||||
if (isDuplicate) {
|
||||
this.notify.warning(
|
||||
"This derived account already exists. Please try a different derivation path.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await saveNewIdentity(newId, mne, newDerivPath);
|
||||
|
||||
// record that as the active DID
|
||||
|
||||
@@ -110,10 +110,22 @@ export default class NewEditAccountView extends Vue {
|
||||
* @async
|
||||
*/
|
||||
async onClickSaveChanges() {
|
||||
await this.$updateSettings({
|
||||
firstName: this.givenName,
|
||||
lastName: "", // deprecated, pre v 0.1.3
|
||||
});
|
||||
// Get the current active DID to save to user-specific settings
|
||||
const settings = await this.$accountSettings();
|
||||
const activeDid = settings.activeDid;
|
||||
|
||||
if (activeDid) {
|
||||
// Save to user-specific settings for the current identity
|
||||
await this.$saveUserSettings(activeDid, {
|
||||
firstName: this.givenName,
|
||||
});
|
||||
} else {
|
||||
// Fallback to master settings if no active DID
|
||||
await this.$saveSettings({
|
||||
firstName: this.givenName,
|
||||
});
|
||||
}
|
||||
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ export default class OnboardMeetingListView extends Vue {
|
||||
if (response2.data?.data) {
|
||||
this.meetings = response2.data.data;
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.$logAndConsole(
|
||||
"Error fetching meetings: " + errorStringForLog(error),
|
||||
true,
|
||||
|
||||
@@ -113,7 +113,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
try {
|
||||
// Identity creation should be handled by router guard, but keep as fallback for meeting setup
|
||||
if (!this.activeDid) {
|
||||
logger.info(
|
||||
this.$logAndConsole(
|
||||
"[OnboardMeetingMembersView] No active DID found, creating identity as fallback for meeting setup",
|
||||
);
|
||||
this.activeDid = await generateSaveAndActivateIdentity();
|
||||
|
||||
@@ -345,7 +345,9 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
|
||||
async created() {
|
||||
this.notify = createNotifyHelpers(this.$notify as any);
|
||||
this.notify = createNotifyHelpers(
|
||||
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
||||
);
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
@@ -419,7 +421,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
} else {
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
class="text-blue-500"
|
||||
@click="onClickLoadProject(plan.handleId)"
|
||||
>
|
||||
{{ plan.name || "Unnamed Project" }}
|
||||
{{ plan.name || unnamedProject }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fulfillersToHitLimit" class="text-center">
|
||||
@@ -207,7 +207,7 @@
|
||||
class="text-blue-500"
|
||||
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
||||
>
|
||||
{{ fulfilledByThis.name || "Unnamed Project" }}
|
||||
{{ fulfilledByThis.name || unnamedProject }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<!-- First, offers on the left-->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
data-testId="offerButton"
|
||||
@@ -243,13 +243,19 @@
|
||||
:project-name="name"
|
||||
/>
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 mt-4">Offered To This Idea</h3>
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Offered To This Idea
|
||||
</h3>
|
||||
|
||||
<div v-if="offersToThis.length === 0">
|
||||
(None yet. Wanna
|
||||
<span class="cursor-pointer text-blue-500" @click="openOfferDialog()"
|
||||
>offer something... especially if others join you</span
|
||||
>?)
|
||||
<div v-if="offersToThis.length === 0" class="text-sm">
|
||||
(None yet.<span v-if="activeDid && isRegistered">
|
||||
Wanna
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="openOfferDialog()"
|
||||
>offer something… especially if others join you</span
|
||||
>?</span
|
||||
>)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
@@ -314,7 +320,7 @@
|
||||
<!-- Now, gives TO this project in the middle -->
|
||||
<!-- (similar to "FROM" gift display below) -->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-to">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@@ -325,7 +331,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4">Given To This Project</h3>
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Given To This Project
|
||||
</h3>
|
||||
|
||||
<div v-if="givesToThis.length === 0" class="text-sm">
|
||||
(None yet. If you've seen something, say something by clicking a
|
||||
@@ -476,7 +484,7 @@
|
||||
<!-- Finally, gives FROM this project on the right -->
|
||||
<!-- (similar to "TO" gift display above) -->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-from">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@@ -494,11 +502,13 @@
|
||||
:is-from-project-view="true"
|
||||
/>
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 mt-4">
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Benefitted From This Project
|
||||
</h3>
|
||||
|
||||
<div v-if="givesProvidedByThis.length === 0">(None yet.)</div>
|
||||
<div v-if="givesProvidedByThis.length === 0" class="text-sm">
|
||||
(None yet.)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
@@ -611,6 +621,7 @@ import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { UNNAMED_PROJECT } from "@/constants/entities";
|
||||
/**
|
||||
* Project View Component
|
||||
* @author Matthew Raymer
|
||||
@@ -664,6 +675,13 @@ export default class ProjectViewView extends Vue {
|
||||
/** Notification helpers instance */
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
/**
|
||||
* Get the unnamed project constant
|
||||
*/
|
||||
get unnamedProject(): string {
|
||||
return UNNAMED_PROJECT;
|
||||
}
|
||||
|
||||
// Account and Settings State
|
||||
/** Currently active DID */
|
||||
activeDid = "";
|
||||
|
||||
@@ -216,15 +216,12 @@
|
||||
<font-awesome icon="plus" :class="plusIconClasses" />
|
||||
button. You'll never know until you try.
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
:class="onboardingButtonClasses"
|
||||
@click="showNameThenIdDialog()"
|
||||
>
|
||||
Get someone to onboard you.
|
||||
</button>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
</div>
|
||||
<RegistrationNotice
|
||||
v-else
|
||||
:passkeys-enabled="PASSKEYS_ENABLED"
|
||||
:given-name="givenName"
|
||||
message="To announce a project, get someone to onboard you."
|
||||
/>
|
||||
</div>
|
||||
<ul id="listProjects" class="border-t border-slate-300">
|
||||
<li
|
||||
@@ -247,7 +244,7 @@
|
||||
|
||||
<div class="grow overflow-hidden">
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ project.name || "Unnamed Project" }}
|
||||
{{ project.name || unnamedProject }}
|
||||
</h2>
|
||||
<div class="text-sm truncate">
|
||||
{{ project.description }}
|
||||
@@ -264,16 +261,16 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
// Capacitor import removed - using QRNavigationService instead
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, PASSKEYS_ENABLED } from "../constants/app";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import RegistrationNotice from "../components/RegistrationNotice.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
|
||||
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
|
||||
@@ -281,14 +278,15 @@ import { OnboardPage, iconForUnitCode } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
|
||||
import {
|
||||
NOTIFY_NO_ACCOUNT_ERROR,
|
||||
NOTIFY_PROJECT_LOAD_ERROR,
|
||||
NOTIFY_PROJECT_INIT_ERROR,
|
||||
NOTIFY_OFFERS_LOAD_ERROR,
|
||||
NOTIFY_OFFERS_FETCH_ERROR,
|
||||
NOTIFY_CAMERA_SHARE_METHOD,
|
||||
} from "@/constants/notifications";
|
||||
import { UNNAMED_PROJECT } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* Projects View Component
|
||||
@@ -317,7 +315,7 @@ import {
|
||||
OnboardingDialog,
|
||||
ProjectIcon,
|
||||
TopMessage,
|
||||
UserNameDialog,
|
||||
RegistrationNotice,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
@@ -327,6 +325,13 @@ export default class ProjectsView extends Vue {
|
||||
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
/**
|
||||
* Get the unnamed project constant
|
||||
*/
|
||||
get unnamedProject(): string {
|
||||
return UNNAMED_PROJECT;
|
||||
}
|
||||
|
||||
// User account state
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
@@ -335,6 +340,7 @@ export default class ProjectsView extends Vue {
|
||||
givenName = "";
|
||||
isLoading = false;
|
||||
isRegistered = false;
|
||||
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
||||
|
||||
// Data collections
|
||||
offers: OfferSummaryRecord[] = [];
|
||||
@@ -464,8 +470,10 @@ export default class ProjectsView extends Vue {
|
||||
);
|
||||
this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("Got error loading plans:", error.message || error);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error("Got error loading plans:", errorMessage);
|
||||
this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
@@ -578,8 +586,10 @@ export default class ProjectsView extends Vue {
|
||||
);
|
||||
this.notify.error(NOTIFY_OFFERS_LOAD_ERROR.message, TIMEOUTS.LONG);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("Got error loading offers:", error.message || error);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error("Got error loading offers:", errorMessage);
|
||||
this.notify.error(NOTIFY_OFFERS_FETCH_ERROR.message, TIMEOUTS.LONG);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
@@ -619,39 +629,6 @@ export default class ProjectsView extends Vue {
|
||||
* Ensures user has provided their name before proceeding with contact sharing.
|
||||
* Uses UserNameDialog component if name is not set.
|
||||
*/
|
||||
showNameThenIdDialog() {
|
||||
if (!this.givenName) {
|
||||
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
||||
this.promptForShareMethod();
|
||||
});
|
||||
} else {
|
||||
this.promptForShareMethod();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts user to choose contact sharing method
|
||||
*
|
||||
* Presents modal dialog asking if users are nearby with cameras.
|
||||
* Routes to appropriate sharing method based on user's choice:
|
||||
* - QR code sharing for nearby users with cameras
|
||||
* - Alternative sharing methods for remote users
|
||||
*/
|
||||
promptForShareMethod() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: NOTIFY_CAMERA_SHARE_METHOD.title,
|
||||
text: NOTIFY_CAMERA_SHARE_METHOD.text,
|
||||
onYes: () => this.handleQRCodeClick(),
|
||||
onNo: () => this.$router.push({ name: "share-my-contact-info" }),
|
||||
yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText,
|
||||
noText: NOTIFY_CAMERA_SHARE_METHOD.noText,
|
||||
},
|
||||
TIMEOUTS.MODAL,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed properties for template logic streamlining
|
||||
@@ -717,14 +694,6 @@ export default class ProjectsView extends Vue {
|
||||
return "bg-green-600 text-white px-1.5 py-1 rounded-full";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class names for onboarding button
|
||||
* @returns String with CSS classes for the onboarding button
|
||||
*/
|
||||
get onboardingButtonClasses() {
|
||||
return "text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class names for project tab styling
|
||||
* @returns Object with CSS classes based on current tab selection
|
||||
@@ -749,21 +718,6 @@ export default class ProjectsView extends Vue {
|
||||
* Utility methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles QR code sharing functionality with platform detection
|
||||
*
|
||||
* Routes to appropriate QR code interface based on current platform:
|
||||
* - Full QR scanner for native mobile platforms
|
||||
* - Web-based QR interface for browser environments
|
||||
*/
|
||||
private handleQRCodeClick() {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method compatibility
|
||||
* @deprecated Use computedOfferTabClassNames for backward compatibility
|
||||
|
||||
@@ -69,10 +69,17 @@
|
||||
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
|
||||
<span>
|
||||
{{ claimCountWithHiddenText }}
|
||||
so if you expected but do not see details from someone then ask them to
|
||||
check that their activity is visible to you on their Contacts
|
||||
<font-awesome icon="users" class="text-slate-500" />
|
||||
page.
|
||||
If you don't see expected info above for someone, ask them to check that
|
||||
their activity is visible to you (
|
||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
) on
|
||||
<a
|
||||
class="text-blue-500 underline cursor-pointer"
|
||||
@click="copyContactsLinkToClipboard"
|
||||
>
|
||||
this page </a
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
|
||||
@@ -120,10 +127,11 @@ import { DateTime } from "luxon";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, APP_SERVER } from "../constants/app";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
@@ -148,6 +156,7 @@ import {
|
||||
NOTIFY_ALL_CONFIRMATIONS_ERROR,
|
||||
NOTIFY_GIVE_SEND_ERROR,
|
||||
NOTIFY_CLAIMS_SEND_ERROR,
|
||||
NOTIFY_COPIED_TO_CLIPBOARD,
|
||||
createConfirmationSuccessMessage,
|
||||
createCombinedSuccessMessage,
|
||||
} from "@/constants/notifications";
|
||||
@@ -195,8 +204,8 @@ export default class QuickActionBvcEndView extends Vue {
|
||||
get claimCountWithHiddenText() {
|
||||
if (this.claimCountWithHidden === 0) return "";
|
||||
return this.claimCountWithHidden === 1
|
||||
? "There is 1 other claim with hidden details,"
|
||||
: `There are ${this.claimCountWithHidden} other claims with hidden details,`;
|
||||
? "There is 1 other claim with hidden details."
|
||||
: `There are ${this.claimCountWithHidden} other claims with hidden details.`;
|
||||
}
|
||||
|
||||
get claimCountByUserText() {
|
||||
@@ -239,7 +248,8 @@ export default class QuickActionBvcEndView extends Vue {
|
||||
}
|
||||
const eventStartDateObj = currentOrPreviousSat
|
||||
.set({ weekday: 6 })
|
||||
.set({ hour: 9 })
|
||||
.set({ hour: 8 })
|
||||
.set({ minute: 30 }) // to catch if people put their claims 30 minutes early
|
||||
.startOf("hour");
|
||||
|
||||
// Hack, but full ISO pushes the length to 340 which crashes verifyJWT!
|
||||
@@ -295,6 +305,25 @@ export default class QuickActionBvcEndView extends Vue {
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
copyContactsLinkToClipboard() {
|
||||
const deepLinkUrl = `${APP_SERVER}/deep-link/did/${this.activeDid}`;
|
||||
useClipboard()
|
||||
.copy(deepLinkUrl)
|
||||
.then(() => {
|
||||
this.notify.success(
|
||||
NOTIFY_COPIED_TO_CLIPBOARD.message("Your info link"),
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to copy to clipboard:", error);
|
||||
this.notify.error(
|
||||
"Failed to copy link to clipboard. Please try again.",
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async record() {
|
||||
try {
|
||||
if (this.claimsToConfirmSelected.length > 0) {
|
||||
|
||||
@@ -144,8 +144,8 @@ export default class ShareMyContactInfoView extends Vue {
|
||||
* Copy the contact message to clipboard
|
||||
*/
|
||||
private async copyToClipboard(message: string): Promise<void> {
|
||||
const { useClipboard } = await import("@vueuse/core");
|
||||
await useClipboard().copy(message);
|
||||
const { copyToClipboard } = await import("../services/ClipboardService");
|
||||
await copyToClipboard(message);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -203,7 +203,7 @@ export default class StartView extends Vue {
|
||||
// Load account count for display logic
|
||||
this.numAccounts = await retrieveAccountCount();
|
||||
|
||||
logger.info("[StartView] Component mounted", {
|
||||
logger.debug("[StartView] Component mounted", {
|
||||
hasGivenName: !!this.givenName,
|
||||
accountCount: this.numAccounts,
|
||||
passkeysEnabled: this.PASSKEYS_ENABLED,
|
||||
@@ -221,7 +221,7 @@ export default class StartView extends Vue {
|
||||
* Routes user to new identifier creation flow with seed-based approach
|
||||
*/
|
||||
public onClickNewSeed() {
|
||||
logger.info("[StartView] User selected new seed generation");
|
||||
logger.debug("[StartView] User selected new seed generation");
|
||||
this.$router.push({ name: "new-identifier" });
|
||||
}
|
||||
|
||||
@@ -235,14 +235,14 @@ export default class StartView extends Vue {
|
||||
const keyName =
|
||||
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : "");
|
||||
|
||||
logger.info("[StartView] Initiating passkey registration", {
|
||||
logger.debug("[StartView] Initiating passkey registration", {
|
||||
keyName,
|
||||
hasGivenName: !!this.givenName,
|
||||
});
|
||||
|
||||
await registerSaveAndActivatePasskey(keyName);
|
||||
|
||||
logger.info("[StartView] Passkey registration successful");
|
||||
logger.debug("[StartView] Passkey registration successful");
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (error) {
|
||||
logger.error("[StartView] Passkey registration failed", error);
|
||||
@@ -255,7 +255,7 @@ export default class StartView extends Vue {
|
||||
* Routes user to account import flow for existing seed phrase
|
||||
*/
|
||||
public onClickNo() {
|
||||
logger.info("[StartView] User selected existing seed import");
|
||||
logger.debug("[StartView] User selected existing seed import");
|
||||
this.$router.push({ name: "import-account" });
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ export default class StartView extends Vue {
|
||||
* Routes user to address derivation flow for existing seed
|
||||
*/
|
||||
public onClickDerive() {
|
||||
logger.info("[StartView] User selected address derivation");
|
||||
logger.debug("[StartView] User selected address derivation");
|
||||
this.$router.push({ name: "import-derive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +91,95 @@
|
||||
name: 'shared-photo',
|
||||
query: { fileName },
|
||||
}"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
||||
data-testId="fileUploadButton"
|
||||
>
|
||||
Go to Shared Page
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- URL Flow Testing Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">URL Flow Testing</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Test claim and partner server URL flow from initialization to change
|
||||
propagation.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border border-gray-300 rounded-md bg-gray-50">
|
||||
<h3 class="font-semibold mb-2">Current URL State</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>API Server:</strong>
|
||||
<span class="font-mono">{{ apiServer || "Not Set" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Partner API Server:</strong>
|
||||
<span class="font-mono">{{ partnerApiServer || "Not Set" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Active DID:</strong>
|
||||
<span class="font-mono">{{ activeDid || "Not Set" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Platform:</strong>
|
||||
<span class="font-mono">{{ getCurrentPlatform() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
:class="primaryButtonClasses"
|
||||
:disabled="isUrlTestRunning"
|
||||
@click="testUrlFlow()"
|
||||
>
|
||||
{{ isUrlTestRunning ? "Testing..." : "Test URL Flow" }}
|
||||
</button>
|
||||
|
||||
<button :class="secondaryButtonClasses" @click="changeApiServer()">
|
||||
Change API Server (Test → Prod)
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="secondaryButtonClasses"
|
||||
@click="changePartnerApiServer()"
|
||||
>
|
||||
Change Partner API Server (Test → Prod)
|
||||
</button>
|
||||
|
||||
<button :class="warningButtonClasses" @click="resetToDefaults()">
|
||||
Reset to Defaults
|
||||
</button>
|
||||
|
||||
<button :class="secondaryButtonClasses" @click="refreshSettings()">
|
||||
Refresh Settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="secondaryButtonClasses"
|
||||
@click="logEnvironmentState()"
|
||||
>
|
||||
Log Environment State
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-gray-300 rounded-md bg-gray-50">
|
||||
<h3 class="font-semibold mb-2">URL Flow Test Results</h3>
|
||||
<div class="max-h-64 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="(result, index) in urlTestResults"
|
||||
:key="index"
|
||||
class="p-2 border border-gray-200 rounded text-xs font-mono bg-white"
|
||||
>
|
||||
{{ result }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
|
||||
See console for results.
|
||||
@@ -326,6 +408,11 @@ export default class Help extends Vue {
|
||||
showEntityGridTest = false;
|
||||
showPlatformServiceTest = false;
|
||||
|
||||
// for URL flow testing
|
||||
isUrlTestRunning = false;
|
||||
urlTestResults: string[] = [];
|
||||
partnerApiServer: string | undefined;
|
||||
|
||||
/**
|
||||
* Computed properties for template streamlining
|
||||
* Eliminates repeated classes and logic in template
|
||||
@@ -534,24 +621,93 @@ export default class Help extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Component initialization
|
||||
*
|
||||
* Loads user settings and account information for testing interface
|
||||
* Uses PlatformServiceMixin for database access
|
||||
*/
|
||||
async mounted() {
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.userName = settings.firstName;
|
||||
logger.info(
|
||||
"[TestView] 🚀 Component mounting - starting URL flow tracking",
|
||||
);
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
if (this.activeDid) {
|
||||
if (account) {
|
||||
this.credIdHex = account.passkeyCredIdHex as string;
|
||||
} else {
|
||||
alert("No account found for DID " + this.activeDid);
|
||||
// Boot-time logging for initial configuration
|
||||
logger.info("[TestView] 🌍 Boot-time configuration detected:", {
|
||||
platform: process.env.VITE_PLATFORM,
|
||||
defaultEndorserApiServer: process.env.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
defaultPartnerApiServer: process.env.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// Track settings loading
|
||||
logger.info("[TestView] 📥 Loading account settings...");
|
||||
const settings = await this.$accountSettings();
|
||||
|
||||
logger.info("[TestView] 📊 Settings loaded:", {
|
||||
activeDid: settings.activeDid,
|
||||
apiServer: settings.apiServer,
|
||||
partnerApiServer: settings.partnerApiServer,
|
||||
isRegistered: settings.isRegistered,
|
||||
firstName: settings.firstName,
|
||||
});
|
||||
|
||||
// Update component state
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.partnerApiServer = settings.partnerApiServer || "";
|
||||
this.userName = settings.firstName;
|
||||
|
||||
logger.info("[TestView] ✅ Component state updated:", {
|
||||
activeDid: this.activeDid,
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
});
|
||||
|
||||
// Load account metadata
|
||||
if (this.activeDid) {
|
||||
logger.info(
|
||||
"[TestView] 🔍 Loading account metadata for DID:",
|
||||
this.activeDid,
|
||||
);
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
|
||||
if (account) {
|
||||
this.credIdHex = account.passkeyCredIdHex as string;
|
||||
logger.info("[TestView] ✅ Account metadata loaded:", {
|
||||
did: account.did,
|
||||
hasPasskey: !!account.passkeyCredIdHex,
|
||||
passkeyId: account.passkeyCredIdHex,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
"[TestView] ⚠️ No account found for DID:",
|
||||
this.activeDid,
|
||||
);
|
||||
alert("No account found for DID " + this.activeDid);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[TestView] 🎯 Component initialization complete:", {
|
||||
activeDid: this.activeDid,
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
hasPasskey: !!this.credIdHex,
|
||||
platform: this.getCurrentPlatform(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[TestView] ❌ Error during component initialization:",
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "error",
|
||||
type: "error",
|
||||
title: "Initialization Error",
|
||||
text: `Failed to initialize component: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,5 +980,276 @@ export default class Help extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the URL flow from initialization to change propagation.
|
||||
* This simulates the flow where a user's DID is set, and then the
|
||||
* claim and partner server URLs are updated.
|
||||
*/
|
||||
public async testUrlFlow() {
|
||||
this.isUrlTestRunning = true;
|
||||
this.urlTestResults = [];
|
||||
|
||||
try {
|
||||
logger.info("[TestView] 🔬 Starting comprehensive URL flow test");
|
||||
this.addUrlTestResult("🚀 Starting URL flow test...");
|
||||
|
||||
// Test 1: Current state
|
||||
this.addUrlTestResult(`📊 Current State:`);
|
||||
this.addUrlTestResult(` - API Server: ${this.apiServer || "Not Set"}`);
|
||||
this.addUrlTestResult(
|
||||
` - Partner API Server: ${this.partnerApiServer || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(` - Active DID: ${this.activeDid || "Not Set"}`);
|
||||
this.addUrlTestResult(` - Platform: ${this.getCurrentPlatform()}`);
|
||||
|
||||
// Test 2: Load fresh settings
|
||||
this.addUrlTestResult(`\n📥 Testing Settings Loading:`);
|
||||
const startTime = Date.now();
|
||||
const settings = await this.$accountSettings();
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
this.addUrlTestResult(` - Settings loaded in ${loadTime}ms`);
|
||||
this.addUrlTestResult(
|
||||
` - API Server from settings: ${settings.apiServer || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - Partner API Server from settings: ${settings.partnerApiServer || "Not Set"}`,
|
||||
);
|
||||
|
||||
// Test 3: Database query
|
||||
this.addUrlTestResult(`\n💾 Testing Database Query:`);
|
||||
const dbStartTime = Date.now();
|
||||
const dbResult = await this.$dbQuery(
|
||||
"SELECT apiServer, partnerApiServer, activeDid FROM settings WHERE id = ? OR accountDid = ?",
|
||||
[1, this.activeDid || ""],
|
||||
);
|
||||
const dbTime = Date.now() - dbStartTime;
|
||||
|
||||
if (dbResult?.values) {
|
||||
this.addUrlTestResult(` - Database query completed in ${dbTime}ms`);
|
||||
this.addUrlTestResult(
|
||||
` - Raw DB values: ${JSON.stringify(dbResult.values)}`,
|
||||
);
|
||||
} else {
|
||||
this.addUrlTestResult(
|
||||
` - Database query failed or returned no results`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test 4: Environment variables
|
||||
this.addUrlTestResult(`\n🌍 Testing Environment Variables:`);
|
||||
this.addUrlTestResult(
|
||||
` - VITE_PLATFORM: ${import.meta.env.VITE_PLATFORM || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - VITE_DEFAULT_ENDORSER_API_SERVER: ${import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - VITE_DEFAULT_PARTNER_API_SERVER: ${import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER || "Not Set"}`,
|
||||
);
|
||||
|
||||
// Test 5: Constants
|
||||
this.addUrlTestResult(`\n📋 Testing App Constants:`);
|
||||
this.addUrlTestResult(
|
||||
` - PROD_ENDORSER_API_SERVER: ${AppString.PROD_ENDORSER_API_SERVER}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - PROD_PARTNER_API_SERVER: ${AppString.PROD_PARTNER_API_SERVER}`,
|
||||
);
|
||||
|
||||
// Test 6: Change detection
|
||||
this.addUrlTestResult(`\n🔄 Testing Change Detection:`);
|
||||
const originalApiServer = this.apiServer;
|
||||
const originalPartnerServer = this.partnerApiServer;
|
||||
|
||||
// Simulate a change
|
||||
this.addUrlTestResult(` - Original API Server: ${originalApiServer}`);
|
||||
this.addUrlTestResult(
|
||||
` - Original Partner Server: ${originalPartnerServer}`,
|
||||
);
|
||||
|
||||
// Test 7: Settings update
|
||||
this.addUrlTestResult(`\n💾 Testing Settings Update:`);
|
||||
const testChanges = {
|
||||
apiServer:
|
||||
originalApiServer === "https://api.endorser.ch"
|
||||
? "https://test-api.endorser.ch"
|
||||
: "https://api.endorser.ch",
|
||||
};
|
||||
|
||||
this.addUrlTestResult(
|
||||
` - Attempting to change API Server to: ${testChanges.apiServer}`,
|
||||
);
|
||||
const updateResult = await this.$saveSettings(testChanges);
|
||||
this.addUrlTestResult(
|
||||
` - Update result: ${updateResult ? "Success" : "Failed"}`,
|
||||
);
|
||||
|
||||
// Test 8: Verify change propagation
|
||||
this.addUrlTestResult(`\n✅ Testing Change Propagation:`);
|
||||
const newSettings = await this.$accountSettings();
|
||||
this.addUrlTestResult(
|
||||
` - New API Server from settings: ${newSettings.apiServer || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - Component state API Server: ${this.apiServer || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - Change propagated: ${newSettings.apiServer === this.apiServer ? "Yes" : "No"}`,
|
||||
);
|
||||
|
||||
// Test 9: Revert changes
|
||||
this.addUrlTestResult(`\n🔄 Reverting Changes:`);
|
||||
const revertResult = await this.$saveSettings({
|
||||
apiServer: originalApiServer,
|
||||
});
|
||||
this.addUrlTestResult(
|
||||
` - Revert result: ${revertResult ? "Success" : "Failed"}`,
|
||||
);
|
||||
|
||||
// Test 10: Final verification
|
||||
this.addUrlTestResult(`\n🎯 Final Verification:`);
|
||||
const finalSettings = await this.$accountSettings();
|
||||
this.addUrlTestResult(
|
||||
` - Final API Server: ${finalSettings.apiServer || "Not Set"}`,
|
||||
);
|
||||
this.addUrlTestResult(
|
||||
` - Matches original: ${finalSettings.apiServer === originalApiServer ? "Yes" : "No"}`,
|
||||
);
|
||||
|
||||
this.addUrlTestResult(`\n✅ URL flow test completed successfully!`);
|
||||
logger.info("[TestView] ✅ URL flow test completed successfully");
|
||||
} catch (error) {
|
||||
const errorMsg = `❌ URL flow test failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
this.addUrlTestResult(errorMsg);
|
||||
logger.error("[TestView] ❌ URL flow test failed:", error);
|
||||
} finally {
|
||||
this.isUrlTestRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a result to the URL test results array.
|
||||
*/
|
||||
private addUrlTestResult(message: string) {
|
||||
this.urlTestResults.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the API server to the production URL.
|
||||
*/
|
||||
public changeApiServer() {
|
||||
const currentServer = this.apiServer;
|
||||
const newServer =
|
||||
currentServer === "https://api.endorser.ch"
|
||||
? "https://test-api.endorser.ch"
|
||||
: "https://api.endorser.ch";
|
||||
|
||||
logger.info("[TestView] 🔄 Changing API server:", {
|
||||
from: currentServer,
|
||||
to: newServer,
|
||||
});
|
||||
|
||||
this.apiServer = newServer;
|
||||
this.addUrlTestResult(
|
||||
`API Server changed from ${currentServer} to ${newServer}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the partner API server to the production URL.
|
||||
*/
|
||||
public changePartnerApiServer() {
|
||||
const currentServer = this.partnerApiServer;
|
||||
const newServer =
|
||||
currentServer === "https://partner-api.endorser.ch"
|
||||
? "https://test-partner-api.endorser.ch"
|
||||
: "https://partner-api.endorser.ch";
|
||||
|
||||
logger.info("[TestView] 🔄 Changing partner API server:", {
|
||||
from: currentServer,
|
||||
to: newServer,
|
||||
});
|
||||
|
||||
this.partnerApiServer = newServer;
|
||||
this.addUrlTestResult(
|
||||
`Partner API Server changed from ${currentServer} to ${newServer}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all URL-related settings to their initial values.
|
||||
*/
|
||||
public resetToDefaults() {
|
||||
this.apiServer = AppString.TEST_ENDORSER_API_SERVER;
|
||||
this.partnerApiServer = AppString.TEST_PARTNER_API_SERVER;
|
||||
this.activeDid = "";
|
||||
this.addUrlTestResult("URL Flow Test Results Reset to Defaults.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes settings from the database to verify changes.
|
||||
*/
|
||||
public async refreshSettings() {
|
||||
try {
|
||||
logger.info("[TestView] 🔄 Refreshing settings from database");
|
||||
const settings = await this.$accountSettings();
|
||||
|
||||
// Update component state
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.partnerApiServer = settings.partnerApiServer || "";
|
||||
|
||||
logger.info("[TestView] ✅ Settings refreshed:", {
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
});
|
||||
|
||||
this.addUrlTestResult(
|
||||
`Settings refreshed - API Server: ${this.apiServer}, Partner API Server: ${this.partnerApiServer}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("[TestView] ❌ Error refreshing settings:", error);
|
||||
this.addUrlTestResult(
|
||||
`Error refreshing settings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current environment state to the console.
|
||||
*/
|
||||
public logEnvironmentState() {
|
||||
logger.info("[TestView] 🌐 Current Environment State:", {
|
||||
VITE_PLATFORM: import.meta.env.VITE_PLATFORM,
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER: import.meta.env
|
||||
.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
VITE_DEFAULT_PARTNER_API_SERVER: import.meta.env
|
||||
.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
activeDid: this.activeDid,
|
||||
apiServer: this.apiServer,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
});
|
||||
this.$notify({
|
||||
group: "info",
|
||||
type: "info",
|
||||
title: "Environment State Logged",
|
||||
text: "Current environment state logged to console.",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current platform based on the API server.
|
||||
*/
|
||||
public getCurrentPlatform(): string {
|
||||
if (this.apiServer?.includes(AppString.PROD_ENDORSER_API_SERVER)) {
|
||||
return "Production";
|
||||
} else if (this.apiServer?.includes(AppString.TEST_ENDORSER_API_SERVER)) {
|
||||
return "Test";
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user