Files
crowd-funder-for-time-pwa/src/views/AccountViewView.vue

2939 lines
96 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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(" ", "&nbsp;") }}
</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(" ", "&nbsp;") }}
</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(" ", "&nbsp;") }}
</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&hellip;
</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>
&nbsp;
<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>