forked from jsnbuchanan/crowd-funder-for-time-pwa
- Centralize all registration notice logic within upgraded RegistrationNotice component - Clean up unused methods and imports in HomeView and AccountViewView - Customizable message to maintain original contextual wordings unique to each view Component is now more focused and follows Vue.js best practices for conditional rendering.
1800 lines
58 KiB
Vue
1800 lines
58 KiB
Vue
<template>
|
|
<QuickNav selected="Profile" />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<main
|
|
id="Content"
|
|
class="p-6 pb-24 max-w-3xl mx-auto"
|
|
role="main"
|
|
aria-label="Account Profile"
|
|
>
|
|
<!-- Heading -->
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light">
|
|
Your Identity
|
|
</h1>
|
|
|
|
<!-- 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: 'start' }"
|
|
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 -->
|
|
<!-- Currently disabled because it doesn't work, even on Chrome.
|
|
If restored, make sure it works or doesn't show on mobile/electron. -->
|
|
<section
|
|
v-if="false"
|
|
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">
|
|
<div>
|
|
Reminder Notification
|
|
<button
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
aria-label="Learn more about 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 reminder notifications"
|
|
tabindex="0"
|
|
@click="showReminderNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input v-model="notifyingReminder" 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>
|
|
</div>
|
|
<div v-if="notifyingReminder" class="w-full flex justify-between">
|
|
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
|
|
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between">
|
|
<!-- label -->
|
|
<div>
|
|
New Activity Notification
|
|
<font-awesome
|
|
icon="question-circle"
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
@click.stop="showNewActivityNotificationInfo"
|
|
/>
|
|
</div>
|
|
<!-- toggle -->
|
|
<div
|
|
class="relative ml-2 cursor-pointer"
|
|
@click="showNewActivityNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input
|
|
v-model="notifyingNewActivity"
|
|
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>
|
|
</div>
|
|
<div v-if="notifyingNewActivityTime" class="w-full text-right">
|
|
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
|
</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" />
|
|
|
|
<LocationSearchSection :search-box="searchBox" />
|
|
|
|
<!-- 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>
|
|
|
|
<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>
|
|
|
|
<div id="sectionImportContactsSettings" class="mt-4">
|
|
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
|
|
|
|
<div class="ml-4 mt-2">
|
|
<input type="file" class="ml-2" @change="uploadImportFile" />
|
|
<transition
|
|
enter-active-class="transform ease-out duration-300 transition"
|
|
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
|
|
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
|
|
leave-active-class="transition ease-in duration-500"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div v-if="showContactImport()" class="mt-4">
|
|
<!-- Bulk import has an error
|
|
<div class="flex justify-center">
|
|
<button
|
|
class="block text-center 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-1.5 py-2 rounded-md mb-6"
|
|
@click="confirmSubmitImportFile()"
|
|
>
|
|
Overwrite Settings & Contacts
|
|
<br />
|
|
(which doesn't include Identifier Data)
|
|
</button>
|
|
</div>
|
|
-->
|
|
<div class="flex justify-center">
|
|
<button
|
|
class="block text-center 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-1.5 py-2 rounded-md mb-6"
|
|
@click="checkContactImports()"
|
|
>
|
|
Import Contacts
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
<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 * as R from "ramda";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { ref } from "vue";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
import { useClipboard } from "@vueuse/core";
|
|
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 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 { Contact } from "../db/tables/contacts";
|
|
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 {
|
|
AccountSettings,
|
|
isApiError,
|
|
ImportContent,
|
|
} from "@/interfaces/accountView";
|
|
import {
|
|
ProfileService,
|
|
createProfileService,
|
|
ProfileData,
|
|
} from "@/services/ProfileService";
|
|
|
|
const inputImportFileNameRef = ref<Blob>();
|
|
|
|
interface UserNameDialogRef {
|
|
open: (cb: (name?: string) => void) => void;
|
|
}
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
ImageMethodDialog,
|
|
LMap,
|
|
LMarker,
|
|
LTileLayer,
|
|
PushNotificationPermission,
|
|
QuickNav,
|
|
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;
|
|
|
|
// 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 = "";
|
|
notifyingReminder: boolean = false;
|
|
notifyingReminderMessage: string = "";
|
|
notifyingReminderTime: string = "";
|
|
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 profileService!: ProfileService;
|
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
created() {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
|
|
// Fix Leaflet icon issues in modern bundlers
|
|
// This prevents the "Cannot read properties of undefined (reading 'Default')" error
|
|
if (L.Icon.Default) {
|
|
// Type-safe way to handle Leaflet icon prototype
|
|
const iconDefault = L.Icon.Default.prototype as 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> {
|
|
this.profileService = createProfileService(
|
|
this.axios,
|
|
this.partnerApiServer,
|
|
);
|
|
try {
|
|
await this.initializeState();
|
|
await this.processIdentity();
|
|
|
|
if (this.isRegistered) {
|
|
try {
|
|
const profile = await this.profileService.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();
|
|
|
|
this.activeDid = settings.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;
|
|
this.searchBox = settings.searchBoxes?.[0] || null;
|
|
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
|
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
|
this.notifyingReminder = !!settings.notifyingReminderTime;
|
|
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
|
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
|
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.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;
|
|
}
|
|
|
|
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
|
doCopyTwoSecRedo(text: string, fn: () => void): void {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
}
|
|
|
|
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(): Promise<void> {
|
|
this.notify.confirm(
|
|
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;
|
|
}
|
|
});
|
|
} else {
|
|
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: "",
|
|
});
|
|
this.notifyingNewActivity = false;
|
|
this.notifyingNewActivityTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingReminderMessage: "",
|
|
notifyingReminderTime: "",
|
|
});
|
|
this.notifyingReminder = false;
|
|
this.notifyingReminderMessage = "";
|
|
this.notifyingReminderTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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 = "";
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
}
|
|
|
|
async uploadImportFile(event: Event): Promise<void> {
|
|
inputImportFileNameRef.value = (
|
|
event.target as HTMLInputElement
|
|
).files?.[0];
|
|
}
|
|
|
|
showContactImport(): boolean {
|
|
return !!inputImportFileNameRef.value;
|
|
}
|
|
|
|
confirmSubmitImportFile(): void {
|
|
if (inputImportFileNameRef.value != null) {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
|
|
this.submitImportFile,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously imports the database from a downloadable JSON file.
|
|
*
|
|
* @throws Will notify the user if there is an export error.
|
|
*/
|
|
async submitImportFile(): Promise<void> {
|
|
if (inputImportFileNameRef.value != null) {
|
|
// TODO: implement this for SQLite
|
|
}
|
|
}
|
|
|
|
async checkContactImports(): Promise<void> {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const fileContent: string = (event.target?.result as string) || "{}";
|
|
try {
|
|
const contents: ImportContent = JSON.parse(fileContent);
|
|
const contactTableRows: Array<Contact> = (
|
|
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
|
|
)?.find((table) => table.tableName === "contacts")
|
|
?.rows as Array<Contact>;
|
|
const contactRows = contactTableRows.map(
|
|
// @ts-expect-error for omitting this field that is found in the Dexie format
|
|
(contact) => R.omit(["$types"], contact) as Contact,
|
|
);
|
|
(this.$router as Router).push({
|
|
name: "contact-import",
|
|
query: { contacts: JSON.stringify(contactRows) },
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error checking contact imports:", error);
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
};
|
|
reader.readAsText(inputImportFileNameRef.value as Blob);
|
|
}
|
|
|
|
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;
|
|
try {
|
|
const did = this.activeDid;
|
|
|
|
if (!did) {
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
|
|
return;
|
|
}
|
|
|
|
await this.$saveUserSettings(did, {
|
|
apiServer: this.apiServer,
|
|
partnerApiServer: this.partnerApiServer,
|
|
webPushServer: this.webPushServer,
|
|
});
|
|
|
|
const imageResp = await fetchImageRateLimits(this.axios, did);
|
|
|
|
if (imageResp.status === 200) {
|
|
this.imageLimits = imageResp.data;
|
|
} else {
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IMAGE_ACCESS;
|
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.CANNOT_UPLOAD_IMAGES);
|
|
return;
|
|
}
|
|
|
|
const endorserResp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
did,
|
|
);
|
|
|
|
if (endorserResp.status === 200) {
|
|
this.endorserLimits = endorserResp.data;
|
|
} else {
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_LIMITS_FOUND;
|
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.BAD_SERVER_RESPONSE);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.limitsMessage =
|
|
ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS;
|
|
logger.error("Error retrieving limits: ", error);
|
|
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
|
|
} finally {
|
|
this.loadingLimits = false;
|
|
}
|
|
}
|
|
|
|
async onClickSaveApiServer(): Promise<void> {
|
|
await this.$saveSettings({
|
|
apiServer: this.apiServerInput,
|
|
});
|
|
this.apiServer = this.apiServerInput;
|
|
// Add this line to save to user-specific settings
|
|
await this.$saveUserSettings(this.activeDid, {
|
|
apiServer: this.apiServer,
|
|
});
|
|
}
|
|
|
|
async onClickSavePartnerServer(): Promise<void> {
|
|
await this.$saveSettings({
|
|
partnerApiServer: this.partnerApiServerInput,
|
|
});
|
|
this.partnerApiServer = this.partnerApiServerInput;
|
|
await this.$saveUserSettings(this.activeDid, {
|
|
partnerApiServer: this.partnerApiServer,
|
|
});
|
|
}
|
|
|
|
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 imageUrl in localStorage 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 {
|
|
logger.debug("Map ready event fired, map object:", map);
|
|
// 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;
|
|
logger.debug("Map ready set to true after mounted");
|
|
}, 500);
|
|
}
|
|
|
|
// Fallback method to handle map initialization failures
|
|
private handleMapInitFailure(): void {
|
|
logger.debug("Starting map initialization timeout (5 seconds)");
|
|
setTimeout(() => {
|
|
if (!this.isMapReady) {
|
|
logger.warn("Map failed to initialize, forcing ready state");
|
|
this.isMapReady = true;
|
|
} else {
|
|
logger.debug("Map initialized successfully, timeout not needed");
|
|
}
|
|
}, 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.profileService.saveProfile(
|
|
this.activeDid,
|
|
profileData,
|
|
);
|
|
if (success) {
|
|
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
|
|
} 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.profileService.toggleProfileLocation({
|
|
description: this.userProfileDesc,
|
|
latitude: this.userProfileLatitude,
|
|
longitude: this.userProfileLongitude,
|
|
includeLocation: this.includeUserProfileLocation,
|
|
});
|
|
this.userProfileLatitude = updated.latitude;
|
|
this.userProfileLongitude = updated.longitude;
|
|
this.includeUserProfileLocation = updated.includeLocation;
|
|
|
|
// 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 {
|
|
logger.debug("Attempting to delete profile for DID:", this.activeDid);
|
|
const success = await this.profileService.deleteProfile(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
|
|
logger.debug("Profile deleted successfully, UI state reset");
|
|
} 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;
|
|
logger.debug("Location unchecked, map state reset");
|
|
} 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) {
|
|
logger.debug("Setting map ready after timeout");
|
|
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();
|
|
}
|
|
}
|
|
</script>
|