Merge branch 'master' into claimview-fullfills-offer

This commit is contained in:
2025-09-08 04:36:48 -04:00
278 changed files with 25504 additions and 4850 deletions

View File

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

View File

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

View File

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

View File

@@ -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 || ""}`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
:weight="2"
color="#3b82f6"
fill-color="#3b82f6"
fill-opacity="0.2"
:fill-opacity="0.2"
/>
</l-map>
</div>

View File

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

View File

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

View File

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

View File

@@ -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,
);

View File

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

View File

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

View File

@@ -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()}.`;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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.",
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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,
};
}

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

View File

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

View File

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

View File

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

View File

@@ -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";
}
/**

View File

@@ -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) => {

View File

@@ -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"}`,
);
}
}
}

View File

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

View File

@@ -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 &amp; 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&hellip; 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 = "";

View File

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

View File

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

View File

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

View File

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

View File

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