2939 lines
96 KiB
Vue
2939 lines
96 KiB
Vue
<template>
|
||
<QuickNav selected="Profile" />
|
||
|
||
<!-- CONTENT -->
|
||
<main
|
||
id="Content"
|
||
class="p-6 pb-24 max-w-3xl mx-auto"
|
||
role="main"
|
||
aria-label="Account Profile"
|
||
>
|
||
<TopMessage />
|
||
|
||
<!-- Main View Heading -->
|
||
<div class="flex gap-4 items-center mb-8">
|
||
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
|
||
Your Identity
|
||
</h1>
|
||
|
||
<!-- Help button -->
|
||
<router-link
|
||
:to="{ name: 'help' }"
|
||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||
>
|
||
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||
</router-link>
|
||
</div>
|
||
|
||
<!-- ID notice -->
|
||
<div
|
||
v-if="!activeDid"
|
||
id="noticeBeforeShare"
|
||
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"
|
||
>
|
||
<p class="mb-4">
|
||
<b>Note:</b> Before you can share with others or take any action, you
|
||
need an identifier.
|
||
</p>
|
||
<router-link
|
||
:to="{ name: 'new-identifier' }"
|
||
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"
|
||
>
|
||
Create An Identifier
|
||
</router-link>
|
||
</div>
|
||
|
||
<!-- Identity Details -->
|
||
<IdentitySection
|
||
:given-name="givenName"
|
||
:profile-image-url="profileImageUrl"
|
||
:active-did="activeDid"
|
||
:is-registered="isRegistered"
|
||
:show-large-identicon-id="showLargeIdenticonId"
|
||
:show-large-identicon-url="showLargeIdenticonUrl"
|
||
:show-did-copy="showDidCopy"
|
||
@edit-name="onEditName"
|
||
@show-qr-code="onShowQrCode"
|
||
@add-image="onAddImage"
|
||
@delete-image="onDeleteImage"
|
||
@show-large-identicon-id="onShowLargeIdenticonId"
|
||
@show-large-identicon-url="onShowLargeIdenticonUrl"
|
||
@close-large-identicon="onCloseLargeIdenticon"
|
||
@copy-did="onCopyDid"
|
||
/>
|
||
|
||
<!-- Registration notice -->
|
||
<RegistrationNotice
|
||
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 -->
|
||
<!-- Enabled for native platforms (iOS/Android) -->
|
||
<section
|
||
v-if="isNativePlatform"
|
||
id="sectionNotifications"
|
||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||
aria-labelledby="notificationsHeading"
|
||
>
|
||
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div>
|
||
Daily Reminder
|
||
<button
|
||
class="text-slate-400 fa-fw cursor-pointer"
|
||
aria-label="Learn more about Daily Reminder notifications"
|
||
@click.stop="showReminderNotificationInfo"
|
||
>
|
||
<font-awesome
|
||
icon="question-circle"
|
||
aria-hidden="true"
|
||
></font-awesome>
|
||
</button>
|
||
</div>
|
||
<div
|
||
class="relative ml-2 cursor-pointer"
|
||
role="switch"
|
||
:aria-checked="notifyingReminder"
|
||
aria-label="Toggle Daily Reminder notifications"
|
||
tabindex="0"
|
||
@click.stop.prevent="showReminderNotificationChoice()"
|
||
>
|
||
<!-- input -->
|
||
<input
|
||
:checked="notifyingReminder"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
readonly
|
||
@click.stop.prevent
|
||
@change.stop.prevent
|
||
/>
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="notifyingReminder" class="w-full">
|
||
<div
|
||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||
>
|
||
<div>
|
||
<b>Time:</b> {{ notifyingReminderTime.replace(" ", " ") }}
|
||
</div>
|
||
<div>
|
||
<b>Message:</b> <i>"{{ notifyingReminderMessage }}"</i>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 text-center">
|
||
<button
|
||
class="w-full 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="editReminderNotification"
|
||
>
|
||
Edit Daily Reminder…
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
New Activity Notification: two delivery channels for the same alert.
|
||
Both let the user pick a time; they differ only in how the alert is
|
||
delivered (and therefore in the parameters passed to the notify-api):
|
||
- In-App: on-device notification via the daily-notification-plugin.
|
||
- SMS: text message sent by the notify-api (opt-in).
|
||
-->
|
||
<div class="mt-4 mb-1">
|
||
<div class="font-semibold">
|
||
New Activity Notification
|
||
<button
|
||
class="text-slate-400 fa-fw cursor-pointer"
|
||
aria-label="Learn more about New Activity notifications"
|
||
@click.stop="showNewActivityNotificationInfo(false)"
|
||
>
|
||
<font-awesome icon="question-circle" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-slate-500">
|
||
Get told about new relevant activity for you. Choose a delivery
|
||
channel below -- you can turn on either one, or both.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Channel 1: In-App notification -->
|
||
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||
<!-- label -->
|
||
<div>
|
||
<font-awesome
|
||
icon="comment"
|
||
class="fa-fw text-slate-400"
|
||
aria-hidden="true"
|
||
/>
|
||
In-App Notification
|
||
</div>
|
||
<!-- toggle -->
|
||
<div
|
||
class="relative ml-2 cursor-pointer"
|
||
role="switch"
|
||
:aria-checked="notifyingNewActivity"
|
||
aria-label="Toggle New Activity notifications in app"
|
||
tabindex="0"
|
||
@click.stop.prevent="showNewActivityNotificationChoice()"
|
||
>
|
||
<input
|
||
:checked="notifyingNewActivity"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
readonly
|
||
@click.stop.prevent
|
||
@change.stop.prevent
|
||
/>
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="notifyingNewActivity" class="w-full pl-1">
|
||
<div
|
||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||
>
|
||
<div>
|
||
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", " ") }}
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 text-center">
|
||
<button
|
||
class="w-full 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="editNewActivityNotification"
|
||
>
|
||
Edit In-App Time…
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
|
||
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||
<!-- label -->
|
||
<div>
|
||
<font-awesome
|
||
icon="message"
|
||
class="fa-fw text-slate-400"
|
||
aria-hidden="true"
|
||
/>
|
||
{{
|
||
notifyingNewActivitySmsPhone
|
||
? "SMS Text Message"
|
||
: "Enable Text Messages"
|
||
}}
|
||
</div>
|
||
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
|
||
<div
|
||
class="relative ml-2 cursor-pointer"
|
||
role="switch"
|
||
:aria-checked="smsEnabled"
|
||
aria-label="Toggle text message delivery"
|
||
tabindex="0"
|
||
@click.stop.prevent="toggleSmsEnabled()"
|
||
>
|
||
<input
|
||
:checked="smsEnabled"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
readonly
|
||
@click.stop.prevent
|
||
@change.stop.prevent
|
||
/>
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
|
||
<div
|
||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
|
||
>
|
||
<!-- Label and value must share one line: Vue's whitespace
|
||
condensing deletes a newline-only text node between elements,
|
||
which would glue "Texting:" to the number. -->
|
||
<div>
|
||
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
|
||
</div>
|
||
<button
|
||
class="text-red-600"
|
||
aria-label="Forget phone number and disable text messages"
|
||
@click="forgetSmsPhoneNumber()"
|
||
>
|
||
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
|
||
Forget Phone Number
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
|
||
<div v-if="smsEnabled" class="w-full pl-4">
|
||
<div class="flex items-center justify-between mt-1 mb-2">
|
||
<div>
|
||
New Activity Text
|
||
<button
|
||
class="text-slate-400 fa-fw cursor-pointer"
|
||
aria-label="Learn more about New Activity text messages"
|
||
@click.stop="showNewActivityNotificationInfo(true)"
|
||
>
|
||
<font-awesome icon="question-circle" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<!-- toggle -->
|
||
<div
|
||
class="relative ml-2 cursor-pointer"
|
||
role="switch"
|
||
:aria-checked="notifyingNewActivitySms"
|
||
aria-label="Toggle New Activity text messages"
|
||
tabindex="0"
|
||
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
|
||
>
|
||
<input
|
||
:checked="notifyingNewActivitySms"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
readonly
|
||
@click.stop.prevent
|
||
@change.stop.prevent
|
||
/>
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="notifyingNewActivitySms" class="w-full">
|
||
<div
|
||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||
>
|
||
<div>
|
||
<b>Time:</b>
|
||
{{ notifyingNewActivitySmsTime.replace(" ", " ") }}
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 text-center">
|
||
<button
|
||
class="w-full 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="editNewActivitySmsNotification"
|
||
>
|
||
Edit SMS Time…
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-2 text-center">
|
||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||
Troubleshoot your notifications…
|
||
</router-link>
|
||
</div>
|
||
</section>
|
||
<PushNotificationPermission ref="pushNotificationPermission" />
|
||
<SmsVerificationDialog ref="smsVerificationDialog" />
|
||
|
||
<!-- User Profile -->
|
||
<section
|
||
v-if="isRegistered"
|
||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||
aria-labelledby="userProfileHeading"
|
||
>
|
||
<h2 id="userProfileHeading" class="mb-2 font-bold">
|
||
Public Profile
|
||
<button
|
||
class="text-slate-400 fa-fw cursor-pointer"
|
||
aria-label="Learn more about public profile"
|
||
@click="showProfileInfo"
|
||
>
|
||
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
||
</button>
|
||
</h2>
|
||
<textarea
|
||
v-model="userProfileDesc"
|
||
class="w-full h-32 p-2 border border-slate-300 rounded-md"
|
||
placeholder="Write something about yourself for the public..."
|
||
:readonly="loadingProfile || savingProfile"
|
||
:class="{ 'bg-slate-100': loadingProfile || savingProfile }"
|
||
aria-label="Public profile description"
|
||
:aria-busy="loadingProfile || savingProfile"
|
||
></textarea>
|
||
|
||
<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>
|
||
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
|
||
<p class="text-sm mb-2 text-slate-500">
|
||
The location you choose will be shared with the world until you remove
|
||
this checkbox. For your security, choose a location nearby but not
|
||
exactly at your true location, like at your town center.
|
||
</p>
|
||
|
||
<l-map
|
||
ref="profileMap"
|
||
class="!z-40 rounded-md"
|
||
@click="onProfileMapClick"
|
||
@ready="onMapReady"
|
||
@mounted="onMapMounted"
|
||
>
|
||
<l-tile-layer
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
layer-type="base"
|
||
name="OpenStreetMap"
|
||
/>
|
||
<l-marker
|
||
v-if="userProfileLatitude && userProfileLongitude"
|
||
:lat-lng="[userProfileLatitude, userProfileLongitude]"
|
||
@click="confirmEraseLatLong()"
|
||
/>
|
||
</l-map>
|
||
</div>
|
||
<div v-if="!loadingProfile && !savingProfile">
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
||
<button
|
||
class="px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||
:disabled="loadingProfile || savingProfile"
|
||
:class="{
|
||
'opacity-50 cursor-not-allowed': loadingProfile || savingProfile,
|
||
}"
|
||
@click="saveProfile"
|
||
>
|
||
Save Profile
|
||
</button>
|
||
<button
|
||
class="px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||
:disabled="loadingProfile || savingProfile"
|
||
:class="{
|
||
'opacity-50 cursor-not-allowed':
|
||
loadingProfile ||
|
||
savingProfile ||
|
||
(!userProfileDesc && !includeUserProfileLocation),
|
||
}"
|
||
@click="confirmDeleteProfile"
|
||
>
|
||
Delete Profile
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="loadingProfile">Loading...</div>
|
||
<div v-else>Saving...</div>
|
||
</section>
|
||
|
||
<LocationSearchSection :search-box="searchBox" />
|
||
|
||
<UsageLimitsSection
|
||
v-if="activeDid"
|
||
:loading-limits="loadingLimits"
|
||
:limits-message="limitsMessage"
|
||
:active-did="activeDid"
|
||
:endorser-limits="endorserLimits"
|
||
:image-limits="imageLimits"
|
||
@recheck-limits="onRecheckLimits"
|
||
/>
|
||
|
||
<DataExportSection :active-did="activeDid" />
|
||
|
||
<!-- id used by puppeteer test script -->
|
||
<h3
|
||
data-testid="advancedSettings"
|
||
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
|
||
@click="toggleShowGeneralAdvanced"
|
||
>
|
||
{{
|
||
showGeneralAdvanced
|
||
? "Hide Advanced Settings"
|
||
: "Show Advanced Settings"
|
||
}}
|
||
</h3>
|
||
<section
|
||
v-if="showGeneralAdvanced"
|
||
id="sectionAdvanced"
|
||
aria-labelledby="advancedHeading"
|
||
>
|
||
<h2 id="advancedHeading" class="sr-only">Advanced Settings</h2>
|
||
<p class="text-rose-600 mb-8">
|
||
Beware: the features here can be confusing and even change data in ways
|
||
you do not expect. But we support your freedom!
|
||
</p>
|
||
|
||
<!-- Deep Identity Details -->
|
||
<span class="text-slate-500 text-sm font-bold mb-2">
|
||
Identifier Details
|
||
</span>
|
||
<div
|
||
id="sectionDeepIdentifier"
|
||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
|
||
>
|
||
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
|
||
<div
|
||
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
||
>
|
||
<code class="truncate">{{ publicBase64 }}</code>
|
||
<button
|
||
class="ml-2"
|
||
@click="
|
||
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
|
||
"
|
||
>
|
||
<font-awesome
|
||
icon="copy"
|
||
class="text-slate-400 fa-fw"
|
||
></font-awesome>
|
||
</button>
|
||
<span v-show="showB64Copy">Copied</span>
|
||
</div>
|
||
|
||
<div class="text-slate-500 text-sm font-bold">Public Key (hex)</div>
|
||
<div
|
||
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
||
>
|
||
<code class="truncate">{{ publicHex }}</code>
|
||
<button
|
||
class="ml-2"
|
||
@click="
|
||
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
|
||
"
|
||
>
|
||
<font-awesome
|
||
icon="copy"
|
||
class="text-slate-400 fa-fw"
|
||
></font-awesome>
|
||
</button>
|
||
<span v-show="showPubCopy">Copied</span>
|
||
</div>
|
||
|
||
<div class="text-slate-500 text-sm font-bold">Derivation Path</div>
|
||
<div
|
||
v-if="derivationPath"
|
||
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
||
>
|
||
<code class="truncate">{{ derivationPath }}</code>
|
||
<button
|
||
class="ml-2"
|
||
@click="
|
||
doCopyTwoSecRedo(
|
||
derivationPath,
|
||
() => (showDerCopy = !showDerCopy),
|
||
)
|
||
"
|
||
>
|
||
<font-awesome
|
||
icon="copy"
|
||
class="text-slate-400 fa-fw"
|
||
></font-awesome>
|
||
</button>
|
||
<span v-show="showDerCopy">Copied</span>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
||
>
|
||
(none)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- id used by puppeteer test script -->
|
||
<router-link
|
||
id="switch-identity-link"
|
||
:to="{ name: 'identity-switcher' }"
|
||
class="block w-fit 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-4 py-2 rounded-md mb-2"
|
||
>
|
||
Switch Identifier
|
||
</router-link>
|
||
|
||
<label
|
||
for="toggleShowAmounts"
|
||
class="flex items-center justify-between cursor-pointer my-4"
|
||
@click="toggleShowContactAmounts"
|
||
>
|
||
<!-- label -->
|
||
<span class="text-slate-500 text-sm font-bold">Contacts Display</span>
|
||
<span class="ml-2">Show hours given & received</span>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input
|
||
v-model="showContactGives"
|
||
type="checkbox"
|
||
name="showContactGives"
|
||
class="sr-only"
|
||
/>
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</label>
|
||
|
||
<div id="sectionClaimServer">
|
||
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
|
||
<div
|
||
class="px-4 py-4"
|
||
role="group"
|
||
aria-labelledby="claimServerHeading"
|
||
>
|
||
<h3 id="claimServerHeading" class="sr-only">
|
||
Claim Server Configuration
|
||
</h3>
|
||
<label for="apiServerInput" class="sr-only">API Server URL</label>
|
||
<input
|
||
id="apiServerInput"
|
||
v-model="apiServerInput"
|
||
type="text"
|
||
class="block w-full rounded border border-slate-400 px-4 py-2"
|
||
aria-describedby="apiServerDescription"
|
||
placeholder="Enter API server URL"
|
||
/>
|
||
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
||
Enter the URL for the claim server. You can use the buttons below to
|
||
quickly set common server URLs.
|
||
</div>
|
||
<button
|
||
v-if="apiServerInput != apiServer"
|
||
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
||
aria-label="Save API server URL"
|
||
@click="onClickSaveApiServer()"
|
||
>
|
||
<font-awesome
|
||
icon="floppy-disk"
|
||
class="fa-fw"
|
||
color="white"
|
||
aria-hidden="true"
|
||
></font-awesome>
|
||
</button>
|
||
<div class="mt-2" role="group" aria-label="Quick server selection">
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
aria-label="Use production server URL"
|
||
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
||
>
|
||
Use Prod
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
aria-label="Use test server URL"
|
||
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
||
>
|
||
Use Test
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
aria-label="Use local server URL"
|
||
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
||
>
|
||
Use Local
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<label
|
||
for="toggleProdWarningMessage"
|
||
class="flex items-center justify-between cursor-pointer px-4 py-4"
|
||
@click="toggleProdWarning"
|
||
>
|
||
<!-- label -->
|
||
<h2>Show warning if on prod server</h2>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input v-model="warnIfProdServer" type="checkbox" class="sr-only" />
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</label>
|
||
|
||
<label
|
||
for="toggleTestWarningMessage"
|
||
class="flex items-center justify-between cursor-pointer px-4 py-4"
|
||
@click="toggleTestWarning"
|
||
>
|
||
<!-- label -->
|
||
<h2>Show warning if on non-prod server</h2>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input v-model="warnIfTestServer" type="checkbox" class="sr-only" />
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
></div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Notification Push Server setting - only show on web platforms -->
|
||
<!-- This will be removed because we're no longer using PWA notifications. -->
|
||
<div v-if="false">
|
||
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
||
Notification Push Server
|
||
</h2>
|
||
<div class="px-3 py-4">
|
||
<input
|
||
v-model="webPushServerInput"
|
||
type="text"
|
||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||
/>
|
||
<button
|
||
v-if="webPushServerInput != webPushServer"
|
||
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
||
@click="onClickSavePushServer()"
|
||
>
|
||
<font-awesome
|
||
icon="floppy-disk"
|
||
class="fa-fw"
|
||
color="white"
|
||
></font-awesome>
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="webPushServerInput = AppConstants.PROD_PUSH_SERVER"
|
||
>
|
||
Use Prod
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="webPushServerInput = AppConstants.TEST1_PUSH_SERVER"
|
||
>
|
||
Use Test 1
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="webPushServerInput = AppConstants.TEST2_PUSH_SERVER"
|
||
>
|
||
Use Test 2
|
||
</button>
|
||
</div>
|
||
<span v-if="!webPushServerInput" class="px-4 text-sm">
|
||
When that setting is blank, this app will use the default web push
|
||
server URL:
|
||
{{ DEFAULT_PUSH_SERVER }}
|
||
</span>
|
||
</div>
|
||
|
||
<h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</h2>
|
||
<div class="px-3 py-4">
|
||
<input
|
||
v-model="partnerApiServerInput"
|
||
type="text"
|
||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||
/>
|
||
<button
|
||
v-if="partnerApiServerInput != partnerApiServer"
|
||
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
||
@click="onClickSavePartnerServer()"
|
||
>
|
||
<font-awesome
|
||
icon="floppy-disk"
|
||
class="fa-fw"
|
||
color="white"
|
||
></font-awesome>
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER"
|
||
>
|
||
Use Prod
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER"
|
||
>
|
||
Use Test
|
||
</button>
|
||
<button
|
||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||
@click="partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER"
|
||
>
|
||
Use Local
|
||
</button>
|
||
</div>
|
||
<span v-if="!partnerApiServerInput" class="px-4 text-sm">
|
||
When that setting is blank, this app will use the default partner server
|
||
URL:
|
||
{{ DEFAULT_PARTNER_API_SERVER }}
|
||
</span>
|
||
|
||
<div class="mt-2">
|
||
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
|
||
|
||
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
||
</div>
|
||
|
||
<label
|
||
for="toggleHideRegisterPromptOnNewContact"
|
||
class="flex items-center justify-between cursor-pointer mt-4"
|
||
@click="toggleHideRegisterPromptOnNewContact()"
|
||
>
|
||
<!-- label -->
|
||
<span class="text-slate-500 text-sm font-bold">
|
||
Hide Register Prompt on New Contact
|
||
</span>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input
|
||
v-model="hideRegisterPromptOnNewContact"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
/>
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
/>
|
||
</div>
|
||
</label>
|
||
|
||
<label
|
||
for="toggleShowShortcutBvc"
|
||
class="flex items-center justify-between cursor-pointer mt-4"
|
||
@click="toggleShowShortcutBvc"
|
||
>
|
||
<!-- label -->
|
||
<span class="text-slate-500 text-sm font-bold">
|
||
Show BVC Shortcut on Home Page
|
||
</span>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input v-model="showShortcutBvc" type="checkbox" class="sr-only" />
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
/>
|
||
</div>
|
||
</label>
|
||
|
||
<div id="sectionPasskeyExpiration" class="flex mt-4 justify-between">
|
||
<span>
|
||
<span class="text-slate-500 text-sm font-bold">
|
||
Passkey Expiration Minutes
|
||
</span>
|
||
<br />
|
||
<span class="text-sm ml-2">
|
||
{{ passkeyExpirationDescription }}
|
||
</span>
|
||
</span>
|
||
<div class="relative ml-2">
|
||
<input
|
||
v-model="passkeyExpirationMinutes"
|
||
type="number"
|
||
class="border border-slate-400 rounded px-2 py-2 text-center w-20"
|
||
@change="updatePasskeyExpiration"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<label
|
||
for="toggleShowGeneralAdvanced"
|
||
class="flex items-center justify-between cursor-pointer mt-4"
|
||
@click="toggleShowGeneralAdvanced"
|
||
>
|
||
<!-- label -->
|
||
<span class="text-slate-500 text-sm font-bold">
|
||
Show All General Advanced Functions
|
||
</span>
|
||
<!-- toggle -->
|
||
<div class="relative ml-2">
|
||
<!-- input -->
|
||
<input
|
||
v-model="showGeneralAdvanced"
|
||
type="checkbox"
|
||
class="sr-only"
|
||
/>
|
||
<!-- line -->
|
||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||
<!-- dot -->
|
||
<div
|
||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||
/>
|
||
</div>
|
||
</label>
|
||
|
||
<router-link
|
||
:to="{ name: 'logs' }"
|
||
class="block w-fit 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-4 py-2 rounded-md mt-2"
|
||
>
|
||
Logs
|
||
</router-link>
|
||
<!-- Non-production bundles only; route `dev-notifications` must exist
|
||
(see `includeDevToolkitRoutes`). -->
|
||
<router-link
|
||
v-if="isDev"
|
||
:to="{ name: 'dev-notifications' }"
|
||
class="block w-fit 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-4 py-2 rounded-md mt-2"
|
||
>
|
||
Notification Debug Panel
|
||
</router-link>
|
||
<router-link
|
||
:to="{ name: 'test' }"
|
||
class="block w-fit 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-4 py-2 rounded-md mt-2"
|
||
>
|
||
Test Page
|
||
</router-link>
|
||
|
||
<div class="flex mt-2">
|
||
<button>
|
||
<router-link
|
||
:to="{ name: 'statistics' }"
|
||
class="block w-fit 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-4 py-2 rounded-md"
|
||
>
|
||
See Global Animated History of Giving
|
||
</router-link>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<UserNameDialog ref="userNameDialog" />
|
||
<ImageMethodDialog ref="imageMethodDialog" />
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
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 { IIdentifier } from "@veramo/core";
|
||
import { Component, Vue } from "vue-facing-decorator";
|
||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||
import { copyToClipboard } from "../services/ClipboardService";
|
||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||
import { Capacitor } from "@capacitor/core";
|
||
|
||
import EntityIcon from "../components/EntityIcon.vue";
|
||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
||
import QuickNav from "../components/QuickNav.vue";
|
||
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
|
||
import TopMessage from "../components/TopMessage.vue";
|
||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||
import DataExportSection from "../components/DataExportSection.vue";
|
||
import IdentitySection from "@/components/IdentitySection.vue";
|
||
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
||
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
||
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
||
import {
|
||
AppString,
|
||
DEFAULT_IMAGE_API_SERVER,
|
||
DEFAULT_PARTNER_API_SERVER,
|
||
DEFAULT_PUSH_SERVER,
|
||
IMAGE_TYPE_PROFILE,
|
||
NotificationIface,
|
||
PASSKEYS_ENABLED,
|
||
} from "../constants/app";
|
||
import {
|
||
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
||
BoundingBox,
|
||
} from "../db/tables/settings";
|
||
import { EndorserRateLimits, ImageRateLimits } from "../interfaces";
|
||
|
||
import {
|
||
clearPasskeyToken,
|
||
fetchEndorserRateLimits,
|
||
fetchImageRateLimits,
|
||
getHeaders,
|
||
tokenExpiryTimeDescription,
|
||
} from "../libs/endorserServer";
|
||
import {
|
||
DAILY_CHECK_TITLE,
|
||
DIRECT_PUSH_TITLE,
|
||
retrieveAccountMetadata,
|
||
} from "../libs/util";
|
||
import { logger } from "../utils/logger";
|
||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||
import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
||
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||
import {
|
||
NotificationService,
|
||
configureNativeFetcherIfReady,
|
||
buildDualScheduleConfig,
|
||
syncStarredPlansToNativePlugin,
|
||
} from "@/services/notifications";
|
||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||
// Profile data interface (inlined from ProfileService)
|
||
interface ProfileData {
|
||
description: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
includeLocation: boolean;
|
||
}
|
||
|
||
interface UserNameDialogRef {
|
||
open: (cb: (name?: string) => void) => void;
|
||
}
|
||
|
||
interface PushNotificationPermissionRef {
|
||
open: (
|
||
title: string,
|
||
callback: (success: boolean, timeText: string, message?: string) => void,
|
||
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
|
||
) => void;
|
||
hourInput?: string;
|
||
minuteInput?: string;
|
||
hourAm?: boolean;
|
||
messageInput?: string;
|
||
}
|
||
|
||
@Component({
|
||
components: {
|
||
EntityIcon,
|
||
ImageMethodDialog,
|
||
LMap,
|
||
LMarker,
|
||
LTileLayer,
|
||
PushNotificationPermission,
|
||
QuickNav,
|
||
SmsVerificationDialog,
|
||
TopMessage,
|
||
UserNameDialog,
|
||
DataExportSection,
|
||
IdentitySection,
|
||
RegistrationNotice,
|
||
LocationSearchSection,
|
||
UsageLimitsSection,
|
||
},
|
||
mixins: [PlatformServiceMixin],
|
||
})
|
||
export default class AccountViewView extends Vue {
|
||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||
$route!: RouteLocationNormalizedLoaded;
|
||
$router!: Router;
|
||
|
||
// Constants
|
||
readonly AppConstants: typeof AppString = AppString;
|
||
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;
|
||
readonly isDev: boolean = includeDevToolkitRoutes;
|
||
|
||
// Identity and settings properties
|
||
activeDid: string = "";
|
||
apiServer: string = "";
|
||
apiServerInput: string = "";
|
||
derivationPath: string = "";
|
||
givenName: string = "";
|
||
hideRegisterPromptOnNewContact: boolean = false;
|
||
isRegistered: boolean = false;
|
||
isSearchAreasSet: boolean = false;
|
||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||
partnerApiServer: string = DEFAULT_PARTNER_API_SERVER;
|
||
partnerApiServerInput: string = DEFAULT_PARTNER_API_SERVER;
|
||
passkeyExpirationDescription: string = "";
|
||
passkeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
||
previousPasskeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
||
profileImageUrl?: string;
|
||
publicHex: string = "";
|
||
publicBase64: string = "";
|
||
webPushServer: string = DEFAULT_PUSH_SERVER;
|
||
webPushServerInput: string = DEFAULT_PUSH_SERVER;
|
||
|
||
// Profile properties
|
||
userProfileDesc: string = "";
|
||
userProfileLatitude: number = 0;
|
||
userProfileLongitude: number = 0;
|
||
includeUserProfileLocation: boolean = false;
|
||
savingProfile: boolean = false;
|
||
|
||
// Notification properties
|
||
notifyingNewActivity: boolean = false;
|
||
notifyingNewActivityTime: string = "";
|
||
// SMS channel (opt-in). MOCK-UP: state is component-local fake data so the
|
||
// flow can be walked through on an emulator; nothing is persisted. TODO:
|
||
// register/verify/forget the number and preferences with the notify-api.
|
||
/** Master switch: the SMS channel is on (requires a verified phone). */
|
||
smsEnabled: boolean = false;
|
||
/** Verified phone number; kept when smsEnabled turns off, cleared by Forget. */
|
||
notifyingNewActivitySmsPhone: string = "";
|
||
notifyingNewActivitySms: boolean = false;
|
||
notifyingNewActivitySmsTime: string = "";
|
||
notifyingReminder: boolean = false;
|
||
notifyingReminderMessage: string = "";
|
||
notifyingReminderTime: string = "";
|
||
/** Guard: only one edit-reminder schedule per user action (avoids double schedule on Android). */
|
||
editReminderScheduleInProgress: boolean = false;
|
||
/** Dev/test only: use 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes). */
|
||
reminderFastRolloverForTesting: boolean = false;
|
||
subscription: PushSubscription | null = null;
|
||
|
||
// UI state properties
|
||
downloadUrl: string = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
||
loadingLimits: boolean = false;
|
||
loadingProfile: boolean = true;
|
||
showAdvanced: boolean = false;
|
||
showB64Copy: boolean = false;
|
||
showContactGives: boolean = false;
|
||
showDidCopy: boolean = false;
|
||
showDerCopy: boolean = false;
|
||
showGeneralAdvanced: boolean = false;
|
||
showLargeIdenticonId?: string;
|
||
showLargeIdenticonUrl?: string;
|
||
showPubCopy: boolean = false;
|
||
showShortcutBvc: boolean = false;
|
||
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 notify!: ReturnType<typeof createNotifyHelpers>;
|
||
|
||
/**
|
||
* Check if running on native platform (iOS/Android)
|
||
*/
|
||
private get isNativePlatform(): boolean {
|
||
return Capacitor.isNativePlatform();
|
||
}
|
||
|
||
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",
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Async function executed when the component is mounted.
|
||
* Initializes the component's state with values from the database,
|
||
* handles identity-related tasks, and checks limitations.
|
||
*
|
||
* @throws Will display specific messages to the user based on different errors.
|
||
*/
|
||
async mounted(): Promise<void> {
|
||
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.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,
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(
|
||
"Telling user to clear cache at page create because:",
|
||
error,
|
||
);
|
||
logger.error(
|
||
"To repeat with concatenated error: telling user to clear cache at page create because: " +
|
||
error,
|
||
);
|
||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_LOAD_ERROR);
|
||
} finally {
|
||
this.loadingProfile = false;
|
||
}
|
||
|
||
// Check limits for any user with an activeDid (this will also check registration status)
|
||
if (this.activeDid) {
|
||
await this.checkLimits();
|
||
}
|
||
|
||
// Only check service worker on web platform - Capacitor/Electron don't support it
|
||
if (!Capacitor.isNativePlatform()) {
|
||
try {
|
||
/**
|
||
* Service workers only exist on web platforms
|
||
*/
|
||
const registration = await navigator.serviceWorker?.ready;
|
||
this.subscription = await registration.pushManager.getSubscription();
|
||
if (!this.subscription) {
|
||
if (this.notifyingNewActivity || this.notifyingReminder) {
|
||
// the app thought there was a subscription but there isn't, so fix the settings
|
||
this.turnOffNotifyingFlags();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
this.notify.warning(
|
||
ACCOUNT_VIEW_CONSTANTS.ERRORS.BROWSER_NOTIFICATIONS_UNSUPPORTED,
|
||
TIMEOUTS.VERY_LONG,
|
||
);
|
||
}
|
||
} else {
|
||
// On native platforms (Capacitor/Electron), skip service worker checks
|
||
// Native notifications are handled differently
|
||
this.subscription = null;
|
||
}
|
||
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
||
}
|
||
|
||
beforeUnmount(): void {
|
||
if (this.downloadUrl) {
|
||
URL.revokeObjectURL(this.downloadUrl);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initializes component state with values from the database or defaults.
|
||
*/
|
||
async initializeState(): Promise<void> {
|
||
// Then get the account-specific settings
|
||
const settings: AccountSettings = await this.$accountSettings();
|
||
|
||
// Get activeDid from active_identity table (single source of truth)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||
this.activeDid = activeIdentity.activeDid || "";
|
||
|
||
this.apiServer = settings.apiServer || "";
|
||
this.apiServerInput = settings.apiServer || "";
|
||
this.givenName =
|
||
(settings?.firstName || "") +
|
||
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
|
||
this.hideRegisterPromptOnNewContact =
|
||
!!settings.hideRegisterPromptOnNewContact;
|
||
this.isRegistered = !!settings?.isRegistered;
|
||
this.isSearchAreasSet =
|
||
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
||
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
||
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
||
this.notifyingReminder = !!settings.notifyingReminderTime;
|
||
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
||
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
||
this.reminderFastRolloverForTesting =
|
||
!!settings.reminderFastRolloverForTesting;
|
||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||
this.partnerApiServerInput =
|
||
settings.partnerApiServer || this.partnerApiServerInput;
|
||
this.profileImageUrl = settings.profileImageUrl;
|
||
this.showContactGives = !!settings.showContactGivesInline;
|
||
this.passkeyExpirationMinutes =
|
||
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
||
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
|
||
this.searchBox = settings.searchBoxes?.[0] || null;
|
||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||
this.showShortcutBvc = !!settings.showShortcutBvc;
|
||
this.warnIfProdServer = !!settings.warnIfProdServer;
|
||
this.warnIfTestServer = !!settings.warnIfTestServer;
|
||
this.webPushServer = settings.webPushServer || this.webPushServer;
|
||
this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
|
||
|
||
if (Capacitor.isNativePlatform() && this.activeDid) {
|
||
void configureNativeFetcherIfReady(this.activeDid);
|
||
if (this.notifyingNewActivity) {
|
||
const planIds = settings?.starredPlanHandleIds ?? [];
|
||
void syncStarredPlansToNativePlugin(planIds);
|
||
}
|
||
}
|
||
}
|
||
|
||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
|
||
fn();
|
||
try {
|
||
await copyToClipboard(text);
|
||
setTimeout(fn, 2000);
|
||
} catch (error) {
|
||
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
|
||
this.notify.error("Failed to copy to clipboard.");
|
||
}
|
||
}
|
||
|
||
async toggleShowContactAmounts(): Promise<void> {
|
||
this.showContactGives = !this.showContactGives;
|
||
await this.$saveSettings({
|
||
showContactGivesInline: this.showContactGives,
|
||
});
|
||
}
|
||
|
||
async toggleShowGeneralAdvanced(): Promise<void> {
|
||
this.showGeneralAdvanced = !this.showGeneralAdvanced;
|
||
await this.$saveSettings({
|
||
showGeneralAdvanced: this.showGeneralAdvanced,
|
||
});
|
||
}
|
||
|
||
async toggleProdWarning(): Promise<void> {
|
||
this.warnIfProdServer = !this.warnIfProdServer;
|
||
await this.$saveSettings({
|
||
warnIfProdServer: this.warnIfProdServer,
|
||
});
|
||
}
|
||
|
||
async toggleTestWarning(): Promise<void> {
|
||
this.warnIfTestServer = !this.warnIfTestServer;
|
||
await this.$saveSettings({
|
||
warnIfTestServer: this.warnIfTestServer,
|
||
});
|
||
}
|
||
|
||
async toggleShowShortcutBvc(): Promise<void> {
|
||
this.showShortcutBvc = !this.showShortcutBvc;
|
||
await this.$saveSettings({
|
||
showShortcutBvc: this.showShortcutBvc,
|
||
});
|
||
}
|
||
|
||
readableDate(timeStr: string): string {
|
||
return timeStr ? timeStr.substring(0, timeStr.indexOf("T")) : "?";
|
||
}
|
||
|
||
/**
|
||
* Processes the identity and updates the component's state.
|
||
*/
|
||
async processIdentity(): Promise<void> {
|
||
const account = await retrieveAccountMetadata(this.activeDid);
|
||
if (account?.identity) {
|
||
const identity = JSON.parse(account.identity as string) as IIdentifier;
|
||
this.publicHex = identity.keys[0].publicKeyHex;
|
||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||
this.derivationPath = identity.keys[0].meta?.derivationPath as string;
|
||
} else if (account?.publicKeyHex) {
|
||
// use the backup values in the top level of the account object
|
||
this.publicHex = account.publicKeyHex as string;
|
||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||
this.derivationPath = account.derivationPath as string;
|
||
}
|
||
}
|
||
|
||
async showNewActivityNotificationInfo(isSms: boolean): Promise<void> {
|
||
this.notify.confirm(
|
||
isSms
|
||
? ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_SMS_INFO
|
||
: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
||
async () => {
|
||
await (this.$router as Router).push({
|
||
name: "help-notification-types",
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
async showNewActivityNotificationChoice(): Promise<void> {
|
||
if (!this.notifyingNewActivity) {
|
||
(
|
||
this.$refs.pushNotificationPermission as PushNotificationPermission
|
||
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
||
if (success) {
|
||
await this.$saveSettings({
|
||
notifyingNewActivityTime: timeText,
|
||
});
|
||
this.notifyingNewActivity = true;
|
||
this.notifyingNewActivityTime = timeText;
|
||
if (Capacitor.isNativePlatform()) {
|
||
await this.scheduleNewActivityDualNotification(timeText);
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
||
if (success) {
|
||
if (
|
||
Capacitor.isNativePlatform() &&
|
||
DailyNotification?.cancelDualSchedule
|
||
) {
|
||
try {
|
||
await DailyNotification.cancelDualSchedule();
|
||
} catch (e) {
|
||
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
|
||
}
|
||
}
|
||
await this.$saveSettings({
|
||
notifyingNewActivityTime: "",
|
||
});
|
||
this.notifyingNewActivity = false;
|
||
this.notifyingNewActivityTime = "";
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
|
||
* Message" master switch).
|
||
*
|
||
* Enabling requires a verified phone number: the user enters a number, the
|
||
* service texts a code, and the toggle only turns on once the code is
|
||
* confirmed. Turning the toggle off pauses the channel but keeps the
|
||
* verified number, so re-enabling skips re-verification; "Forget Phone
|
||
* Number" is the destructive path.
|
||
*
|
||
* MOCK-UP: the verification round-trips are stubbed in
|
||
* SmsVerificationDialog and all state is component-local.
|
||
*/
|
||
async toggleSmsEnabled(): Promise<void> {
|
||
if (this.smsEnabled) {
|
||
// Pause the channel; the verified number stays for easy re-enable.
|
||
// TODO(notify-api): disable all SMS preferences server-side.
|
||
this.smsEnabled = false;
|
||
this.notifyingNewActivitySms = false;
|
||
this.notifyingNewActivitySmsTime = "";
|
||
return;
|
||
}
|
||
|
||
if (this.notifyingNewActivitySmsPhone) {
|
||
// Number already verified: just switch the channel back on.
|
||
this.smsEnabled = true;
|
||
return;
|
||
}
|
||
|
||
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
|
||
async (success: boolean, phoneNumber?: string) => {
|
||
if (success && phoneNumber) {
|
||
this.notifyingNewActivitySmsPhone = phoneNumber;
|
||
this.smsEnabled = true;
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Forget the verified phone number entirely: the master toggle turns off
|
||
* and every SMS notification becomes unavailable until a number is
|
||
* verified again.
|
||
*/
|
||
async forgetSmsPhoneNumber(): Promise<void> {
|
||
this.notify.confirm(
|
||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_FORGET_PHONE_CONFIRM,
|
||
async () => {
|
||
// TODO(notify-api): DELETE /api/sms/register to remove the number
|
||
// and all SMS preferences server-side.
|
||
this.notifyingNewActivitySmsPhone = "";
|
||
this.smsEnabled = false;
|
||
this.notifyingNewActivitySms = false;
|
||
this.notifyingNewActivitySmsTime = "";
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Toggle the "New Activity Text" notification (only reachable while the
|
||
* SMS channel is enabled).
|
||
*
|
||
* TODO (notify-api wiring): on enable, POST the SMS-notification preference
|
||
* with SMS-specific params (channel="sms", phoneNumber, sendTime) -- the
|
||
* same alert as the in-app channel, delivered by a different transport. On
|
||
* disable, tell the notify-api to remove it.
|
||
*/
|
||
async showNewActivitySmsNotificationChoice(): Promise<void> {
|
||
if (this.notifyingNewActivitySms) {
|
||
this.notifyingNewActivitySms = false;
|
||
this.notifyingNewActivitySmsTime = "";
|
||
return;
|
||
}
|
||
await this.promptSmsTimeAndEnable();
|
||
}
|
||
|
||
/**
|
||
* Prompt for the SMS send time (reusing the time-picker dialog) and enable
|
||
* the SMS channel on success.
|
||
*
|
||
* MOCK-UP: the chosen time is held in component state only.
|
||
*/
|
||
async promptSmsTimeAndEnable(): Promise<void> {
|
||
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
|
||
DAILY_CHECK_TITLE,
|
||
async (success: boolean, timeText: string) => {
|
||
if (success) {
|
||
// TODO: register SMS preference with notify-api (phone + time)
|
||
this.notifyingNewActivitySms = true;
|
||
this.notifyingNewActivitySmsTime = timeText;
|
||
}
|
||
},
|
||
{ skipSchedule: true },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Edit the time for the SMS delivery channel.
|
||
*
|
||
* MOCK-UP: updates component state only. TODO: PUT the updated sendTime to
|
||
* the notify-api SMS preference.
|
||
*/
|
||
async editNewActivitySmsNotification(): Promise<void> {
|
||
const dialog = this.$refs
|
||
.pushNotificationPermission as PushNotificationPermission;
|
||
|
||
dialog.open(
|
||
DAILY_CHECK_TITLE,
|
||
async (success: boolean, timeText: string) => {
|
||
if (!success) return;
|
||
// TODO: PUT updated sendTime to notify-api SMS preference
|
||
this.notifyingNewActivitySmsTime = timeText;
|
||
this.notify.success(
|
||
"New Activity SMS time updated.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
},
|
||
{ skipSchedule: true },
|
||
);
|
||
|
||
// Pre-populate the dialog with the current SMS time
|
||
setTimeout(() => {
|
||
const timeMatch = this.notifyingNewActivitySmsTime.match(
|
||
/(\d+):(\d+)\s*(AM|PM)/i,
|
||
);
|
||
if (timeMatch) {
|
||
let hour = parseInt(timeMatch[1], 10);
|
||
const minute = timeMatch[2];
|
||
const isAm = timeMatch[3].toUpperCase() === "AM";
|
||
if (hour === 12) {
|
||
hour = 12;
|
||
} else if (hour > 12) {
|
||
hour = hour - 12;
|
||
}
|
||
const dialogComponent =
|
||
dialog as unknown as PushNotificationPermissionRef;
|
||
if (dialogComponent) {
|
||
dialogComponent.hourInput = hour.toString();
|
||
dialogComponent.minuteInput = minute;
|
||
dialogComponent.hourAm = isAm;
|
||
}
|
||
}
|
||
}, 150);
|
||
}
|
||
|
||
/**
|
||
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
|
||
*/
|
||
async scheduleNewActivityDualNotification(notifyTime: string): Promise<void> {
|
||
const plugin = DailyNotification as unknown as {
|
||
scheduleDualNotification?: (opts: { config: unknown }) => Promise<void>;
|
||
};
|
||
if (!plugin.scheduleDualNotification) {
|
||
logger.warn(
|
||
"[AccountViewView] scheduleDualNotification not available on this device",
|
||
);
|
||
this.notify.error(
|
||
"New Activity scheduling is not available on this device. Please update the app.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
const time24h = this.parseTimeTo24Hour(notifyTime);
|
||
await configureNativeFetcherIfReady(this.activeDid);
|
||
const settings = await this.$accountSettings();
|
||
const planIds = settings?.starredPlanHandleIds ?? [];
|
||
await syncStarredPlansToNativePlugin(planIds);
|
||
const config = buildDualScheduleConfig({ notifyTime: time24h });
|
||
// Diagnostic: log what Capacitor sees at call time (helps debug UNIMPLEMENTED)
|
||
const cap = (typeof window !== "undefined" &&
|
||
(
|
||
window as unknown as {
|
||
Capacitor?: {
|
||
PluginHeaders?: Array<{
|
||
name: string;
|
||
methods?: Array<{ name: string }>;
|
||
}>;
|
||
};
|
||
}
|
||
).Capacitor) as
|
||
| {
|
||
PluginHeaders?: Array<{
|
||
name: string;
|
||
methods?: Array<{ name: string }>;
|
||
}>;
|
||
}
|
||
| undefined;
|
||
const dnHeader = cap?.PluginHeaders?.find(
|
||
(h) => h.name === "DailyNotification",
|
||
);
|
||
const methodsAtCall = dnHeader?.methods?.map((m) => m.name) ?? null;
|
||
logger.warn(
|
||
"[AccountViewView] Before scheduleDualNotification, PluginHeaders methods:",
|
||
methodsAtCall ?? "DailyNotification not in headers",
|
||
);
|
||
if (
|
||
methodsAtCall &&
|
||
!methodsAtCall.includes("scheduleDualNotification")
|
||
) {
|
||
logger.warn(
|
||
"[AccountViewView] scheduleDualNotification missing from PluginHeaders at call time – bridge may be stale for this run",
|
||
);
|
||
}
|
||
await plugin.scheduleDualNotification!({ config });
|
||
} catch (error) {
|
||
logger.error(
|
||
"[AccountViewView] scheduleNewActivityDualNotification failed:",
|
||
error,
|
||
);
|
||
const err = error as {
|
||
code?: string;
|
||
errorMessage?: string;
|
||
message?: string;
|
||
};
|
||
const code = err?.code;
|
||
const msg = err?.errorMessage ?? err?.message ?? "";
|
||
if (code === "UNIMPLEMENTED") {
|
||
this.notify.error(
|
||
"New Activity scheduling is not yet available on this device. Please update the app when support is added.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
} else if (
|
||
msg.includes("BGTaskSchedulerErrorDomain") ||
|
||
msg.includes("error 1")
|
||
) {
|
||
this.notify.error(
|
||
"New Activity scheduling needs a real device and Background App Refresh enabled. It does not work in Simulator.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
} else {
|
||
this.notify.error(
|
||
"Could not schedule New Activity notification. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async showReminderNotificationInfo(): Promise<void> {
|
||
this.notify.confirm(
|
||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
|
||
async () => {
|
||
await (this.$router as Router).push({
|
||
name: "help-notification-types",
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
async showReminderNotificationChoice(): Promise<void> {
|
||
if (!this.notifyingReminder) {
|
||
(
|
||
this.$refs.pushNotificationPermission as PushNotificationPermission
|
||
).open(
|
||
DIRECT_PUSH_TITLE,
|
||
async (success: boolean, timeText: string, message?: string) => {
|
||
if (success) {
|
||
await this.$saveSettings({
|
||
notifyingReminderMessage: message,
|
||
notifyingReminderTime: timeText,
|
||
});
|
||
this.notifyingReminder = true;
|
||
this.notifyingReminderMessage = message || "";
|
||
this.notifyingReminderTime = timeText;
|
||
}
|
||
},
|
||
{
|
||
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
|
||
? 10
|
||
: undefined,
|
||
},
|
||
);
|
||
} else {
|
||
// On native platforms, handle notification cancellation directly
|
||
// (bypassing web push unsubscribe flow which doesn't work on native)
|
||
if (this.isNativePlatform) {
|
||
// Show confirmation dialog
|
||
this.notify.confirm(
|
||
"Would you like to turn off Daily Reminder notifications?",
|
||
async () => {
|
||
try {
|
||
// Cancel the native notification
|
||
const service = NotificationService.getInstance();
|
||
await service.cancelDailyNotification();
|
||
|
||
// Update settings and state
|
||
await this.$saveSettings({
|
||
notifyingReminderMessage: "",
|
||
notifyingReminderTime: "",
|
||
});
|
||
this.notifyingReminder = false;
|
||
this.notifyingReminderMessage = "";
|
||
this.notifyingReminderTime = "";
|
||
|
||
this.notify.success(
|
||
"Daily Reminder notifications have been turned off.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
} catch (error) {
|
||
logger.error(
|
||
"[AccountViewView] Error canceling native notification:",
|
||
error,
|
||
);
|
||
this.notify.error(
|
||
"Failed to turn off notification. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
} else {
|
||
// Web platform: use existing web push unsubscribe flow
|
||
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
||
if (success) {
|
||
await this.$saveSettings({
|
||
notifyingReminderMessage: "",
|
||
notifyingReminderTime: "",
|
||
});
|
||
this.notifyingReminder = false;
|
||
this.notifyingReminderMessage = "";
|
||
this.notifyingReminderTime = "";
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Edit existing reminder notification
|
||
* Opens the notification dialog to allow editing time and message
|
||
*/
|
||
async editReminderNotification(): Promise<void> {
|
||
const dialog = this.$refs
|
||
.pushNotificationPermission as PushNotificationPermission;
|
||
|
||
// skipSchedule: true so only this callback schedules (dialog does not). Avoids double schedule on Android.
|
||
dialog.open(
|
||
DIRECT_PUSH_TITLE,
|
||
async (success: boolean, timeText: string, message?: string) => {
|
||
if (!success) return;
|
||
if (this.editReminderScheduleInProgress) return;
|
||
this.editReminderScheduleInProgress = true;
|
||
try {
|
||
const service = NotificationService.getInstance();
|
||
// On iOS: cancel then schedule. On Android: plugin cancels internally when scheduling with same id; skip pre-cancel to avoid double-cancel edge cases.
|
||
if (Capacitor.getPlatform() !== "android") {
|
||
await service.cancelDailyNotification();
|
||
}
|
||
|
||
// Schedule the updated notification (one call per user action)
|
||
const time24h = this.parseTimeTo24Hour(timeText);
|
||
const title = "Daily Reminder";
|
||
const body =
|
||
message ||
|
||
this.notifyingReminderMessage ||
|
||
"Click to share some gratitude with the world -- even if they're unnamed.";
|
||
|
||
const scheduleSuccess = await service.scheduleDailyNotification({
|
||
time: time24h,
|
||
title,
|
||
body,
|
||
priority: "normal",
|
||
...(this.reminderFastRolloverForTesting
|
||
? { rolloverIntervalMinutes: 10 }
|
||
: {}),
|
||
});
|
||
|
||
if (scheduleSuccess) {
|
||
await this.$saveSettings({
|
||
notifyingReminderMessage: message,
|
||
notifyingReminderTime: timeText,
|
||
});
|
||
this.notifyingReminderMessage = message || "";
|
||
this.notifyingReminderTime = timeText;
|
||
this.notify.success("Daily reminder updated.", TIMEOUTS.STANDARD);
|
||
} else {
|
||
this.notify.error(
|
||
"Failed to update reminder. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
logger.error("[AccountViewView] Edit reminder schedule failed:", err);
|
||
this.notify.error(
|
||
"Failed to update reminder. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
} finally {
|
||
this.editReminderScheduleInProgress = false;
|
||
}
|
||
},
|
||
{
|
||
skipSchedule: true,
|
||
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
|
||
? 10
|
||
: undefined,
|
||
},
|
||
);
|
||
|
||
// Pre-populate the form with current values after dialog opens
|
||
// Use setTimeout to ensure the dialog is fully rendered
|
||
setTimeout(() => {
|
||
// Parse the current time string (e.g., "5:22 PM")
|
||
const timeMatch = this.notifyingReminderTime.match(
|
||
/(\d+):(\d+)\s*(AM|PM)/i,
|
||
);
|
||
if (timeMatch) {
|
||
let hour = parseInt(timeMatch[1], 10);
|
||
const minute = timeMatch[2];
|
||
const isAm = timeMatch[3].toUpperCase() === "AM";
|
||
|
||
// Convert to 12-hour format for the input
|
||
if (hour === 12) {
|
||
hour = 12;
|
||
} else if (hour > 12) {
|
||
hour = hour - 12;
|
||
}
|
||
|
||
// Set the component's properties directly
|
||
// Note: We need to access the component's internal properties
|
||
// This is a workaround but necessary to pre-populate
|
||
const dialogComponent =
|
||
dialog as unknown as PushNotificationPermissionRef;
|
||
if (dialogComponent) {
|
||
dialogComponent.hourInput = hour.toString();
|
||
dialogComponent.minuteInput = minute;
|
||
dialogComponent.hourAm = isAm;
|
||
dialogComponent.messageInput =
|
||
this.notifyingReminderMessage ||
|
||
"Click to share some gratitude with the world -- even if they're unnamed.";
|
||
}
|
||
}
|
||
}, 150);
|
||
}
|
||
|
||
/**
|
||
* Edit existing New Activity notification time.
|
||
* Opens the dialog with current time; on success updates dual schedule via
|
||
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
|
||
*/
|
||
async editNewActivityNotification(): Promise<void> {
|
||
const dialog = this.$refs
|
||
.pushNotificationPermission as PushNotificationPermission;
|
||
|
||
dialog.open(
|
||
DAILY_CHECK_TITLE,
|
||
async (success: boolean, timeText: string) => {
|
||
if (!success) return;
|
||
if (Capacitor.isNativePlatform()) {
|
||
const time24h = this.parseTimeTo24Hour(timeText);
|
||
const config = buildDualScheduleConfig({ notifyTime: time24h });
|
||
const plugin = DailyNotification as unknown as {
|
||
updateDualScheduleConfig?: (opts: {
|
||
config: unknown;
|
||
}) => Promise<void>;
|
||
scheduleDualNotification?: (opts: {
|
||
config: unknown;
|
||
}) => Promise<void>;
|
||
};
|
||
try {
|
||
if (plugin.updateDualScheduleConfig) {
|
||
await plugin.updateDualScheduleConfig({ config });
|
||
} else {
|
||
await this.scheduleNewActivityDualNotification(timeText);
|
||
}
|
||
} catch (e) {
|
||
logger.warn(
|
||
"[AccountViewView] updateDualScheduleConfig failed, falling back to scheduleDualNotification:",
|
||
e,
|
||
);
|
||
try {
|
||
await this.scheduleNewActivityDualNotification(timeText);
|
||
} catch (fallbackError) {
|
||
logger.error(
|
||
"[AccountViewView] editNewActivityNotification schedule failed:",
|
||
fallbackError,
|
||
);
|
||
this.notify.error(
|
||
"Could not update New Activity time. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
await this.$saveSettings({ notifyingNewActivityTime: timeText });
|
||
this.notifyingNewActivityTime = timeText;
|
||
this.notify.success(
|
||
"New Activity notification time updated.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
},
|
||
{ skipSchedule: true },
|
||
);
|
||
|
||
// Pre-populate the dialog with current New Activity time
|
||
setTimeout(() => {
|
||
const timeMatch = this.notifyingNewActivityTime.match(
|
||
/(\d+):(\d+)\s*(AM|PM)/i,
|
||
);
|
||
if (timeMatch) {
|
||
let hour = parseInt(timeMatch[1], 10);
|
||
const minute = timeMatch[2];
|
||
const isAm = timeMatch[3].toUpperCase() === "AM";
|
||
if (hour === 12) {
|
||
hour = 12;
|
||
} else if (hour > 12) {
|
||
hour = hour - 12;
|
||
}
|
||
const dialogComponent =
|
||
dialog as unknown as PushNotificationPermissionRef;
|
||
if (dialogComponent) {
|
||
dialogComponent.hourInput = hour.toString();
|
||
dialogComponent.minuteInput = minute;
|
||
dialogComponent.hourAm = isAm;
|
||
}
|
||
}
|
||
}, 150);
|
||
}
|
||
|
||
/**
|
||
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
|
||
* if reminder is already on, reschedules so the plugin uses the new interval.
|
||
*/
|
||
async toggleReminderFastRollover(): Promise<void> {
|
||
const next = !this.reminderFastRolloverForTesting;
|
||
await this.$saveSettings({
|
||
reminderFastRolloverForTesting: next,
|
||
});
|
||
this.reminderFastRolloverForTesting = next;
|
||
|
||
if (this.notifyingReminder) {
|
||
try {
|
||
const service = NotificationService.getInstance();
|
||
if (Capacitor.getPlatform() !== "android") {
|
||
await service.cancelDailyNotification();
|
||
}
|
||
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
|
||
const title = "Daily Reminder";
|
||
const body =
|
||
this.notifyingReminderMessage ||
|
||
"Click to share some gratitude with the world -- even if they're unnamed.";
|
||
await service.scheduleDailyNotification({
|
||
time: time24h,
|
||
title,
|
||
body,
|
||
priority: "normal",
|
||
...(next ? { rolloverIntervalMinutes: 10 } : {}),
|
||
});
|
||
this.notify.success(
|
||
next
|
||
? "Reminder will repeat every 10 minutes (testing)."
|
||
: "Reminder will repeat daily (24h).",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
} catch (err) {
|
||
logger.error(
|
||
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
|
||
err,
|
||
);
|
||
this.notify.error(
|
||
"Failed to update reminder interval. Please try again.",
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
|
||
*/
|
||
private parseTimeTo24Hour(timeStr: string): string {
|
||
const timeMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
|
||
if (!timeMatch) {
|
||
return "09:00"; // Default fallback
|
||
}
|
||
|
||
let hour = parseInt(timeMatch[1], 10);
|
||
const minute = timeMatch[2];
|
||
const isAm = timeMatch[3].toUpperCase() === "AM";
|
||
|
||
// Convert to 24-hour format
|
||
if (isAm && hour === 12) {
|
||
hour = 0;
|
||
} else if (!isAm && hour !== 12) {
|
||
hour = hour + 12;
|
||
}
|
||
|
||
return `${hour.toString().padStart(2, "0")}:${minute}`;
|
||
}
|
||
|
||
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
||
const newSetting = !this.hideRegisterPromptOnNewContact;
|
||
await this.$saveSettings({
|
||
hideRegisterPromptOnNewContact: newSetting,
|
||
});
|
||
this.hideRegisterPromptOnNewContact = newSetting;
|
||
}
|
||
|
||
public async updatePasskeyExpiration(): Promise<void> {
|
||
await this.$saveSettings({
|
||
passkeyExpirationMinutes: this.passkeyExpirationMinutes,
|
||
});
|
||
clearPasskeyToken();
|
||
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
||
}
|
||
|
||
public async turnOffNotifyingFlags(): Promise<void> {
|
||
// should tell the push server as well
|
||
await this.$saveSettings({
|
||
notifyingNewActivityTime: "",
|
||
notifyingReminderMessage: "",
|
||
notifyingReminderTime: "",
|
||
});
|
||
this.notifyingNewActivity = false;
|
||
this.notifyingNewActivityTime = "";
|
||
this.notifyingReminder = false;
|
||
this.notifyingReminderMessage = "";
|
||
this.notifyingReminderTime = "";
|
||
// SMS channel (mock-up, component-local). TODO: also tell the notify-api.
|
||
// Leaves the verified phone number in place intentionally.
|
||
this.notifyingNewActivitySms = false;
|
||
this.notifyingNewActivitySmsTime = "";
|
||
}
|
||
|
||
/**
|
||
* Asynchronously exports the database into a downloadable JSON file.
|
||
*
|
||
* @throws Will notify the user if there is an export error.
|
||
*/
|
||
public async exportDatabase() {
|
||
try {
|
||
// Generate the blob from the database
|
||
const blob = await this.generateDatabaseBlob();
|
||
|
||
// Create a temporary URL for the blob
|
||
this.downloadUrl = this.createBlobURL(blob);
|
||
|
||
// Trigger the download
|
||
this.downloadDatabaseBackup(this.downloadUrl);
|
||
|
||
// Notify the user that the download has started
|
||
this.notifyDownloadStarted();
|
||
|
||
// Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
|
||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
||
} catch (error) {
|
||
this.handleExportError(error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generates a blob object representing the database.
|
||
*
|
||
* @returns {Promise<Blob>} The generated blob object.
|
||
*/
|
||
private async generateDatabaseBlob(): Promise<Blob> {
|
||
// TODO: Implement this for SQLite
|
||
throw new Error("Not implemented");
|
||
}
|
||
|
||
/**
|
||
* Creates a temporary URL for a blob object.
|
||
*
|
||
* @param {Blob} blob - The blob object.
|
||
* @returns {string} The temporary URL for the blob.
|
||
*/
|
||
private createBlobURL(blob: Blob): string {
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
/**
|
||
* Triggers the download of the database backup.
|
||
*
|
||
* @param {string} url - The temporary URL for the blob.
|
||
*/
|
||
private downloadDatabaseBackup(url: string) {
|
||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||
downloadAnchor.href = url;
|
||
downloadAnchor.download = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
|
||
downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo
|
||
}
|
||
|
||
public computedStartDownloadLinkClassNames() {
|
||
return {
|
||
hidden: this.downloadUrl,
|
||
};
|
||
}
|
||
|
||
public computedDownloadLinkClassNames() {
|
||
return {
|
||
hidden: !this.downloadUrl,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Notifies the user that the download has started.
|
||
*/
|
||
private notifyDownloadStarted() {
|
||
this.notify.downloadStarted();
|
||
}
|
||
|
||
/**
|
||
* Handles errors during the database export process.
|
||
*
|
||
* @param {Error} error - The error object.
|
||
*/
|
||
private handleExportError(error: unknown): void {
|
||
logger.error("Export Error:", error);
|
||
this.notify.error(
|
||
ACCOUNT_VIEW_CONSTANTS.ERRORS.EXPORT_ERROR,
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
|
||
private progressCallback(progress: ImportProgress): boolean {
|
||
logger.log(
|
||
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
||
);
|
||
if (progress.done) {
|
||
this.notify.success(
|
||
ACCOUNT_VIEW_CONSTANTS.SUCCESS.IMPORT_COMPLETE,
|
||
TIMEOUTS.LONG,
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async checkLimits(): Promise<void> {
|
||
this.loadingLimits = true;
|
||
const did = this.activeDid;
|
||
if (!did) {
|
||
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.$saveUserSettings(did, {
|
||
apiServer: this.apiServer,
|
||
partnerApiServer: this.partnerApiServer,
|
||
webPushServer: this.webPushServer,
|
||
});
|
||
|
||
const imageResp = await fetchImageRateLimits(
|
||
this.axios,
|
||
did,
|
||
this.DEFAULT_IMAGE_API_SERVER,
|
||
);
|
||
|
||
if (imageResp && imageResp.status === 200) {
|
||
this.imageLimits = imageResp.data;
|
||
} else {
|
||
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IMAGE_ACCESS;
|
||
}
|
||
|
||
const endorserResp = await fetchEndorserRateLimits(
|
||
this.apiServer,
|
||
this.axios,
|
||
did,
|
||
);
|
||
|
||
if (endorserResp.status === 200) {
|
||
this.endorserLimits = endorserResp.data;
|
||
}
|
||
} catch (error) {
|
||
this.limitsMessage =
|
||
ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS;
|
||
|
||
// Enhanced error logging with server context
|
||
const axiosError = error as {
|
||
response?: {
|
||
data?: { error?: { code?: string; message?: string } };
|
||
status?: number;
|
||
};
|
||
};
|
||
logger.warn(
|
||
"[Server Limits] Error retrieving limits, expected for unregistered users:",
|
||
{
|
||
error: error instanceof Error ? error.message : String(error),
|
||
did: did,
|
||
apiServer: this.apiServer,
|
||
imageServer: this.DEFAULT_IMAGE_API_SERVER,
|
||
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;
|
||
}
|
||
}
|
||
|
||
async onClickSaveApiServer(): Promise<void> {
|
||
// 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(),
|
||
});
|
||
|
||
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> {
|
||
// 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(),
|
||
});
|
||
|
||
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> {
|
||
await this.$saveSettings({
|
||
webPushServer: this.webPushServerInput,
|
||
});
|
||
this.webPushServer = this.webPushServerInput;
|
||
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.INFO.RELOAD_VAPID);
|
||
}
|
||
|
||
openImageDialog(): void {
|
||
(this.$refs.imageMethodDialog as ImageMethodDialog).open(
|
||
async (imgUrl) => {
|
||
await this.$saveSettings({
|
||
profileImageUrl: imgUrl,
|
||
});
|
||
this.profileImageUrl = imgUrl;
|
||
},
|
||
IMAGE_TYPE_PROFILE,
|
||
true,
|
||
);
|
||
}
|
||
|
||
confirmDeleteImage(): void {
|
||
this.notify.confirm(
|
||
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMAGE_DELETE_WARNING,
|
||
this.deleteImage,
|
||
);
|
||
}
|
||
|
||
async deleteImage(): Promise<void> {
|
||
try {
|
||
// Extract the image ID from the full URL
|
||
const imageId = this.profileImageUrl?.split("/").pop();
|
||
if (!imageId) {
|
||
this.notify.error("Invalid image URL");
|
||
return;
|
||
}
|
||
|
||
const response = await this.axios.delete(
|
||
this.apiServer + "/api/image/" + imageId,
|
||
{ headers: await getHeaders(this.activeDid) },
|
||
);
|
||
if (response.status === 204) {
|
||
this.profileImageUrl = "";
|
||
await this.$saveSettings({
|
||
profileImageUrl: "",
|
||
});
|
||
this.notify.success("Image deleted successfully.");
|
||
} else {
|
||
logger.error("Non-success deleting image:", response);
|
||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
|
||
// keep the profileImageUrl so the user can try again if they want
|
||
}
|
||
} catch (error) {
|
||
if (isApiError(error) && error.response?.status === 404) {
|
||
// it already doesn't exist so we won't say anything to the user
|
||
// Clear the local reference since the image is gone
|
||
this.profileImageUrl = "";
|
||
await this.$saveSettings({
|
||
profileImageUrl: "",
|
||
});
|
||
} else {
|
||
this.notify.error(
|
||
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_ERROR,
|
||
TIMEOUTS.STANDARD,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
onMapReady(map: L.Map): void {
|
||
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 {
|
||
this.notify.info(
|
||
ACCOUNT_VIEW_CONSTANTS.INFO.PROFILE_INFO,
|
||
TIMEOUTS.VERY_LONG,
|
||
);
|
||
}
|
||
|
||
async saveProfile(): Promise<void> {
|
||
this.savingProfile = true;
|
||
try {
|
||
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,
|
||
);
|
||
if (success) {
|
||
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
|
||
|
||
// Show seed phrase backup reminder if needed
|
||
try {
|
||
const settings = await this.$accountSettings();
|
||
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
|
||
} catch (error) {
|
||
logger.error("Error checking seed backup status:", error);
|
||
}
|
||
} else {
|
||
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;
|
||
}
|
||
}
|
||
|
||
toggleUserProfileLocation(): void {
|
||
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 {
|
||
this.notify.confirm(
|
||
ACCOUNT_VIEW_CONSTANTS.WARNINGS.ERASE_LOCATION_WARNING,
|
||
async () => {
|
||
this.eraseLatLong();
|
||
},
|
||
);
|
||
}
|
||
|
||
eraseLatLong(): void {
|
||
this.userProfileLatitude = 0;
|
||
this.userProfileLongitude = 0;
|
||
this.zoom = 2;
|
||
this.includeUserProfileLocation = false;
|
||
}
|
||
|
||
async confirmDeleteProfile(): Promise<void> {
|
||
this.notify.confirm(
|
||
ACCOUNT_VIEW_CONSTANTS.WARNINGS.DELETE_PROFILE_WARNING,
|
||
this.deleteProfile,
|
||
);
|
||
}
|
||
|
||
async deleteProfile(): Promise<void> {
|
||
try {
|
||
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) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
private handleQRCodeClick() {
|
||
if (Capacitor.isNativePlatform()) {
|
||
this.$router.push({ name: "contact-qr-scan-full" });
|
||
} else {
|
||
this.$router.push({ name: "contact-qr" });
|
||
}
|
||
}
|
||
|
||
onProfileMapClick(event: LeafletMouseEvent) {
|
||
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
|
||
onEditName() {
|
||
const dialog = this.$refs.userNameDialog as UserNameDialogRef | undefined;
|
||
if (dialog && typeof dialog.open === "function") {
|
||
dialog.open((name?: string) => {
|
||
if (name) this.givenName = name;
|
||
});
|
||
} else {
|
||
this.notify.error("Name dialog not available.");
|
||
logger.error(
|
||
"UserNameDialog ref is missing or open() is not a function",
|
||
dialog,
|
||
);
|
||
}
|
||
}
|
||
onShowQrCode() {
|
||
this.handleQRCodeClick();
|
||
}
|
||
onAddImage() {
|
||
this.openImageDialog();
|
||
}
|
||
onDeleteImage() {
|
||
this.confirmDeleteImage();
|
||
}
|
||
onShowLargeIdenticonId(id: string) {
|
||
this.showLargeIdenticonId = id;
|
||
}
|
||
onShowLargeIdenticonUrl(url: string) {
|
||
this.showLargeIdenticonUrl = url;
|
||
}
|
||
onCloseLargeIdenticon() {
|
||
this.showLargeIdenticonId = undefined;
|
||
this.showLargeIdenticonUrl = undefined;
|
||
}
|
||
onCopyDid(did: string) {
|
||
this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy));
|
||
}
|
||
|
||
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(),
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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>
|