Compare commits

...

48 Commits

Author SHA1 Message Date
25512d3db1 some fixes to the gifted-dialog logic 2025-06-29 18:18:18 -06:00
Jose Olarte III
bf9fee7ee9 Various aesthetic improvements and optimizations 2025-06-27 21:59:19 +08:00
Jose Olarte III
08c46a27d3 Add project-to-project case 2025-06-26 18:52:52 +08:00
Jose Olarte III
c9405839c3 Merge branch 'gifting-ui-2025-05' into gifting-periphery-improvements 2025-06-26 15:27:55 +08:00
0e6a9c4f89 adjust grammar for recording receipt 2025-06-25 20:51:57 -06:00
Jose Olarte III
b6278ca148 Unit codes pulled from util.ts 2025-06-25 21:28:25 +08:00
Jose Olarte III
d8e237f8cb Describe firstStep variable 2025-06-25 21:17:11 +08:00
Jose Olarte III
4b539ccc55 Better handling of No-name and Unnamed entities 2025-06-25 21:15:05 +08:00
Jose Olarte III
ea49173885 Changed currentStep to boolean 2025-06-25 17:38:33 +08:00
Jose Olarte III
447a7cb089 Style "unnamed" entity 2025-06-25 17:35:01 +08:00
Jose Olarte III
c0ddba8898 Various design tweaks 2025-06-24 19:22:09 +08:00
Jose Olarte III
fe4ae90849 Giver-recipient display fixes
- Truncate very long texts (such as dids)
- Stacked layout in mobile, row layout in wider screens
- Minor design adjustments
2025-06-24 19:19:51 +08:00
Jose Olarte III
ce04312baa Updated amount input controls
Now consistent with gifting dialog version
2025-06-24 19:17:30 +08:00
Jose Olarte III
a8cc480960 Merge branch 'master' into gifting-periphery-improvements 2025-06-24 16:20:07 +08:00
Jose Olarte III
357822d713 Fix: truncate text blocks
- Avoid did display stretching screen width
2025-06-24 16:18:22 +08:00
3baa6633a6 on mobile: bump version to 1.0.2 and build to 35 2025-06-20 20:27:16 -06:00
bda98eb632 reword the account-download button 2025-06-20 19:36:16 -06:00
eea1cb995a bump to version 1.0.3-beta 2025-06-20 19:27:07 -06:00
276e0a741b put version on front page so that people can tell whether to refresh 2025-06-20 19:03:50 -06:00
e46d6133fb bump to version 1.0.1 2025-06-20 15:56:47 -06:00
94994a7251 allow blocking another person's content from this user (with iViewContent contact field) 2025-06-20 15:53:31 -06:00
838723c26b remove debugging info messages (change to debug if we want these -- and tell us how to turn off debug locally) 2025-06-20 14:01:08 -06:00
bb6eb92ba1 fix ? instead of 0 in rate limits, update location verbiage 2025-06-20 13:34:14 -06:00
a997d4cb92 Merge branch 'migrate-dexie-to-sqlite' 2025-06-20 11:49:51 -06:00
73733345ff bump to version 1.0.0-beta 2025-06-20 11:46:09 -06:00
Jose Olarte III
ca22161f12 Fix: entity-type identifier validation
- Ensure claims contain only correct and necessary giver and recipient identifiers, as per Endorser.ch documentation
2025-06-20 20:37:14 +08:00
Jose Olarte III
d3b80fbe47 Feature: giver-recipient validation
- Ensures person-to-person gifting won't allow the same entity as giver and recipient
- Disable user item selection if it would create conflict
- Error messaging fallback
2025-06-20 18:38:35 +08:00
Jose Olarte III
0342c872f4 Fix: added context for ContactGiftingView 2025-06-20 15:50:57 +08:00
Jose Olarte III
a7e65b3b49 Giver-recipient controls
- Dialog now shows separate cards for giver and recipient
- Ability to change giver and/or recipient
- Project giver/recipient is locked in ProjectView (context reinforcement)
2025-06-19 21:16:56 +08:00
3118f71320 fix linting (whitespace only) 2025-06-18 21:44:11 -06:00
d12f23aa81 Merge pull request 'Make all external URLs go to the /deep-link/ endpoint to redirect to mobile vs web' (#139) from deep-link-redirect into master
Reviewed-on: #139
2025-06-18 23:33:12 -04:00
e9a8a3c1e7 add support for deep-link query parameters 2025-06-18 19:31:16 -06:00
1e0efe6011 lengthen the error timeout when the message may be complicated, eg. with details from the server 2025-06-18 18:32:55 -06:00
16557f1e4b update build instruction & package-lock.json 2025-06-18 17:32:41 -06:00
c4a54967bc fix linting 2025-06-18 16:33:55 -06:00
20ade415dc bump to version 0.5.8 build 34 2025-06-18 16:31:31 -06:00
6689520270 fix all copies for externally-shared links to redirected deep links 2025-06-18 15:53:16 -06:00
3fd6c2b80d add first cut at deep-link redirecting, with one example contact-import that works on mobile 2025-06-18 13:16:17 -06:00
Jose Olarte III
eb7605991c Fixed more gifting use cases 2025-06-18 19:58:10 +08:00
fa21660fd1 fix spelling 2025-06-15 12:43:22 -06:00
Jose Olarte III
df1c1f0186 Fix: pass project info
In GiftingDialog, project information is passed along if:
- Selecting "Show All" to go to ContactGiftingView
- Selecting "Photos and Other Options" to go to GiftedDetailsView
2025-06-13 20:52:26 +08:00
Jose Olarte III
3daf1c8a5c Feature: Project Gifting
- Gifting dialog: added ability to pick a project to benefit from
- Project view: modified dialog calls in Project view to toggle between giving to and benefiting from a project
- Project view: removed redundant person selection
- Project view: benefiting from a project locks the project selection in dialog to enforce context.
2025-06-12 20:50:27 +08:00
Jose Olarte III
7eefee1ea5 Fix: Conditional show-all link
- Only show "Show All" when user has contacts
2025-06-12 14:34:00 +08:00
Jose Olarte III
140c36a416 Merge branch 'master' into gifting-ui-2025-05 2025-06-11 19:10:59 +08:00
Jose Olarte III
988244b7ae Added check for "Unnamed" giver
Pass string "Unnamed" to select unnamed giver and skip contact selection step of dialog.
2025-05-16 20:22:09 +08:00
Jose Olarte III
4b355a5448 WIP: two-step dialog + functionality
- Dialog is now presented as two distinct steps
- Gifting functionality reinstated
- Minor UI tweaks
- IN PROGRESS: ensuring calls to dialog from other parts of the app remain functional
2025-05-14 21:47:12 +08:00
Jose Olarte III
b511f9cd24 WIP: adjustments to bring closer to original mockups 2025-05-13 21:16:39 +08:00
Jose Olarte III
579cecbe6e WIP: gifting UI revamp
Started to transform the gifting dialog into the two-step setup as per previous mockups
2025-05-12 21:22:05 +08:00
43 changed files with 1965 additions and 663 deletions

View File

@@ -41,6 +41,7 @@ Install dependencies:
1. Run the production build: 1. Run the production build:
```bash ```bash
rm -rf dist
npm run build:web npm run build:web
``` ```
@@ -62,11 +63,13 @@ Install dependencies:
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. * Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build`
* Commit everything (since the commit hash is used the app). * Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version in the step later). * Put the commit hash in the changelog (which will help you remember to bump the version in the step later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.0 && git push origin 1.0.0`. * Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.2 && git push origin 1.0.2`.
* For test, build the app (because test server is not yet set up to build): * For test, build the app (because test server is not yet set up to build):
@@ -90,13 +93,13 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* `pkgx +npm sh` * `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.5.9 && npm install && npm run build:web && cd -` * `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web && cd -`
(The plain `npm run build:web` uses the .env.production file.) (The plain `npm run build:web` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/` * Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production. * Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, commit, and push. Also record what version is on production.
## Docker Deployment ## Docker Deployment
@@ -359,12 +362,9 @@ Prerequisites: macOS with Xcode installed
4. Bump the version to match Android & package.json: 4. Bump the version to match Android & package.json:
``` ```
cd ios/App cd ios/App && xcrun agvtool new-version 35 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.2;/g" App.xcodeproj/project.pbxproj && cd -
xcrun agvtool new-version 33
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.7;/g" > temp && mv temp App.xcodeproj/project.pbxproj
cd -
``` ```
5. Open the project in Xcode: 5. Open the project in Xcode:

View File

@@ -6,12 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d
### Added
- Version on feed title
## [1.0.0] - 2025.06.20 - 9b69c0b22c7e3ac0584219f5ac434a02bda2e01b
## [1.0.1] - 2025.06.20
### Added
- Allow a user to block someone else's content from view
## [1.0.0] - 2025.06.20 - 5aa693de6337e5dbb278bfddc6bd39094bc14f73
### Added ### Added
- Web-oriented migration from IndexedDB to SQLite - Web-oriented migration from IndexedDB to SQLite
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7] ## [0.4.7]
### Fixed ### Fixed
- Cameras everywhere - Cameras everywhere

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 33 versionCode 35
versionName "0.5.7" versionName "1.0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -100,6 +100,7 @@ try {
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas - `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service - `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration - `src/main.capacitor.ts`: Capacitor integration
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
## Type Safety Examples ## Type Safety Examples

View File

@@ -403,7 +403,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.5.7; MARKETING_VERSION = 1.0.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.5.7; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.0.0", "version": "1.0.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.0.0", "version": "1.0.3-beta",
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0", "@capacitor-mlkit/barcode-scanning": "^6.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.0.0", "version": "1.0.3-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

View File

@@ -1,99 +1,534 @@
<template> <template>
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4"> <!-- Step 1: Giver -->
{{ customTitle }} <div v-show="firstStep" id="sectionGiftedGiver">
</h1> <label class="block font-bold mb-4">
<input {{
v-model="description" stepType === "recipient"
type="text" ? "Choose who received the gift:"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" : showProjects
:placeholder="prompt || 'What was given?'" ? "Choose a project benefitted from:"
/> : "Choose a person received from:"
<div class="flex flex-row justify-center"> }}
<span </label>
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()" <!-- Unified Quick-pick grid for People and Projects -->
<ul
:class="
shouldShowProjects
? 'grid grid-cols-3 md:grid-cols-4 gap-x-2 gap-y-4 text-center mb-4'
: 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-2 gap-y-4 text-center mb-4'
"
> >
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }} <template v-if="shouldShowProjects">
</span> <!-- show projects -->
<div <li
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" v-for="project in projects.slice(0, 7)"
@click="amountInput === '0' ? null : decrement()" :key="project.handleId"
> class="cursor-pointer"
<font-awesome icon="chevron-left" /> @click="
</div> stepType === 'recipient'
<input ? selectRecipientProject(project)
id="inputGivenAmount" : selectProject(project)
v-model="amountInput" "
type="number" >
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" <div class="relative w-fit mx-auto mb-1">
/> <ProjectIcon
<div :entity-id="project.handleId"
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2" :icon-size="48"
@click="increment()" :image-url="project.image"
> class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full"
<font-awesome icon="chevron-right" /> />
</div> </div>
</div> <h3
<div class="mt-4 flex justify-center"> class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
<span> >
<router-link {{ project.name }}
:to="{ </h3>
name: 'gifted-details', <div class="text-xs text-slate-500 truncate">
query: { <font-awesome icon="user" class="fa-fw text-slate-400" />
amountInput, {{
description, didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
giverDid: giver?.did, }}
giverName: giver?.name, </div>
offerId, </li>
fulfillsProjectId: toProjectId, <li
providerProjectId: fromProjectId, v-if="projects.length === 0"
recipientDid: receiver?.did, class="text-xs text-slate-500 italic col-span-full"
recipientName: receiver?.name, >
unitCode, (No projects found.)
}, </li>
}" <li v-if="projects.length > 0">
class="text-blue-500" <router-link :to="{ name: 'discover' }" class="cursor-pointer">
> <font-awesome
Photo & more options ... icon="circle-right"
</router-link> class="text-blue-500 text-5xl mb-1"
</span> />
</div> <h3
<p class="text-center mb-2 mt-6 italic"> class="text-xs text-slate-400 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
Sign & Send to publish to the world >
<font-awesome Show All
icon="circle-info" </h3>
class="pl-2 text-blue-500 cursor-pointer" </router-link>
@click="explainData()" </li>
/> </template>
</p> <template v-else>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <!-- show people (contacts) -->
<li
v-if="
stepType === 'recipient' ||
(stepType === 'giver' && isFromProjectView)
"
:class="{
'cursor-pointer': !wouldCreateConflict(activeDid),
'cursor-not-allowed opacity-50': wouldCreateConflict(activeDid)
}"
@click="
!wouldCreateConflict(activeDid) &&
(stepType === 'recipient'
? selectRecipient({ did: activeDid, name: 'You' })
: selectGiver({ did: activeDid, name: 'You' }))
"
>
<font-awesome
:class="{
'text-blue-500 text-5xl mb-1': !wouldCreateConflict(activeDid),
'text-slate-400 text-5xl mb-1': wouldCreateConflict(activeDid)
}"
icon="hand"
/>
<h3
:class="{
'text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden': !wouldCreateConflict(activeDid),
'text-xs text-slate-400 font-medium text-ellipsis whitespace-nowrap overflow-hidden': wouldCreateConflict(activeDid)
}"
>
You
</h3>
</li>
<li
class="cursor-pointer"
@click="
stepType === 'recipient' ? selectRecipient() : selectGiver()
"
>
<font-awesome
icon="circle-question"
class="text-slate-400 text-5xl mb-1"
/>
<h3
class="text-xs text-slate-400 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
(Unnamed)
</h3>
</li>
<li
v-if="allContacts.length === 0"
class="text-xs text-slate-500 italic col-span-full"
>
(Add friends to see more people worthy of recognition.)
</li>
<li
v-for="contact in allContacts.slice(0, 10)"
:key="contact.did"
:class="{
'cursor-pointer': !wouldCreateConflict(contact.did),
'cursor-not-allowed opacity-50': wouldCreateConflict(contact.did)
}"
@click="
!wouldCreateConflict(contact.did) &&
(stepType === 'recipient'
? selectRecipient(contact)
: selectGiver(contact))
"
>
<div class="relative w-fit mx-auto mb-1">
<EntityIcon
:contact="contact"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<div
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome
icon="clock"
class="block text-white text-xs w-[1em]"
/>
</div>
</div>
<h3
:class="{
'text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden': !wouldCreateConflict(contact.did) && contact.name,
'text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden text-slate-400 italic': !contact.name,
'text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden text-slate-400': wouldCreateConflict(contact.did) && contact.name
}"
>
{{ contact.name || "(No name)" }}
</h3>
</li>
<li v-if="allContacts.length > 0" class="cursor-pointer">
<router-link
:to="{
name: 'contact-gift',
query: {
stepType: stepType,
giverEntityType: giverEntityType,
recipientEntityType: recipientEntityType,
...(stepType === 'giver'
? {
recipientProjectId: toProjectId,
recipientProjectName: receiver?.name,
recipientProjectImage: receiver?.image,
recipientProjectHandleId: receiver?.handleId,
recipientDid: receiver?.did,
}
: {
giverProjectId: fromProjectId,
giverProjectName: giver?.name,
giverProjectImage: giver?.image,
giverProjectHandleId: giver?.handleId,
giverDid: giver?.did,
}),
fromProjectId: fromProjectId,
toProjectId: toProjectId,
showProjects: (showProjects || false).toString(),
isFromProjectView: (isFromProjectView || false).toString(),
},
}"
>
<font-awesome
icon="circle-right"
class="text-blue-500 text-5xl mb-1"
/>
<h3
class="text-xs text-slate-400 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
</ul>
<button <button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel" @click="cancel"
> >
Cancel Cancel
</button> </button>
</div> </div>
<!-- Step 2: Gift -->
<div v-show="!firstStep" id="sectionGiftedGift">
<div class="grid grid-cols-2 gap-2 mb-4">
<!-- Giver Button -->
<button
v-if="
(giverEntityType === 'person' || giverEntityType === 'project') &&
!(isFromProjectView && giverEntityType === 'project')
"
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
@click="goBackToStep1('giver')"
>
<div>
<template v-if="giverEntityType === 'project'">
<ProjectIcon
v-if="giver?.handleId"
:entity-id="giver.handleId"
:icon-size="32"
:image-url="giver.image"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
</template>
<template v-else>
<EntityIcon
v-if="giver?.did"
:contact="giver"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-3xl"
/>
</template>
</div>
<div class="text-start min-w-0">
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
{{
giverEntityType === "project"
? "Benefited from:"
: "Received from:"
}}
</p>
<h3
v-if="giver?.name && giver.name !== giver.did"
class="font-semibold truncate"
>
{{ giver.name }}
</h3>
<h3
v-if="giver?.name && giver.name === giver.did"
class="font-semibold truncate text-slate-400 italic"
>
(No name)
</h3>
<h3
v-else-if="!giver?.name"
class="font-semibold truncate text-slate-400 italic"
>
(Unnamed)
</h3>
</div>
<p class="ms-auto text-sm text-blue-500 pe-1">
<font-awesome icon="pen" title="Change" />
</p>
</button>
<div
v-else
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
>
<div>
<template v-if="giverEntityType === 'project'">
<ProjectIcon
v-if="giver?.handleId"
:entity-id="giver.handleId"
:icon-size="32"
:image-url="giver.image"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
</template>
<template v-else>
<EntityIcon
v-if="giver?.did"
:contact="giver"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-3xl"
/>
</template>
</div>
<div class="text-start min-w-0">
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
{{
giverEntityType === "project"
? "Benefited from:"
: "Received from:"
}}
</p>
<h3
v-if="giver?.name && giver.name !== giver.did"
class="font-semibold truncate"
>
{{ giver.name }}
</h3>
<h3
v-if="giver?.name && giver.name === giver.did"
class="font-semibold truncate text-slate-400 italic"
>
(No name)
</h3>
<h3
v-else-if="!giver?.name"
class="font-semibold truncate text-slate-400 italic"
>
(Unnamed)
</h3>
</div>
<p class="ms-auto text-sm text-slate-400 pe-1">
<font-awesome icon="lock" title="Can't be changed" />
</p>
</div>
<!-- Recipient Button -->
<button
v-if="recipientEntityType === 'person'"
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
@click="goBackToStep1('recipient')"
>
<div>
<EntityIcon
v-if="receiver?.did"
:contact="receiver"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-3xl"
/>
</div>
<div class="text-start min-w-0">
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
Given to:
</p>
<h3
v-if="receiver?.name && receiver.name !== receiver.did"
class="font-semibold truncate"
>
{{ receiver.name }}
</h3>
<h3
v-if="receiver?.name && receiver.name === receiver.did"
class="font-semibold truncate text-slate-400 italic"
>
(No name)
</h3>
<h3
v-else-if="!receiver?.name"
class="font-semibold truncate text-slate-400 italic"
>
(Unnamed)
</h3>
</div>
<p class="ms-auto text-sm text-blue-500 pe-1">
<font-awesome icon="pen" title="Change" />
</p>
</button>
<div
v-else-if="recipientEntityType === 'project'"
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
>
<div>
<ProjectIcon
v-if="receiver?.handleId"
:entity-id="receiver.handleId"
:icon-size="32"
:image-url="receiver.image"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
</div>
<div class="text-start min-w-0">
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
Given to project:
</p>
<h3
v-if="receiver?.name"
class="font-semibold truncate"
>
{{ receiver.name }}
</h3>
<h3
v-else
class="font-semibold truncate text-slate-400 italic"
>
(Unnamed)
</h3>
</div>
<p class="ms-auto text-sm text-slate-400 pe-1">
<font-awesome icon="lock" title="Can't be changed" />
</p>
</div>
</div>
<input
v-model="description"
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2 mb-4 placeholder:italic"
:placeholder="prompt || 'What was given?'"
/>
<div class="flex mb-4">
<button
class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<font-awesome icon="chevron-left" />
</button>
<input
id="inputGivenAmount"
v-model="amountInput"
type="number"
class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]"
/>
<button
class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<font-awesome icon="chevron-right" />
</button>
<select
v-model="unitCode"
class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"
>
<option
v-for="(displayName, code) in unitOptions"
:key="code"
:value="code"
>
{{ displayName }}
</option>
</select>
</div>
<router-link
:to="{
name: 'gifted-details',
query: giftedDetailsQuery,
}"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg mb-4"
>
Photo &amp; more options&hellip;
</router-link>
<p class="text-center text-sm mb-4">
<b class="font-medium">Sign &amp; Send</b> to publish to the world
<font-awesome
icon="circle-info"
class="fa-fw text-blue-500 text-base cursor-pointer"
@click="explainData()"
/>
</p>
<!-- Conflict warning -->
<div v-if="hasPersonConflict" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700 text-sm text-center">
<font-awesome icon="exclamation-triangle" class="fa-fw mr-1" />
Cannot record: Same person selected as both giver and recipient
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
:disabled="hasPersonConflict"
:class="{
'block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg': !hasPersonConflict,
'block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-slate-300 to-slate-500 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-slate-400 px-1.5 py-2 rounded-lg cursor-not-allowed': hasPersonConflict
}"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
@click="cancel"
>
Cancel
</button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop, Watch } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
serverMessageForUser, serverMessageForUser,
getHeaders,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
@@ -102,13 +537,38 @@ import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { PlanData } from "../interfaces/records";
@Component @Component({
components: {
EntityIcon,
ProjectIcon,
},
})
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop() fromProjectId = ""; @Prop() fromProjectId = "";
@Prop() toProjectId = ""; @Prop() toProjectId = "";
@Prop({ default: false }) showProjects = false;
@Prop() isFromProjectView = false;
@Watch("showProjects")
onShowProjectsChange() {
this.updateEntityTypes();
}
@Watch("fromProjectId")
onFromProjectIdChange() {
this.updateEntityTypes();
}
@Watch("toProjectId")
onToProjectIdChange() {
this.updateEntityTypes();
}
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
@@ -119,6 +579,7 @@ export default class GiftedDialog extends Vue {
callbackOnSuccess?: (amount: number) => void = () => {}; callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string; customTitle?: string;
description = ""; description = "";
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = ""; offerId = "";
prompt = ""; prompt = "";
@@ -128,6 +589,80 @@ export default class GiftedDialog extends Vue {
libsUtil = libsUtil; libsUtil = libsUtil;
projects: PlanData[] = [];
didInfo = didInfo;
// Computed property to help debug template logic
get shouldShowProjects() {
const result =
(this.stepType === "giver" && this.giverEntityType === "project") ||
(this.stepType === "recipient" && this.recipientEntityType === "project");
return result;
}
// Computed property to check if current selection would create a conflict
get hasPersonConflict() {
// Only check for conflicts when both entities are persons
if (this.giverEntityType !== "person" || this.recipientEntityType !== "person") {
return false;
}
// Check if giver and recipient are the same person
if (this.giver?.did && this.receiver?.did && this.giver.did === this.receiver.did) {
return true;
}
return false;
}
// Computed property to check if a contact would create a conflict when selected
wouldCreateConflict(contactDid: string) {
// Only check for conflicts when both entities are persons
if (this.giverEntityType !== "person" || this.recipientEntityType !== "person") {
return false;
}
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === contactDid;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === contactDid;
}
return false;
}
stepType = "giver";
giverEntityType = "person" as "person" | "project";
recipientEntityType = "person" as "person" | "project";
updateEntityTypes() {
// Reset and set entity types based on current context
this.giverEntityType = "person";
this.recipientEntityType = "person";
// Determine entity types based on current context
if (this.showProjects) {
// HomeView "Project" button or ProjectViewView "Given by This"
this.giverEntityType = "project";
this.recipientEntityType = "person";
} else if (this.fromProjectId) {
// ProjectViewView "Given by This" button (project is giver)
this.giverEntityType = "project";
this.recipientEntityType = "person";
} else if (this.toProjectId) {
// ProjectViewView "Given to This" button (project is recipient)
this.giverEntityType = "person";
this.recipientEntityType = "project";
} else {
// HomeView "Person" button
this.giverEntityType = "person";
this.recipientEntityType = "person";
}
}
async open( async open(
giver?: libsUtil.GiverReceiverInputInfo, giver?: libsUtil.GiverReceiverInputInfo,
receiver?: libsUtil.GiverReceiverInputInfo, receiver?: libsUtil.GiverReceiverInputInfo,
@@ -140,10 +675,14 @@ export default class GiftedDialog extends Vue {
this.giver = giver; this.giver = giver;
this.prompt = prompt || ""; this.prompt = prompt || "";
this.receiver = receiver; this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.amountInput = "0"; this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess; this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || ""; this.offerId = offerId || "";
this.firstStep = !giver;
this.stepType = "giver";
// Update entity types based on current props
this.updateEntityTypes();
try { try {
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
@@ -174,7 +713,16 @@ export default class GiftedDialog extends Vue {
this.allContacts, this.allContacts,
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings from database:", err); logger.error("Error retrieving settings from database:", err);
this.$notify( this.$notify(
@@ -224,6 +772,7 @@ export default class GiftedDialog extends Vue {
this.amountInput = "0"; this.amountInput = "0";
this.prompt = ""; this.prompt = "";
this.unitCode = "HUR"; this.unitCode = "HUR";
this.firstStep = true;
} }
async confirm() { async confirm() {
@@ -265,6 +814,20 @@ export default class GiftedDialog extends Vue {
); );
return; return;
} }
// Check for person conflict
if (this.hasPersonConflict) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You cannot select the same person as both giver and recipient.",
},
3000,
);
return;
}
this.close(); this.close();
this.$notify( this.$notify(
@@ -304,20 +867,52 @@ export default class GiftedDialog extends Vue {
unitCode: string = "HUR", unitCode: string = "HUR",
) { ) {
try { try {
// Determine the correct parameters based on entity types
let fromDid: string | undefined;
let toDid: string | undefined;
let fulfillsProjectHandleId: string | undefined;
let providerPlanHandleId: string | undefined;
if (this.giverEntityType === "project" && this.recipientEntityType === "person") {
// Project-to-person gift
fromDid = undefined; // No person giver
toDid = recipientDid as string; // Person recipient
fulfillsProjectHandleId = undefined; // No project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else if (this.giverEntityType === "person" && this.recipientEntityType === "project") {
// Person-to-project gift
fromDid = giverDid as string; // Person giver
toDid = undefined; // No person recipient
fulfillsProjectHandleId = this.toProjectId; // Project recipient
providerPlanHandleId = undefined; // No project giver
} else if (this.giverEntityType === "project" && this.recipientEntityType === "project") {
// Project-to-project gift
fromDid = undefined; // No person giver
toDid = undefined; // No person recipient
fulfillsProjectHandleId = this.toProjectId; // Project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else {
// Person-to-person gift
fromDid = giverDid as string;
toDid = recipientDid as string;
fulfillsProjectHandleId = undefined;
providerPlanHandleId = undefined;
}
const result = await createAndSubmitGive( const result = await createAndSubmitGive(
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid, this.activeDid,
giverDid as string, fromDid,
recipientDid as string, toDid,
description, description,
amount, amount,
unitCode, unitCode,
this.toProjectId, fulfillsProjectHandleId,
this.offerId, this.offerId,
false, false,
undefined, undefined,
this.fromProjectId, providerPlanHandleId,
); );
if (!result.success) { if (!result.success) {
@@ -378,6 +973,118 @@ export default class GiftedDialog extends Vue {
-1, -1,
); );
} }
selectGiver(contact?: Contact) {
if (contact) {
this.giver = {
did: contact.did,
name: contact.name || contact.did,
};
} else {
this.giver = {
did: "",
name: "",
};
}
this.firstStep = false;
}
goBackToStep1(step: string) {
this.stepType = step;
this.firstStep = true;
}
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to load projects",
},
3000,
);
}
}
selectProject(project: PlanData) {
this.giver = {
name: project.name,
image: project.image,
handleId: project.handleId,
};
this.receiver = {
did: this.activeDid,
name: "You",
};
this.firstStep = false;
}
selectRecipient(contact?: Contact) {
if (contact) {
this.receiver = {
did: contact.did,
name: contact.name || contact.did,
};
} else {
this.receiver = {
did: "",
name: "",
};
}
this.firstStep = false;
}
selectRecipientProject(project: PlanData) {
this.receiver = {
// no did, because it's a project
name: project.name,
image: project.image,
handleId: project.handleId,
};
this.firstStep = false;
}
// Computed property for the query parameters
get giftedDetailsQuery() {
return {
amountInput: this.amountInput,
description: this.description,
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId: this.giverEntityType === "person" && this.recipientEntityType === "project"
? this.toProjectId
: undefined,
providerProjectId: this.giverEntityType === "project" && this.recipientEntityType === "person"
? this.giver?.handleId
: this.fromProjectId,
recipientDid: this.receiver?.did,
recipientName: this.receiver?.name,
unitCode: this.unitCode,
};
}
// Computed property to get unit options
get unitOptions() {
return this.libsUtil.UNIT_SHORT;
}
} }
</script> </script>

View File

@@ -77,7 +77,7 @@
If you'd like an introduction, If you'd like an introduction,
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', deepLinkUrl)"
>click here to copy this page, paste it into a message, and ask if >click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a they'll tell you more about the {{ roleName }}.</a
> >
@@ -104,7 +104,7 @@ import * as R from "ramda";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
@Component @Component
export default class HiddenDidDialog extends Vue { export default class HiddenDidDialog extends Vue {
@@ -117,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
activeDid = ""; activeDid = "";
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
canShare = false; canShare = false;
windowLocation = window.location.href; deepLinkPathSuffix = "";
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
R = R; R = R;
serverUtil = serverUtil; serverUtil = serverUtil;
@@ -129,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
} }
open( open(
deepLinkPathSuffix: string,
roleName: string, roleName: string,
visibleToDids: string[], visibleToDids: string[],
allContacts: Array<Contact>, allContacts: Array<Contact>,
activeDid: string, activeDid: string,
allMyDids: Array<string>, allMyDids: Array<string>,
) { ) {
this.deepLinkPathSuffix = deepLinkPathSuffix;
this.roleName = roleName; this.roleName = roleName;
this.visibleToDids = visibleToDids; this.visibleToDids = visibleToDids;
this.allContacts = allContacts; this.allContacts = allContacts;
this.activeDid = activeDid; this.activeDid = activeDid;
this.allMyDids = allMyDids; this.allMyDids = allMyDids;
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
this.isOpen = true; this.isOpen = true;
} }
@@ -173,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation); this.copyToClipboard("A link to this page", this.deepLinkUrl);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.deepLinkUrl,
}); });
} }
} }

View File

@@ -34,7 +34,6 @@ const secretBase64 = arrayBufferToBase64(randomBytes);
const MIGRATIONS = [ const MIGRATIONS = [
{ {
name: "001_initial", name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: ` sql: `
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -119,6 +118,12 @@ const MIGRATIONS = [
); );
`, `,
}, },
{
name: "002_add_iViewContent_to_contacts",
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
]; ];
/** /**

View File

@@ -219,9 +219,9 @@ export async function logConsoleAndDb(
isError = false, isError = false,
): Promise<void> { ): Promise<void> {
if (isError) { if (isError) {
logger.error(`${new Date().toISOString()} ${message}`); logger.error(`${new Date().toISOString()}`, message);
} else { } else {
logger.log(`${new Date().toISOString()} ${message}`); logger.log(`${new Date().toISOString()}`, message);
} }
await logToDb(message); await logToDb(message);
} }

View File

@@ -1,15 +1,16 @@
export interface ContactMethod { export type ContactMethod = {
label: string; label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API" type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string; value: string;
} };
export interface Contact { export type Contact = {
// //
// When adding a property, consider whether it should be added when exporting & sharing contacts. // When adding a property, consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
did: string; did: string;
contactMethods?: Array<ContactMethod>; contactMethods?: Array<ContactMethod>;
iViewContent?: boolean;
name?: string; name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string; notes?: string;
@@ -17,9 +18,15 @@ export interface Contact {
publicKeyBase64?: string; publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting seesMe?: boolean; // cached value of the server setting
registered?: boolean; // cached value of the server setting registered?: boolean; // cached value of the server setting
} };
export type ContactWithJsonStrings = Contact & { /**
* This is for those cases (eg. with a DB) where every field is a primitive (and not an object).
*
* This is so that we can reuse most of the type and don't have to maintain another copy.
* Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2
*/
export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string; contactMethods?: string;
}; };

View File

@@ -29,18 +29,17 @@ import { z } from "zod";
// Add a union type of all valid route paths // Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [ export const VALID_DEEP_LINK_ROUTES = [
"user-profile", // note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
"project",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim", "claim",
"claim-cert",
"claim-add-raw", "claim-add-raw",
"contact-edit", "claim-cert",
"contacts", "confirm-gift",
"contact-import",
"did", "did",
"invite-one-accept",
"onboard-meeting-setup",
"project",
"user-profile",
] as const; ] as const;
// Create a type from the array // Create a type from the array
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type // Parameter validation schemas for each route type
export const deepLinkSchemas = { export const deepLinkSchemas = {
"user-profile": z.object({ // note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({ claim: z.object({
id: z.string(), id: z.string(),
}), }),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({ "claim-add-raw": z.object({
id: z.string(), id: z.string(),
claim: z.string().optional(), claim: z.string().optional(),
claimJwtId: z.string().optional(), claimJwtId: z.string().optional(),
}), }),
"contact-edit": z.object({ "claim-cert": z.object({
did: z.string(), id: z.string(),
}), }),
contacts: z.object({ "confirm-gift": z.object({
contacts: z.string(), // JSON string of contacts array id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}), }),
did: z.object({ did: z.object({
did: z.string(), did: z.string(),
}), }),
"invite-one-accept": z.object({
jwt: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"user-profile": z.object({
id: z.string(),
}),
}; };
export type DeepLinkParams = { export type DeepLinkParams = {

View File

@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo); const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI; const viewPrefix =
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt; return viewPrefix + vcJwt;
} }

View File

@@ -10,8 +10,8 @@ import {
faArrowLeft, faArrowLeft,
faArrowRight, faArrowRight,
faArrowRotateBackward, faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp, faArrowUp,
faArrowUpRightFromSquare,
faBan, faBan,
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
@@ -29,6 +29,7 @@ import {
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleQuestion, faCircleQuestion,
faCircleRight,
faCircleUser, faCircleUser,
faClock, faClock,
faCoins, faCoins,
@@ -60,6 +61,7 @@ import {
faLightbulb, faLightbulb,
faLink, faLink,
faLocationDot, faLocationDot,
faLock,
faLongArrowAltLeft, faLongArrowAltLeft,
faLongArrowAltRight, faLongArrowAltRight,
faMagnifyingGlass, faMagnifyingGlass,
@@ -79,6 +81,7 @@ import {
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
faThumbtack,
faTrashCan, faTrashCan,
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
@@ -92,8 +95,8 @@ library.add(
faArrowLeft, faArrowLeft,
faArrowRight, faArrowRight,
faArrowRotateBackward, faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp, faArrowUp,
faArrowUpRightFromSquare,
faBan, faBan,
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
@@ -111,6 +114,7 @@ library.add(
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleQuestion, faCircleQuestion,
faCircleRight,
faCircleUser, faCircleUser,
faClock, faClock,
faCoins, faCoins,
@@ -142,6 +146,7 @@ library.add(
faLightbulb, faLightbulb,
faLink, faLink,
faLocationDot, faLocationDot,
faLock,
faLongArrowAltLeft, faLongArrowAltLeft,
faLongArrowAltRight, faLongArrowAltRight,
faMagnifyingGlass, faMagnifyingGlass,
@@ -161,6 +166,7 @@ library.add(
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
faThumbtack,
faTrashCan, faTrashCan,
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,

View File

@@ -17,7 +17,7 @@ import {
updateDefaultSettings, updateDefaultSettings,
} from "../db/index"; } from "../db/index";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts"; import { Contact, ContactWithJsonStrings } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { import {
@@ -50,6 +50,8 @@ import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
export interface GiverReceiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string;
name?: string; name?: string;
image?: string;
handleId?: string;
} }
export enum OnboardPage { export enum OnboardPage {
@@ -974,19 +976,16 @@ export interface DatabaseExport {
*/ */
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => { export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
// Convert each contact to a plain object and ensure all fields are included // Convert each contact to a plain object and ensure all fields are included
const rows = contacts.map((contact) => ({ const rows = contacts.map((contact) => {
did: contact.did, const exContact: ContactWithJsonStrings = R.omit(
name: contact.name || null, ["contactMethods"],
contactMethods: contact.contactMethods contact,
? JSON.stringify(parseJsonField(contact.contactMethods, [])) );
: null, exContact.contactMethods = contact.contactMethods
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null, ? JSON.stringify(contact.contactMethods, [])
notes: contact.notes || null, : undefined;
profileImageUrl: contact.profileImageUrl || null, return exContact;
publicKeyBase64: contact.publicKeyBase64 || null, });
seesMe: contact.seesMe || false,
registered: contact.registered || false,
}));
return { return {
data: { data: {

View File

@@ -34,8 +34,7 @@ import router from "./router";
import { handleApiError } from "./services/api"; import { handleApiError } from "./services/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks"; import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db/databaseUtil"; import { logger, safeStringify } from "./utils/logger";
import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization"); logger.log("[Capacitor] Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
await router.isReady(); await router.isReady();
await deepLinkHandler.handleDeepLink(data.url); await deepLinkHandler.handleDeepLink(data.url);
} catch (error) { } catch (error) {
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true); logger.error("[DeepLink] Error handling deep link: ", error);
handleApiError( handleApiError(
{ {
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : safeStringify(error),
} as AxiosError, } as AxiosError,
"deep-link", "deep-link",
); );

View File

@@ -10,15 +10,11 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM; // const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true"; // const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.log("Platform", JSON.stringify({ platform }));
logger.log("PWA enabled", JSON.stringify({ pwa_enabled }));
// Global Error Handler // Global Error Handler
function setupGlobalErrorHandler(app: VueApp) { function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler");
app.config.errorHandler = ( app.config.errorHandler = (
err: unknown, err: unknown,
instance: ComponentPublicInstance | null, instance: ComponentPublicInstance | null,
@@ -38,30 +34,13 @@ function setupGlobalErrorHandler(app: VueApp) {
// Function to initialize the app // Function to initialize the app
export function initializeApp() { export function initializeApp() {
logger.log("[App Init] Starting app initialization");
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM);
const app = createApp(App); const app = createApp(App);
logger.log("[App Init] Vue app created");
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera); app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
logger.log("[App Init] Components registered");
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
logger.log("[App Init] Pinia store initialized");
app.use(VueAxios, axios); app.use(VueAxios, axios);
logger.log("[App Init] Axios initialized");
app.use(router); app.use(router);
logger.log("[App Init] Router initialized");
app.use(Notifications); app.use(Notifications);
logger.log("[App Init] Notifications initialized");
setupGlobalErrorHandler(app); setupGlobalErrorHandler(app);
logger.log("[App Init] App initialization complete");
return app; return app;
} }

View File

@@ -5,9 +5,6 @@ import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM; const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true"; const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Web] PWA enabled", { pwa_enabled });
logger.info("[Web] Platform", { platform });
// Only import service worker for web builds // Only import service worker for web builds
if (platform !== "electron" && pwa_enabled) { if (platform !== "electron" && pwa_enabled) {
import("./registerServiceWorker"); // Web PWA support import("./registerServiceWorker"); // Web PWA support
@@ -31,7 +28,7 @@ function sqlInit() {
if (platform === "web" || platform === "development") { if (platform === "web" || platform === "development") {
sqlInit(); sqlInit();
} else { } else {
logger.info("[Web] SQL not initialized for platform", { platform }); logger.warn("[Web] SQL not initialized for platform", { platform });
} }
app.mount("#app"); app.mount("#app");

View File

@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
name: "discover", name: "discover",
component: () => import("../views/DiscoverView.vue"), component: () => import("../views/DiscoverView.vue"),
}, },
{
path: "/deep-link/:path*",
name: "deep-link",
component: () => import("../views/DeepLinkRedirectView.vue"),
},
{ {
path: "/gifted-details", path: "/gifted-details",
name: "gifted-details", name: "gifted-details",

View File

@@ -6,7 +6,7 @@
*/ */
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { logger } from "../utils/logger"; import { logger, safeStringify } from "../utils/logger";
/** /**
* Handles API errors with platform-specific logging and error processing. * Handles API errors with platform-specific logging and error processing.
@@ -37,7 +37,8 @@ import { logger } from "../utils/logger";
*/ */
export const handleApiError = (error: AxiosError, endpoint: string) => { export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") { if (process.env.VITE_PLATFORM === "capacitor") {
logger.error(`[Capacitor API Error] ${endpoint}:`, { const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,

View File

@@ -27,18 +27,16 @@
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2] * timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
* *
* Supported Routes: * Supported Routes:
* - user-profile: View user profile
* - project: View project details
* - onboard-meeting-setup: Setup onboarding meeting
* - invite-one-accept: Accept invitation
* - contact-import: Import contacts
* - confirm-gift: Confirm gift
* - claim: View claim * - claim: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim * - claim-add-raw: Add raw claim
* - contact-edit: Edit contact * - claim-cert: View claim certificate
* - contacts: View contacts * - confirm-gift
* - contact-import: Import contacts
* - did: View DID * - did: View DID
* - invite-one-accept: Accept invitation
* - onboard-meeting-members
* - project: View project details
* - user-profile: View user profile
* *
* @example * @example
* const handler = new DeepLinkHandler(router); * const handler = new DeepLinkHandler(router);
@@ -81,14 +79,15 @@ export class DeepLinkHandler {
string, string,
{ name: string; paramKey?: string } { name: string; paramKey?: string }
> = { > = {
// note that similar lists are in src/interfaces/deepLinks.ts
claim: { name: "claim" }, claim: { name: "claim" },
"claim-add-raw": { name: "claim-add-raw" }, "claim-add-raw": { name: "claim-add-raw" },
"claim-cert": { name: "claim-cert" }, "claim-cert": { name: "claim-cert" },
"confirm-gift": { name: "confirm-gift" }, "confirm-gift": { name: "confirm-gift" },
"contact-import": { name: "contact-import", paramKey: "jwt" },
did: { name: "did", paramKey: "did" }, did: { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" }, "invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
"onboard-meeting-members": { name: "onboard-meeting-members" }, "onboard-meeting-members": { name: "onboard-meeting-members" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
project: { name: "project" }, project: { name: "project" },
"user-profile": { name: "user-profile" }, "user-profile": { name: "user-profile" },
}; };
@@ -99,7 +98,7 @@ export class DeepLinkHandler {
* *
* @param url - The deep link URL to parse (format: scheme://path[?query]) * @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid * @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path, params, query) * @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
*/ */
private parseDeepLink(url: string) { private parseDeepLink(url: string) {
const parts = url.split("://"); const parts = url.split("://");
@@ -115,7 +114,16 @@ export class DeepLinkHandler {
}); });
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/"); const [routePath, ...pathParams] = path.split("/");
// logger.info(
// "[DeepLink] Debug:",
// "Route Path:",
// routePath,
// "Path Params:",
// pathParams,
// "Query String:",
// queryString,
// );
// Validate route exists before proceeding // Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) { if (!this.ROUTE_MAP[routePath]) {
@@ -134,45 +142,14 @@ export class DeepLinkHandler {
} }
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (param) { if (pathParams) {
// Now we know routePath exists in ROUTE_MAP // Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath]; const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param; params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
} }
return { path: routePath, params, query }; return { path: routePath, params, query };
} }
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}
/** /**
* Routes the deep link to appropriate view with validated parameters. * Routes the deep link to appropriate view with validated parameters.
* Validates route and parameters using Zod schemas before routing. * Validates route and parameters using Zod schemas before routing.
@@ -243,6 +220,39 @@ export class DeepLinkHandler {
code: "INVALID_PARAMETERS", code: "INVALID_PARAMETERS",
message: (error as Error).message, message: (error as Error).message,
details: error, details: error,
params: params,
query: query,
};
}
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
}; };
} }
} }

View File

@@ -39,7 +39,6 @@ import {
generateUpdateStatement, generateUpdateStatement,
generateInsertStatement, generateInsertStatement,
} from "../db/databaseUtil"; } from "../db/databaseUtil";
import { updateDefaultSettings } from "../db/databaseUtil";
import { importFromMnemonic } from "../libs/util"; import { importFromMnemonic } from "../libs/util";
/** /**
@@ -156,11 +155,14 @@ export async function getDexieContacts(): Promise<Contact[]> {
await db.open(); await db.open();
const contacts = await db.contacts.toArray(); const contacts = await db.contacts.toArray();
logger.info( logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from Dexie`, `[IndexedDBMigrationService] Retrieved ${contacts.length} contacts from Dexie`,
); );
return contacts; return contacts;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving Dexie contacts:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie contacts:",
error,
);
throw new Error(`Failed to retrieve Dexie contacts: ${error}`); throw new Error(`Failed to retrieve Dexie contacts: ${error}`);
} }
} }
@@ -214,11 +216,14 @@ export async function getSqliteContacts(): Promise<Contact[]> {
} }
logger.info( logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from SQLite`, `[IndexedDBMigrationService] Retrieved ${contacts.length} contacts from SQLite`,
); );
return contacts; return contacts;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving SQLite contacts:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite contacts:",
error,
);
throw new Error(`Failed to retrieve SQLite contacts: ${error}`); throw new Error(`Failed to retrieve SQLite contacts: ${error}`);
} }
} }
@@ -251,11 +256,14 @@ export async function getDexieSettings(): Promise<Settings[]> {
await db.open(); await db.open();
const settings = await db.settings.toArray(); const settings = await db.settings.toArray();
logger.info( logger.info(
`[MigrationService] Retrieved ${settings.length} settings from Dexie`, `[IndexedDBMigrationService] Retrieved ${settings.length} settings from Dexie`,
); );
return settings; return settings;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving Dexie settings:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie settings:",
error,
);
throw new Error(`Failed to retrieve Dexie settings: ${error}`); throw new Error(`Failed to retrieve Dexie settings: ${error}`);
} }
} }
@@ -309,11 +317,14 @@ export async function getSqliteSettings(): Promise<Settings[]> {
} }
logger.info( logger.info(
`[MigrationService] Retrieved ${settings.length} settings from SQLite`, `[IndexedDBMigrationService] Retrieved ${settings.length} settings from SQLite`,
); );
return settings; return settings;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving SQLite settings:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite settings:",
error,
);
throw new Error(`Failed to retrieve SQLite settings: ${error}`); throw new Error(`Failed to retrieve SQLite settings: ${error}`);
} }
} }
@@ -353,11 +364,14 @@ export async function getSqliteAccounts(): Promise<string[]> {
} }
logger.info( logger.info(
`[MigrationService] Retrieved ${dids.length} accounts from SQLite`, `[IndexedDBMigrationService] Retrieved ${dids.length} accounts from SQLite`,
); );
return dids; return dids;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving SQLite accounts:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite accounts:",
error,
);
throw new Error(`Failed to retrieve SQLite accounts: ${error}`); throw new Error(`Failed to retrieve SQLite accounts: ${error}`);
} }
} }
@@ -391,11 +405,14 @@ export async function getDexieAccounts(): Promise<Account[]> {
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
logger.info( logger.info(
`[MigrationService] Retrieved ${accounts.length} accounts from Dexie`, `[IndexedDBMigrationService] Retrieved ${accounts.length} accounts from Dexie`,
); );
return accounts; return accounts;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving Dexie accounts:", error); logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie accounts:",
error,
);
throw new Error(`Failed to retrieve Dexie accounts: ${error}`); throw new Error(`Failed to retrieve Dexie accounts: ${error}`);
} }
} }
@@ -429,7 +446,7 @@ export async function getDexieAccounts(): Promise<Account[]> {
* ``` * ```
*/ */
export async function compareDatabases(): Promise<DataComparison> { export async function compareDatabases(): Promise<DataComparison> {
logger.info("[MigrationService] Starting database comparison"); logger.info("[IndexedDBMigrationService] Starting database comparison");
const [ const [
dexieContacts, dexieContacts,
@@ -470,7 +487,7 @@ export async function compareDatabases(): Promise<DataComparison> {
}, },
}; };
logger.info("[MigrationService] Database comparison completed", { logger.info("[IndexedDBMigrationService] Database comparison completed", {
dexieContacts: dexieContacts.length, dexieContacts: dexieContacts.length,
sqliteContacts: sqliteContacts.length, sqliteContacts: sqliteContacts.length,
dexieSettings: dexieSettings.length, dexieSettings: dexieSettings.length,
@@ -679,6 +696,7 @@ function compareAccounts(dexieAccounts: Account[], sqliteDids: string[]) {
* ``` * ```
*/ */
function contactsEqual(contact1: Contact, contact2: Contact): boolean { function contactsEqual(contact1: Contact, contact2: Contact): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ifEmpty = (arg: any, def: any) => (arg ? arg : def); const ifEmpty = (arg: any, def: any) => (arg ? arg : def);
const contact1Methods = const contact1Methods =
contact1.contactMethods && contact1.contactMethods &&
@@ -954,7 +972,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
export async function migrateContacts( export async function migrateContacts(
overwriteExisting: boolean = false, overwriteExisting: boolean = false,
): Promise<MigrationResult> { ): Promise<MigrationResult> {
logger.info("[MigrationService] Starting contact migration", { logger.info("[IndexedDBMigrationService] Starting contact migration", {
overwriteExisting, overwriteExisting,
}); });
@@ -990,7 +1008,7 @@ export async function migrateContacts(
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.contactsMigrated++; result.contactsMigrated++;
logger.info(`[MigrationService] Updated contact: ${contact.did}`); logger.info(`[IndexedDBMigrationService] Updated contact: ${contact.did}`);
} else { } else {
result.warnings.push( result.warnings.push(
`Contact ${contact.did} already exists, skipping`, `Contact ${contact.did} already exists, skipping`,
@@ -1004,17 +1022,17 @@ export async function migrateContacts(
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.contactsMigrated++; result.contactsMigrated++;
logger.info(`[MigrationService] Added contact: ${contact.did}`); logger.info(`[IndexedDBMigrationService] Added contact: ${contact.did}`);
} }
} catch (error) { } catch (error) {
const errorMsg = `Failed to migrate contact ${contact.did}: ${error}`; const errorMsg = `Failed to migrate contact ${contact.did}: ${error}`;
logger.error("[MigrationService]", errorMsg); logger.error("[IndexedDBMigrationService]", errorMsg);
result.errors.push(errorMsg); result.errors.push(errorMsg);
result.success = false; result.success = false;
} }
} }
logger.info("[MigrationService] Contact migration completed", { logger.info("[IndexedDBMigrationService] Contact migration completed", {
contactsMigrated: result.contactsMigrated, contactsMigrated: result.contactsMigrated,
errors: result.errors.length, errors: result.errors.length,
warnings: result.warnings.length, warnings: result.warnings.length,
@@ -1023,7 +1041,7 @@ export async function migrateContacts(
return result; return result;
} catch (error) { } catch (error) {
const errorMsg = `Contact migration failed: ${error}`; const errorMsg = `Contact migration failed: ${error}`;
logger.error("[MigrationService]", errorMsg); logger.error("[IndexedDBMigrationService]", errorMsg);
result.errors.push(errorMsg); result.errors.push(errorMsg);
result.success = false; result.success = false;
return result; return result;
@@ -1063,7 +1081,7 @@ export async function migrateContacts(
* ``` * ```
*/ */
export async function migrateSettings(): Promise<MigrationResult> { export async function migrateSettings(): Promise<MigrationResult> {
logger.info("[MigrationService] Starting settings migration"); logger.info("[IndexedDBMigrationService] Starting settings migration");
const result: MigrationResult = { const result: MigrationResult = {
success: true, success: true,
@@ -1076,17 +1094,17 @@ export async function migrateSettings(): Promise<MigrationResult> {
try { try {
const dexieSettings = await getDexieSettings(); const dexieSettings = await getDexieSettings();
logger.info("[MigrationService] Migrating settings", { logger.info("[IndexedDBMigrationService] Migrating settings", {
dexieSettings: dexieSettings.length, dexieSettings: dexieSettings.length,
}); });
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
// Create an array of promises for all settings migrations // Create an array of promises for all settings migrations
const migrationPromises = dexieSettings.map(async (setting) => { const migrationPromises = dexieSettings.map(async (setting) => {
logger.info("[MigrationService] Starting to migrate settings", setting); logger.info(
let sqliteSettingRaw: "[IndexedDBMigrationService] Starting to migrate settings",
| { columns: string[]; values: unknown[][] } setting,
| undefined; );
// adjust SQL based on the accountDid key, maybe null // adjust SQL based on the accountDid key, maybe null
let conditional: string; let conditional: string;
@@ -1098,15 +1116,18 @@ export async function migrateSettings(): Promise<MigrationResult> {
conditional = "accountDid = ?"; conditional = "accountDid = ?";
preparams = [setting.accountDid]; preparams = [setting.accountDid];
} }
sqliteSettingRaw = await platformService.dbQuery( const sqliteSettingRaw = await platformService.dbQuery(
"SELECT * FROM settings WHERE " + conditional, "SELECT * FROM settings WHERE " + conditional,
preparams, preparams,
); );
logger.info("[MigrationService] Migrating one set of settings:", { logger.info(
setting, "[IndexedDBMigrationService] Migrating one set of settings:",
sqliteSettingRaw, {
}); setting,
sqliteSettingRaw,
},
);
if (sqliteSettingRaw?.values?.length) { if (sqliteSettingRaw?.values?.length) {
// should cover the master settings, where accountDid is null // should cover the master settings, where accountDid is null
delete setting.id; // don't conflict with the id in the sqlite database delete setting.id; // don't conflict with the id in the sqlite database
@@ -1117,7 +1138,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
conditional, conditional,
preparams, preparams,
); );
logger.info("[MigrationService] Updating settings", { logger.info("[IndexedDBMigrationService] Updating settings", {
sql, sql,
params, params,
}); });
@@ -1127,10 +1148,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
// insert new setting // insert new setting
delete setting.id; // don't conflict with the id in the sqlite database delete setting.id; // don't conflict with the id in the sqlite database
delete setting.activeDid; // ensure we don't set the activeDid (since master settings are an update and don't hit this case) delete setting.activeDid; // ensure we don't set the activeDid (since master settings are an update and don't hit this case)
const { sql, params } = generateInsertStatement( const { sql, params } = generateInsertStatement(setting, "settings");
setting,
"settings",
);
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.settingsMigrated++; result.settingsMigrated++;
} }
@@ -1140,7 +1158,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
const updatedSettings = await Promise.all(migrationPromises); const updatedSettings = await Promise.all(migrationPromises);
logger.info( logger.info(
"[MigrationService] Finished migrating settings", "[IndexedDBMigrationService] Finished migrating settings",
updatedSettings, updatedSettings,
result, result,
); );
@@ -1148,7 +1166,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
return result; return result;
} catch (error) { } catch (error) {
logger.error( logger.error(
"[MigrationService] Complete settings migration failed:", "[IndexedDBMigrationService] Complete settings migration failed:",
error, error,
); );
const errorMessage = `Settings migration failed: ${error}`; const errorMessage = `Settings migration failed: ${error}`;
@@ -1192,7 +1210,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
* ``` * ```
*/ */
export async function migrateAccounts(): Promise<MigrationResult> { export async function migrateAccounts(): Promise<MigrationResult> {
logger.info("[MigrationService] Starting account migration"); logger.info("[IndexedDBMigrationService] Starting account migration");
const result: MigrationResult = { const result: MigrationResult = {
success: true, success: true,
@@ -1248,14 +1266,17 @@ export async function migrateAccounts(): Promise<MigrationResult> {
); );
} }
logger.info("[MigrationService] Successfully migrated account", { logger.info(
did, "[IndexedDBMigrationService] Successfully migrated account",
dateCreated: account.dateCreated, {
}); did,
dateCreated: account.dateCreated,
},
);
} catch (error) { } catch (error) {
const errorMessage = `Failed to migrate account ${did}: ${error}`; const errorMessage = `Failed to migrate account ${did}: ${error}`;
result.errors.push(errorMessage); result.errors.push(errorMessage);
logger.error("[MigrationService] Account migration failed:", { logger.error("[IndexedDBMigrationService] Account migration failed:", {
error, error,
did, did,
}); });
@@ -1272,7 +1293,7 @@ export async function migrateAccounts(): Promise<MigrationResult> {
result.errors.push(errorMessage); result.errors.push(errorMessage);
result.success = false; result.success = false;
logger.error( logger.error(
"[MigrationService] Complete account migration failed:", "[IndexedDBMigrationService] Complete account migration failed:",
error, error,
); );
return result; return result;
@@ -1306,11 +1327,11 @@ export async function migrateAll(): Promise<MigrationResult> {
try { try {
logger.info( logger.info(
"[MigrationService] Starting complete migration from Dexie to SQLite", "[IndexedDBMigrationService] Starting complete migration from Dexie to SQLite",
); );
// Step 1: Migrate Accounts (foundational) // Step 1: Migrate Accounts (foundational)
logger.info("[MigrationService] Step 1: Migrating accounts..."); logger.info("[IndexedDBMigrationService] Step 1: Migrating accounts...");
const accountsResult = await migrateAccounts(); const accountsResult = await migrateAccounts();
if (!accountsResult.success) { if (!accountsResult.success) {
result.errors.push( result.errors.push(
@@ -1322,7 +1343,7 @@ export async function migrateAll(): Promise<MigrationResult> {
result.warnings.push(...accountsResult.warnings); result.warnings.push(...accountsResult.warnings);
// Step 2: Migrate Settings (depends on accounts) // Step 2: Migrate Settings (depends on accounts)
logger.info("[MigrationService] Step 2: Migrating settings..."); logger.info("[IndexedDBMigrationService] Step 2: Migrating settings...");
const settingsResult = await migrateSettings(); const settingsResult = await migrateSettings();
if (!settingsResult.success) { if (!settingsResult.success) {
result.errors.push( result.errors.push(
@@ -1335,7 +1356,7 @@ export async function migrateAll(): Promise<MigrationResult> {
// Step 4: Migrate Contacts (independent, but after accounts for consistency) // Step 4: Migrate Contacts (independent, but after accounts for consistency)
// ... but which is better done through the contact import view // ... but which is better done through the contact import view
// logger.info("[MigrationService] Step 4: Migrating contacts..."); // logger.info("[IndexedDBMigrationService] Step 4: Migrating contacts...");
// const contactsResult = await migrateContacts(); // const contactsResult = await migrateContacts();
// if (!contactsResult.success) { // if (!contactsResult.success) {
// result.errors.push( // result.errors.push(
@@ -1354,7 +1375,7 @@ export async function migrateAll(): Promise<MigrationResult> {
result.contactsMigrated; result.contactsMigrated;
logger.info( logger.info(
`[MigrationService] Complete migration successful: ${totalMigrated} total records migrated`, `[IndexedDBMigrationService] Complete migration successful: ${totalMigrated} total records migrated`,
{ {
accounts: result.accountsMigrated, accounts: result.accountsMigrated,
settings: result.settingsMigrated, settings: result.settingsMigrated,
@@ -1367,7 +1388,10 @@ export async function migrateAll(): Promise<MigrationResult> {
} catch (error) { } catch (error) {
const errorMessage = `Complete migration failed: ${error}`; const errorMessage = `Complete migration failed: ${error}`;
result.errors.push(errorMessage); result.errors.push(errorMessage);
logger.error("[MigrationService] Complete migration failed:", error); logger.error(
"[IndexedDBMigrationService] Complete migration failed:",
error,
);
return result; return result;
} }
} }

View File

@@ -25,7 +25,6 @@ class MigrationRegistry {
*/ */
registerMigration(migration: Migration): void { registerMigration(migration: Migration): void {
this.migrations.push(migration); this.migrations.push(migration);
logger.info(`[MigrationService] Registered migration: ${migration.name}`);
} }
/** /**
@@ -42,7 +41,6 @@ class MigrationRegistry {
*/ */
clearMigrations(): void { clearMigrations(): void {
this.migrations = []; this.migrations = [];
logger.info("[MigrationService] Cleared all registered migrations");
} }
} }
@@ -94,10 +92,6 @@ export async function runMigrations<T>(
); );
const appliedMigrations = extractMigrationNames(appliedMigrationsResult); const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
logger.info(
`[MigrationService] Found ${appliedMigrations.size} applied migrations`,
);
// Get all registered migrations // Get all registered migrations
const migrations = migrationRegistry.getMigrations(); const migrations = migrationRegistry.getMigrations();
@@ -106,21 +100,12 @@ export async function runMigrations<T>(
return; return;
} }
logger.info(
`[MigrationService] Running ${migrations.length} registered migrations`,
);
// Run each migration that hasn't been applied yet // Run each migration that hasn't been applied yet
for (const migration of migrations) { for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) { if (appliedMigrations.has(migration.name)) {
logger.info(
`[MigrationService] Skipping already applied migration: ${migration.name}`,
);
continue; continue;
} }
logger.info(`[MigrationService] Applying migration: ${migration.name}`);
try { try {
// Execute the migration SQL // Execute the migration SQL
await sqlExec(migration.sql); await sqlExec(migration.sql);
@@ -141,8 +126,6 @@ export async function runMigrations<T>(
throw new Error(`Migration ${migration.name} failed: ${error}`); throw new Error(`Migration ${migration.name} failed: ${error}`);
} }
} }
logger.info("[MigrationService] All migrations completed successfully");
} catch (error) { } catch (error) {
logger.error("[MigrationService] Migration process failed:", error); logger.error("[MigrationService] Migration process failed:", error);
throw error; throw error;

View File

@@ -1,6 +1,6 @@
import { logToDb } from "../db/databaseUtil"; import { logToDb } from "../db/databaseUtil";
function safeStringify(obj: unknown) { export function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => { return JSON.stringify(obj, (_key, value) => {
@@ -52,23 +52,18 @@ export const logger = {
} }
}, },
warn: (message: string, ...args: unknown[]) => { warn: (message: string, ...args: unknown[]) => {
if ( // eslint-disable-next-line no-console
process.env.NODE_ENV !== "production" || console.warn(message, ...args);
process.env.VITE_PLATFORM === "capacitor" || const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
process.env.VITE_PLATFORM === "electron" logToDb(message + argsString);
) {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
}, },
error: (message: string, ...args: unknown[]) => { error: (message: string, ...args: unknown[]) => {
// Errors will always be logged // Errors will always be logged
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(message, ...args); console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const messageString = safeStringify(message);
logToDb(message + argsString); const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
}, },
}; };

View File

@@ -349,8 +349,9 @@
</div> </div>
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video"> <div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500"> <p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your The location you choose will be shared with the world until you remove
place. this checkbox. For your security, choose a location nearby but not
exactly at your true location, like at your town center.
</p> </p>
<l-map <l-map
@@ -435,11 +436,11 @@
<p class="text-sm"> <p class="text-sm">
You have done You have done
<b <b
>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{ >{{ endorserLimits?.doneClaimsThisWeek ?? "?" }} claim{{
endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s" endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
}}</b }}</b
> >
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this out of <b>{{ endorserLimits?.maxClaimsPerWeek ?? "?" }}</b> for this
week. Your claims counter resets at week. Your claims counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime) readableDate(endorserLimits?.nextWeekBeginDateTime)
@@ -449,14 +450,14 @@
You have done You have done
<b <b
>{{ >{{
endorserLimits?.doneRegistrationsThisMonth || "?" endorserLimits?.doneRegistrationsThisMonth ?? "?"
}} }}
registration{{ registration{{
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s" endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
}}</b }}</b
> >
out of out of
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this <b>{{ endorserLimits?.maxRegistrationsPerMonth ?? "?" }}</b> for this
this month. this month.
<i>(You cannot register anyone on your first day.)</i> <i>(You cannot register anyone on your first day.)</i>
Your registration counter resets at Your registration counter resets at
@@ -467,11 +468,11 @@
<p class="mt-3 text-sm"> <p class="mt-3 text-sm">
You have uploaded You have uploaded
<b <b
>{{ imageLimits?.doneImagesThisWeek || "?" }} image{{ >{{ imageLimits?.doneImagesThisWeek ?? "?" }} image{{
imageLimits?.doneImagesThisWeek === 1 ? "" : "s" imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
}}</b }}</b
> >
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this out of <b>{{ imageLimits?.maxImagesPerWeek ?? "?" }}</b> for this
week. Your image counter resets at week. Your image counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime) readableDate(imageLimits?.nextWeekBeginDateTime)

View File

@@ -49,21 +49,32 @@
v-if="veriClaim.id" v-if="veriClaim.id"
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)" :to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2" class="text-blue-500 mt-2"
title="Printable Certificate" title="View Printable Certificate"
> >
<font-awesome <font-awesome
icon="square" icon="square"
class="text-white bg-yellow-500 p-1" class="text-white bg-yellow-500 p-1"
/> />
</router-link> </router-link>
<button
v-if="veriClaim.id"
class="text-blue-500 ml-2 mt-2"
title="Copy Printable Certificate Link"
@click="
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
"
>
<font-awesome icon="link" class="text-yellow-500 p-1" />
</button>
</div> </div>
<!-- show link icon to copy this URL to the clipboard --> <!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full"> <div class="flex justify-end w-full">
<button <button
title="Copy Link" title="Copy Link"
@click=" @click="copyToClipboard('A link to this page', windowDeepLink)"
copyToClipboard('A link to this page', window.location.href)
"
> >
<font-awesome icon="link" class="text-slate-500" /> <font-awesome icon="link" class="text-slate-500" />
</button> </button>
@@ -405,7 +416,7 @@
contacts can see more details: contacts can see more details:
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a >click to copy this page info</a
> >
and see if they can make an introduction. Someone is connected to and see if they can make an introduction. Someone is connected to
@@ -428,7 +439,7 @@
If you'd like an introduction, If you'd like an introduction,
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about >share this page with them and ask if they'll tell you more about
about the participants.</a about the participants.</a
> >
@@ -546,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces"; import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index"; import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil"; import { logConsoleAndDb } from "../db/databaseUtil";
@@ -593,8 +604,9 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {}; veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href; windowDeepLink = window.location.href; // changed in the setup for deep linking
APP_SERVER = APP_SERVER;
R = R; R = R;
yaml = yaml; yaml = yaml;
libsUtil = libsUtil; libsUtil = libsUtil;
@@ -671,6 +683,7 @@ export default class ClaimView extends Vue {
5000, 5000,
); );
} }
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
this.canShare = !!navigator.share; this.canShare = !!navigator.share;
} }
@@ -1006,11 +1019,11 @@ export default class ClaimView extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation); this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.windowDeepLink,
}); });
} }

View File

@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {}; veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href; windowLocation = window.location.href; // this is changed to a deep link in the setup
R = R; R = R;
yaml = yaml; yaml = yaml;
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
} }
const claimId = decodeURIComponent(pathParam); const claimId = decodeURIComponent(pathParam);
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
await this.loadClaim(claimId, this.activeDid); await this.loadClaim(claimId, this.activeDid);
} }
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
/** /**
* Add participant (giver/recipient) name & URL info * Add participant (giver/recipient) name & URL info
*/ */
this.giverName = this.didInfo(this.giveDetails?.agentDid);
if (this.giveDetails?.agentDid) { if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`; this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
} }
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
if (this.giveDetails?.recipientDid) { if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`; this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
} }

View File

@@ -4,14 +4,14 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="text-2xl text-center font-semibold relative px-7">
<!-- Back --> <!-- Back -->
<router-link <router-link
:to="{ name: 'home' }" :to="{ name: 'home' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome> ><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link> </router-link>
Given by... {{ stepType === "giver" ? "Given by..." : "Given to..." }}
</h1> </h1>
</div> </div>
@@ -19,19 +19,18 @@
<ul class="border-t border-slate-300"> <ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3"> <li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow"> <span class="grow flex gap-2 items-center font-medium">
<img <font-awesome
src="../assets/blank-square.svg" icon="circle-question"
width="32" class="text-slate-400 text-4xl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/> />
Unnamed/Unknown <span class="italic text-slate-400">(Unnamed/Unknown)</span>
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
type="button" type="button"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md" class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog()" @click="openDialog('Unnamed')"
> >
<font-awesome icon="gift" class="fa-fw"></font-awesome> <font-awesome icon="gift" class="fa-fw"></font-awesome>
</button> </button>
@@ -44,13 +43,14 @@
class="border-b border-slate-300 py-3" class="border-b border-slate-300 py-3"
> >
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold"> <span class="grow flex gap-2 items-center font-medium">
<EntityIcon <EntityIcon
:contact="contact" :contact="contact"
:icon-size="32" :icon-size="34"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden"
/> />
{{ contact.name || "(no name)" }} <span v-if="contact.name">{{ contact.name }}</span>
<span v-else class="italic text-slate-400">(No name)</span>
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
@@ -65,7 +65,13 @@
</li> </li>
</ul> </ul>
<GiftedDialog ref="customDialog" :to-project-id="projectId" /> <GiftedDialog
ref="customDialog"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:show-projects="showProjects"
:is-from-project-view="isFromProjectView"
/>
</section> </section>
</template> </template>
@@ -97,6 +103,24 @@ export default class ContactGiftingView extends Vue {
description = ""; description = "";
projectId = ""; projectId = "";
prompt = ""; prompt = "";
recipientProjectName = "";
recipientProjectImage = "";
recipientProjectHandleId = "";
// New context parameters
stepType = "giver";
giverEntityType = "person" as "person" | "project";
recipientEntityType = "person" as "person" | "project";
giverProjectId = "";
giverProjectName = "";
giverProjectImage = "";
giverProjectHandleId = "";
giverDid = "";
recipientDid = "";
fromProjectId = "";
toProjectId = "";
showProjects = false;
isFromProjectView = false;
async created() { async created() {
try { try {
@@ -124,9 +148,41 @@ export default class ContactGiftingView extends Vue {
); );
} }
this.projectId = (this.$route.query["projectId"] as string) || ""; this.projectId =
(this.$route.query["recipientProjectId"] as string) || "";
this.recipientProjectName =
(this.$route.query["recipientProjectName"] as string) || "";
this.recipientProjectImage =
(this.$route.query["recipientProjectImage"] as string) || "";
this.recipientProjectHandleId =
(this.$route.query["recipientProjectHandleId"] as string) || "";
this.prompt = (this.$route.query["prompt"] as string) ?? this.prompt; this.prompt = (this.$route.query["prompt"] as string) ?? this.prompt;
// Read new context parameters
this.stepType = (this.$route.query["stepType"] as string) || "giver";
this.giverEntityType =
(this.$route.query["giverEntityType"] as "person" | "project") ||
"person";
this.recipientEntityType =
(this.$route.query["recipientEntityType"] as "person" | "project") ||
"person";
this.giverProjectId =
(this.$route.query["giverProjectId"] as string) || "";
this.giverProjectName =
(this.$route.query["giverProjectName"] as string) || "";
this.giverProjectImage =
(this.$route.query["giverProjectImage"] as string) || "";
this.giverProjectHandleId =
(this.$route.query["giverProjectHandleId"] as string) || "";
this.giverDid = (this.$route.query["giverDid"] as string) || "";
this.recipientDid = (this.$route.query["recipientDid"] as string) || "";
this.fromProjectId = (this.$route.query["fromProjectId"] as string) || "";
this.toProjectId = (this.$route.query["toProjectId"] as string) || "";
this.showProjects =
(this.$route.query["showProjects"] as string) === "true";
this.isFromProjectView =
(this.$route.query["isFromProjectView"] as string) === "true";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings & contacts:", err); logger.error("Error retrieving settings & contacts:", err);
@@ -144,17 +200,108 @@ export default class ContactGiftingView extends Vue {
} }
} }
openDialog(giver?: GiverReceiverInputInfo) { openDialog(contact?: GiverReceiverInputInfo | "Unnamed") {
const recipient = this.projectId if (contact === "Unnamed") {
? undefined // Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
: { did: this.activeDid, name: "you" }; let recipient: GiverReceiverInputInfo;
(this.$refs.customDialog as GiftedDialog).open( let giver: GiverReceiverInputInfo | undefined;
giver,
recipient, if (this.stepType === "giver") {
undefined, // We're selecting a giver, so recipient is either a project or the current user
"Given by " + (giver?.name || "someone not named"), if (this.recipientEntityType === "project") {
this.prompt, recipient = {
); did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
recipient = { did: this.activeDid, name: "You" };
}
giver = undefined; // Will be set to "Unnamed" in GiftedDialog
} else {
// We're selecting a recipient, so recipient is "Unnamed" and giver is preserved from context
recipient = { did: "", name: "Unnamed" };
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
// no did, because it's a project
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else if (this.giverDid) {
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
giver = { did: this.activeDid, name: "You" };
}
}
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
this.stepType === "giver" ? "Given by Unnamed" : "Given to Unnamed",
this.prompt,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.customDialog as GiftedDialog).selectGiver();
} else {
// Regular case: contact is a GiverReceiverInputInfo
let giver: GiverReceiverInputInfo;
let recipient: GiverReceiverInputInfo;
if (this.stepType === "giver") {
// We're selecting a giver, so the contact becomes the giver
giver = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
// Recipient is either a project or the current user
if (this.recipientEntityType === "project") {
recipient = {
did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
recipient = { did: this.activeDid, name: "You" };
}
} else {
// We're selecting a recipient, so the contact becomes the recipient
recipient = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
did: this.giverProjectHandleId,
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else if (this.giverDid) {
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
giver = { did: this.activeDid, name: "You" };
}
}
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
this.stepType === "giver"
? "Given by " + (contact?.name || "someone not named")
: "Given to " + (contact?.name || "someone not named"),
this.prompt,
);
}
} }
} }
</script> </script>

View File

@@ -124,12 +124,14 @@ import * as databaseUtil from "../db/databaseUtil";
import { import {
CONTACT_CSV_HEADER, CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
setVisibilityUtil, setVisibilityUtil,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import { retrieveAccountMetadata } from "../libs/util"; import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil"; import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@@ -157,6 +159,7 @@ export default class ContactQRScanFull extends Vue {
apiServer = ""; apiServer = "";
givenName = ""; givenName = "";
isRegistered = false; isRegistered = false;
profileImageUrl = "";
qrValue = ""; qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX; ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -179,6 +182,7 @@ export default class ContactQRScanFull extends Vue {
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid); const account = await retrieveAccountMetadata(this.activeDid);
if (account) { if (account) {
@@ -588,9 +592,19 @@ export default class ContactQRScanFull extends Vue {
); );
} }
onCopyUrlToClipboard() { async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard() useClipboard()
.copy(this.qrValue) .copy(jwtUrl)
.then(() => { .then(() => {
this.$notify( this.$notify(
{ {

View File

@@ -177,6 +177,7 @@ import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { import {
CONTACT_CSV_HEADER, CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register, register,
setVisibilityUtil, setVisibilityUtil,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
@@ -187,6 +188,7 @@ import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types"; import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Account } from "@/db/tables/accounts";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@@ -216,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false; isRegistered = false;
qrValue = ""; qrValue = "";
isScanning = false; isScanning = false;
profileImageUrl = "";
error: string | null = null; error: string | null = null;
// QR Scanner properties // QR Scanner properties
@@ -253,6 +256,7 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact; !!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await libsUtil.retrieveAccountMetadata(this.activeDid); const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
if (account) { if (account) {
@@ -667,10 +671,19 @@ export default class ContactQRScanShow extends Vue {
}); });
} }
onCopyUrlToClipboard() { async onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard() useClipboard()
.copy(this.qrValue) .copy(jwtUrl)
.then(() => { .then(() => {
this.$notify( this.$notify(
{ {

View File

@@ -126,7 +126,6 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
v-if="showGiveNumbers" v-if="showGiveNumbers"
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="showGiveAmountsClassNames()" :class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()" @click="toggleShowGiveTotals()"
@@ -142,7 +141,6 @@
</button> </button>
<button <button
href=""
class="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-3 py-1.5 rounded-md" class="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-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()" @click="toggleShowContactAmounts()"
> >
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
private async processContactJwt() { private async processContactJwt() {
// handle a contact sent via URL // handle a contact sent via URL
// //
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts // For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms. // because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string; const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) { if (importedContactJwt) {
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
title: "Error with Invite", title: "Error with Invite",
text: message, text: message,
}, },
5000, -1,
); );
} }
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter // if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
@@ -1124,7 +1122,7 @@ export default class ContactsView extends Vue {
(regResult.error as string) || (regResult.error as string) ||
"Something went wrong during registration.", "Something went wrong during registration.",
}, },
5000, -1,
); );
} }
} catch (error) { } catch (error) {
@@ -1158,7 +1156,7 @@ export default class ContactsView extends Vue {
title: "Registration Error", title: "Registration Error",
text: userMessage, text: userMessage,
}, },
5000, -1,
); );
} }
} }
@@ -1397,7 +1395,8 @@ export default class ContactsView extends Vue {
const contactsJwt = await createEndorserJwtForDid(this.activeDid, { const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts, contacts: selectedContacts,
}); });
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt; const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard() useClipboard()
.copy(contactsJwtUrl) .copy(contactsJwtUrl)
.then(() => { .then(() => {

View File

@@ -77,6 +77,7 @@
@click="confirmSetVisibility(contactFromDid, false)" @click="confirmSetVisibility(contactFromDid, false)"
> >
<font-awesome icon="eye" class="fa-fw" /> <font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button> </button>
<button <button
v-else-if=" v-else-if="
@@ -87,6 +88,32 @@
@click="confirmSetVisibility(contactFromDid, true)" @click="confirmSetVisibility(contactFromDid, true)"
> >
<font-awesome icon="eye-slash" class="fa-fw" /> <font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-if="
contactFromDid?.iViewContent &&
contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.iViewContent &&
contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button> </button>
<button <button
@@ -825,9 +852,9 @@ export default class DIDView extends Vue {
title: "Visibility Refreshed", title: "Visibility Refreshed",
text: text:
libsUtil.nameForContact(contact, true) + libsUtil.nameForContact(contact, true) +
" can " + " can" +
(visibility ? "" : "not ") + (visibility ? "" : " not") +
"see your activity.", " see your activity.",
}, },
3000, 3000,
); );
@@ -857,6 +884,64 @@ export default class DIDView extends Vue {
); );
} }
} }
/**
* Confirm whether the user want to see/hide the other's content, then execute it
*
* @param contact Contact content to show/hide from user
* @param view whether user wants to view this contact
*/
async confirmViewContent(contact: Contact, view: boolean) {
const contentVisibilityPrompt = view
? "Are you sure you want to see their content?"
: "Are you sure you want to hide their content from you?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Content Visibility",
text: contentVisibilityPrompt,
onYes: async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
}
},
},
-1,
);
}
/**
* Updates contact content visibility for this device
*
* @param contact - Contact to update content visibility for
* @param visibility - New content visibility state
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET iViewContent = ? WHERE did = ?",
[visibility, contact.did],
);
if (USE_DEXIE_DB) {
db.contacts.update(contact.did, { iViewContent: visibility });
}
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
"You will" +
(visibility ? "" : " not") +
` see ${contact.name}'s activity.`,
},
3000,
);
return true;
}
} }
</script> </script>

View File

@@ -102,7 +102,7 @@
icon-name="chart" icon-name="chart"
svg-class="-ml-1 mr-3 h-5 w-5" svg-class="-ml-1 mr-3 h-5 w-5"
/> />
Download Account Show Account Seed
</button> </button>
<button <button
@@ -1122,6 +1122,7 @@ export default class DatabaseMigration extends Vue {
private loadingMessage = ""; private loadingMessage = "";
private error = ""; private error = "";
private warning = ""; private warning = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private exportedData: Record<string, any> | null = null; private exportedData: Record<string, any> | null = null;
private successMessage = ""; private successMessage = "";
@@ -1134,6 +1135,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} setting - The setting object * @param {any} setting - The setting object
* @returns {string} The display name for the setting * @returns {string} The display name for the setting
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getSettingDisplayName(setting: any): string { getSettingDisplayName(setting: any): string {
// Handle exported JSON format (has 'type' and 'did' fields) // Handle exported JSON format (has 'type' and 'did' fields)
if (setting.type && setting.did) { if (setting.type && setting.did) {
@@ -1153,6 +1155,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} account - The account object * @param {any} account - The account object
* @returns {boolean} True if account has identity * @returns {boolean} True if account has identity
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAccountHasIdentity(account: any): boolean { getAccountHasIdentity(account: any): boolean {
// Handle exported JSON format (has 'hasIdentity' field) // Handle exported JSON format (has 'hasIdentity' field)
if (account.hasIdentity !== undefined) { if (account.hasIdentity !== undefined) {
@@ -1170,6 +1173,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} account - The account object * @param {any} account - The account object
* @returns {boolean} True if account has mnemonic * @returns {boolean} True if account has mnemonic
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAccountHasMnemonic(account: any): boolean { getAccountHasMnemonic(account: any): boolean {
// Handle exported JSON format (has 'hasMnemonic' field) // Handle exported JSON format (has 'hasMnemonic' field)
if (account.hasMnemonic !== undefined) { if (account.hasMnemonic !== undefined) {

View File

@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
const path = originalPath.value.replace(/^\/+/, ""); const path = originalPath.value.replace(/^\/+/, "");
// Log for debugging // Log for debugging
logger.log("Original Path:", originalPath.value); logger.log(
logger.log("Route Params:", route.params); "[DeepLinkError] Original Path:",
logger.log("Route Query:", route.query); originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
return path; return path;
}); });

View File

@@ -0,0 +1,227 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">
Redirecting to Time Safari
</h1>
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
</a>
</div>
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { APP_SERVER } from "@/constants/app";
import { logger } from "@/utils/logger";
import { errorStringForLog } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({})
export default class DeepLinkRedirectView extends Vue {
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
pageError: string | null = null;
destinationUrl: string | null = null; // full path after "/deep-link/"
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
isDevelopment: boolean = false;
userAgent: string = "";
private platformService = PlatformServiceFactory.getInstance();
mounted() {
// Get the path from the route parameter (catch-all parameter)
const pathParam = this.$route.params.path;
// If pathParam is an array (catch-all parameter), join it
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
// Get query parameters from the route
const queryParams = this.$route.query;
// Build query string if there are query parameters
let queryString = "";
if (Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
const stringValue = Array.isArray(value) ? value[0] : value;
if (stringValue !== null && stringValue !== undefined) {
searchParams.append(key, stringValue);
}
}
});
queryString = "?" + searchParams.toString();
}
// Combine path with query parameters
const fullPathWithQuery = fullPath + queryString;
this.destinationUrl = fullPathWithQuery;
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
this.isDevelopment = process.env.NODE_ENV !== "production";
this.userAgent = navigator.userAgent;
this.openDeepLink();
}
private openDeepLink() {
if (!this.deepLinkUrl || !this.webUrl) {
this.pageError =
"No deep link was provided. Check the URL and try again.";
return;
}
try {
// For mobile, try the deep link URL; for desktop, use the web URL
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
// Method 1: Try window.location.href (works on most browsers)
window.location.href = redirectUrl;
// Method 2: Fallback - create and click a link element
setTimeout(() => {
try {
const link = document.createElement("a");
link.href = redirectUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
logger.error(
"Fallback deep link failed: " + errorStringForLog(error),
);
this.pageError =
"Redirecting to the Time Safari app failed. Please use a manual option below.";
}
}, 100);
} catch (error) {
logger.error("Deep link redirect failed: " + errorStringForLog(error));
this.pageError =
"Unable to open the Time Safari app. Please use a manual option below.";
}
}
private handleDeepLinkClick(event: Event) {
if (!this.deepLinkUrl) return;
// Prevent default to handle the click manually
event.preventDefault();
this.openDeepLink();
}
private handleWebFallbackClick(event: Event) {
if (!this.webUrl) return;
// Get platform capabilities
const capabilities = this.platformService.getCapabilities();
// For mobile, try to open in a new tab/window
if (capabilities.isMobile) {
event.preventDefault();
window.open(this.webUrl, "_blank");
}
// For desktop, let the default behavior happen (opens in same tab)
}
// Computed properties for template
get isMobile(): boolean {
return this.platformService.getCapabilities().isMobile;
}
get isIOS(): boolean {
return this.platformService.getCapabilities().isIOS;
}
}
</script>

View File

@@ -4,84 +4,91 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <!-- Breadcrumb -->
<div <div id="ViewBreadcrumb" class="mb-8">
v-if="!hideBackButton" <h1 class="text-2xl text-center font-semibold relative px-7 mb-2">
class="text-lg text-center font-light relative px-7" <!-- Back -->
> <div
<h1 v-if="!hideBackButton"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()" @click="cancelBack()"
> >
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome> <font-awesome icon="chevron-left" class="fa-fw" />
</div>
What Was Given
</h1> </h1>
<h2 class="text-lg font-normal text-center overflow-hidden">
<div class="truncate">
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</div>
<div class="truncate">
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone not named"
}}
</div>
</h2>
</div> </div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</span>
<br />
<span>
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone not named"
}}</span
>
</h1>
<textarea <textarea
v-model="description" v-model="description"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received" placeholder="What was received"
/> />
<div class="flex flex-row justify-center"> <div class="flex mb-4">
<span <button
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20" class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()" @click="amountInput === '0' ? null : decrement()"
> >
<font-awesome icon="chevron-left" /> <font-awesome icon="chevron-left" />
</div> </button>
<input <input
id="inputGivenAmount"
v-model="amountInput" v-model="amountInput"
type="number" type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]"
/> />
<div <button
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2" class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()" @click="increment()"
> >
<font-awesome icon="chevron-right" /> <font-awesome icon="chevron-right" />
</div> </button>
<select
v-model="unitCode"
class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"
>
<option
v-for="(displayName, code) in unitOptions"
:key="code"
:value="code"
>
{{ displayName }}
</option>
</select>
</div> </div>
<div class="flex justify-center mt-4" data-testId="imagery"> <div class="flex justify-center mt-4" data-testId="imagery">
<span v-if="imageUrl" class="flex justify-between"> <span v-if="imageUrl" class="flex items-end gap-3">
<a :href="imageUrl" target="_blank"> <a :href="imageUrl" target="_blank">
<img :src="imageUrl" class="h-24 rounded-xl" /> <img :src="imageUrl" class="h-36 rounded-lg" />
</a> </a>
<font-awesome <font-awesome
icon="trash-can" icon="trash-can"
class="text-red-500 fa-fw ml-8 mt-10" class="text-red-500 fa-fw cursor-pointer"
@click="confirmDeleteImage" @click="confirmDeleteImage"
/> />
</span> </span>
@@ -95,22 +102,22 @@
</div> </div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" /> <ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<div class="mt-4 flex justify-between gap-2"> <div class="mt-4 sm:flex justify-between gap-2">
<!-- First Column for Giver --> <!-- First Column for Giver -->
<div class="flex-grow border border-slate-400 p-2 rounded-md"> <div class="sm:flex-grow sm:w-1/2 border border-slate-400 p-2 rounded-md overflow-hidden">
<div class="flex"> <div class="flex items-center">
<input <input
v-if="giverDid && !providedByProject" v-if="giverDid && !providedByProject"
v-model="providedByGiver" v-model="providedByGiver"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="flex-shrink-0 h-6 w-6 mr-2"
/> />
<font-awesome <font-awesome
v-else v-else
icon="square" icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm" class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/> />
<label class="text-sm mt-1"> <label class="text-sm truncate">
{{ {{
giverDid giverDid
? "This was provided by " + giverName + "." ? "This was provided by " + giverName + "."
@@ -120,24 +127,24 @@
<font-awesome <font-awesome
v-if="!giverDid || providedByProject" v-if="!giverDid || providedByProject"
icon="info-circle" icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm" class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfGiver()" @click="notifyUserOfGiver()"
/> />
</div> </div>
<div class="flex"> <div class="flex items-center">
<input <input
v-if="providerProjectId && !providedByGiver" v-if="providerProjectId && !providedByGiver"
v-model="providedByProject" v-model="providedByProject"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="flex-shrink-0 h-6 w-6 mr-2"
/> />
<font-awesome <font-awesome
v-else v-else
icon="square" icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm" class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/> />
<label class="text-sm mt-1"> <label class="text-sm truncate">
{{ {{
providerProjectId providerProjectId
? "This was provided by " + providerProjectName + "." ? "This was provided by " + providerProjectName + "."
@@ -147,31 +154,31 @@
<font-awesome <font-awesome
v-if="!providerProjectId || providedByGiver" v-if="!providerProjectId || providedByGiver"
icon="info-circle" icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm" class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfProvidingProject()" @click="notifyUserOfProvidingProject()"
/> />
</div> </div>
</div> </div>
<div class="flex-shrink flex justify-center items-center"> <div class="sm:flex-shrink flex justify-center items-center my-1 sm:my-0">
<font-awesome icon="arrow-right" class="fa-fw h-7" /> <font-awesome icon="arrow-right" class="fa-fw h-7 rotate-90 sm:rotate-0" />
</div> </div>
<!-- Third Column for Recipient --> <!-- Third Column for Recipient -->
<div class="flex-grow border border-slate-400 p-2 rounded-md"> <div class="sm:flex-grow sm:w-1/2 border border-slate-400 p-2 rounded-md overflow-hidden">
<div class="flex"> <div class="flex items-center">
<input <input
v-if="recipientDid && !givenToProject" v-if="recipientDid && !givenToProject"
v-model="givenToRecipient" v-model="givenToRecipient"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="flex-shrink-0 h-6 w-6 mr-2"
/> />
<font-awesome <font-awesome
v-else v-else
icon="square" icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm" class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/> />
<label class="text-sm mt-1"> <label class="text-sm truncate">
{{ {{
recipientDid recipientDid
? "This was given to " + recipientName + "." ? "This was given to " + recipientName + "."
@@ -181,24 +188,24 @@
<font-awesome <font-awesome
v-if="!recipientDid || givenToProject" v-if="!recipientDid || givenToProject"
icon="info-circle" icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm" class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfRecipient()" @click="notifyUserOfRecipient()"
/> />
</div> </div>
<div class="flex"> <div class="flex items-center">
<input <input
v-if="fulfillsProjectId && !givenToRecipient" v-if="fulfillsProjectId && !givenToRecipient"
v-model="givenToProject" v-model="givenToProject"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="flex-shrink-0 h-6 w-6 mr-2"
/> />
<font-awesome <font-awesome
v-else v-else
icon="square" icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm" class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/> />
<label class="text-sm mt-1"> <label class="text-sm truncate">
{{ {{
fulfillsProjectId fulfillsProjectId
? "This was given to " + fulfillsProjectName + ". " ? "This was given to " + fulfillsProjectName + ". "
@@ -208,7 +215,7 @@
<font-awesome <font-awesome
v-if="!fulfillsProjectId || givenToRecipient" v-if="!fulfillsProjectId || givenToRecipient"
icon="info-circle" icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm" class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserFulfillsProject()" @click="notifyUserFulfillsProject()"
/> />
</div> </div>
@@ -229,11 +236,11 @@
</router-link> </router-link>
</div> </div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center text-sm my-4">
Sign & Send to publish to the world <b class="font-medium">Sign &amp; Send</b> to publish to the world
<font-awesome <font-awesome
icon="circle-info" icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer" class="fa-fw text-blue-500 text-base cursor-pointer"
@click="explainData()" @click="explainData()"
/> />
</p> </p>
@@ -910,5 +917,10 @@ export default class GiftedDetails extends Vue {
7000, 7000,
); );
} }
// Computed property to get unit options
get unitOptions() {
return this.libsUtil.UNIT_SHORT;
}
} }
</script> </script>

View File

@@ -12,6 +12,7 @@ Raymer * @version 1.0.0 */
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
{{ AppString.APP_NAME }} {{ AppString.APP_NAME }}
<span class="text-xs text-gray-500">{{ package.version }}</span>
</h1> </h1>
<OnboardingDialog ref="onboardingDialog" /> <OnboardingDialog ref="onboardingDialog" />
@@ -117,101 +118,73 @@ Raymer * @version 1.0.0 */
</div> </div>
<div v-else id="sectionRecordSomethingGiven"> <div v-else id="sectionRecordSomethingGiven">
<!-- !isCreatingIdentifier && isRegistered --> <!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="text-xl font-bold">Record something given by:</h2>
<button
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openGiftedPrompts()"
>
<font-awesome
icon="lightbulb"
class="block text-center w-[1em]"
/>
</button>
</div>
<!-- show the actions for recognizing a give --> <div class="grid grid-cols-2 gap-2">
<div class="flex"> <button
<h2 class="text-xl font-bold">What have you seen someone do?</h2> type="button"
<button class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
class="ml-2 block text-xs text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md" @click="openDialogPerson()"
@click="openGiftedPrompts()" >
> <font-awesome icon="user" />
<font-awesome icon="lightbulb" class="fa-fw" /> Person
</button> </button>
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" />
Project
</button>
</div>
</div> </div>
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mt-4"
>
<li @click="openDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
Unnamed/Unknown
</h3>
</li>
<li v-if="allContacts.length === 0" class="text-sm">
(Add friends to see more people worthy of recognition.)
</li>
<li
v-for="contact in allContacts.slice(0, 6)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:contact="contact"
:icon-size="64"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
{{ contact.name || contact.did }}
</h3>
</li>
<li>
<router-link
v-if="allContacts.length >= 6"
:to="{ name: 'contact-gift' }"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
>
... or someone else...
</router-link>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<GiftedDialog ref="customDialog" /> <GiftedDialog ref="customDialog" :show-projects="showProjectsDialog" />
<GiftedPrompts ref="giftedPrompts" /> <GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" /> <FeedFilters ref="feedFilters" />
<div class="relative">
<button
v-if="isRegistered"
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="openDialog()"
>
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
<!-- Results List --> <!-- Results List -->
<div class="mt-4 mb-4"> <div class="mt-4 mb-4">
<div class="flex items-center mb-4"> <div class="flex gap-2 items-center mb-3">
<h2 class="text-xl font-bold flex items-center gap-4"> <h2 class="text-xl font-bold">Latest Activity</h2>
Latest Activity <button
<button v-if="resultsAreFiltered()"
v-if="resultsAreFiltered()" class="block ms-auto 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-2 rounded-full"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white" @click="openFeedFilters()"
@click="openFeedFilters()" >
> <font-awesome
<font-awesome icon="filter" class="fa-fw" /> icon="filter"
</button> class="block text-center w-[1em] translate-y-[0.05em]"
<button />
v-else </button>
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white" <button
@click="openFeedFilters()" v-else
> class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
<font-awesome icon="filter" class="fa-fw" /> @click="openFeedFilters()"
</button> >
</h2> <font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
</div> </div>
<div <div
@@ -353,6 +326,7 @@ import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../interfaces/give"; import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import * as Package from "../../package.json";
interface Claim { interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials claim?: Claim; // For nested claims in Verifiable Credentials
@@ -443,11 +417,13 @@ export default class HomeView extends Vue {
AppString = AppString; AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED; PASSKEYS_ENABLED = PASSKEYS_ENABLED;
package = Package;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
blockedContactDids: Array<string> = [];
feedData: GiveRecordWithContactInfo[] = []; feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string; feedPreviousOldestId?: string;
feedLastViewedClaimId?: string; feedLastViewedClaimId?: string;
@@ -474,6 +450,7 @@ export default class HomeView extends Vue {
selectedImageData: Blob | null = null; selectedImageData: Blob | null = null;
isImageViewerOpen = false; isImageViewerOpen = false;
imageCache: Map<string, Blob | null> = new Map(); imageCache: Map<string, Blob | null> = new Map();
showProjectsDialog = false;
/** /**
* Initializes the component on mount * Initializes the component on mount
@@ -519,7 +496,6 @@ export default class HomeView extends Vue {
// Retrieve DIDs with better error handling // Retrieve DIDs with better error handling
try { try {
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
} catch (error) { } catch (error) {
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true); logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
throw new Error( throw new Error(
@@ -552,9 +528,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount(); settings = await retrieveSettingsForActiveAccount();
} }
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`, `[HomeView] Failed to retrieve settings: ${error}`,
@@ -571,25 +544,14 @@ export default class HomeView extends Vue {
// Load contacts with graceful fallback // Load contacts with graceful fallback
try { try {
const platformService = PlatformServiceFactory.getInstance(); this.loadContacts();
const dbContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbContacts,
) as Contact[];
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`, `[HomeView] Failed to retrieve contacts: ${error}`,
true, true,
); );
this.allContacts = []; // Ensure we have a valid empty array this.allContacts = []; // Ensure we have a valid empty array
this.blockedContactDids = [];
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -641,9 +603,6 @@ export default class HomeView extends Vue {
}); });
} }
this.isRegistered = true; this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
@@ -685,11 +644,6 @@ export default class HomeView extends Vue {
this.newOffersToUserHitLimit = offersToUser.hitLimit; this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length; this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit; this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
logConsoleAndDb(
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
`${this.numNewOffersToUserProjects} project offers`,
);
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
@@ -761,6 +715,9 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
} }
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.map((c) => c.did);
} }
/** /**
@@ -1028,6 +985,7 @@ export default class HomeView extends Vue {
); );
if (results.data.length > 0) { if (results.data.length > 0) {
endOfResults = false; endOfResults = false;
// gather any contacts that user has blocked from view
await this.processFeedResults(results.data); await this.processFeedResults(results.data);
await this.updateFeedLastViewedId(results.data); await this.updateFeedLastViewedId(results.data);
} }
@@ -1215,7 +1173,7 @@ export default class HomeView extends Vue {
} }
/** /**
* Checks if record should be included based on filters * Checks if record should be included based on filters & preferences
* *
* @internal * @internal
* @callGraph * @callGraph
@@ -1241,6 +1199,10 @@ export default class HomeView extends Vue {
record: GiveSummaryRecord, record: GiveSummaryRecord,
fulfillsPlan?: FulfillsPlan, fulfillsPlan?: FulfillsPlan,
): boolean { ): boolean {
if (this.blockedContactDids.includes(record.issuerDid)) {
return false;
}
if (!this.isAnyFeedFilterOn) { if (!this.isAnyFeedFilterOn) {
return true; return true;
} }
@@ -1637,17 +1599,33 @@ export default class HomeView extends Vue {
* @param giver Optional contact info for giver * @param giver Optional contact info for giver
* @param description Optional gift description * @param description Optional gift description
*/ */
openDialog(giver?: GiverReceiverInputInfo, description?: string) { openDialog(giver?: GiverReceiverInputInfo | "Unnamed", description?: string) {
(this.$refs.customDialog as GiftedDialog).open( if (giver === "Unnamed") {
giver, // Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
{ (this.$refs.customDialog as GiftedDialog).open(
did: this.activeDid, undefined,
name: "you", {
} as GiverReceiverInputInfo, did: this.activeDid,
undefined, name: "You",
"Given by " + (giver?.name || "someone not named"), } as GiverReceiverInputInfo,
description, undefined,
); "Given by Unnamed",
description,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.customDialog as GiftedDialog).selectGiver();
} else {
(this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
"Given by " + (giver?.name || "someone not named"),
description,
);
}
} }
/** /**
@@ -1881,5 +1859,18 @@ export default class HomeView extends Vue {
this.$router.push({ name: "contact-qr" }); this.$router.push({ name: "contact-qr" });
} }
} }
openDialogPerson(
giver?: GiverReceiverInputInfo | "Unnamed",
description?: string,
) {
this.showProjectsDialog = false;
this.openDialog(giver, description);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.customDialog as any).open();
}
} }
</script> </script>

View File

@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
} }
inviteLink(jwt: string): string { inviteLink(jwt: string): string {
return APP_SERVER + "/invite-one-accept/" + jwt; return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
} }
copyInviteAndNotify(inviteId: string, jwt: string) { copyInviteAndNotify(inviteId: string, jwt: string) {

View File

@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
onboardMeetingMembersLink(): string { onboardMeetingMembersLink(): string {
if (this.currentMeeting) { if (this.currentMeeting) {
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent( return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "", this.currentMeeting?.password || "",
)}`; )}`;
} }

View File

@@ -27,6 +27,12 @@
> >
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> <font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button> </button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</h2> </h2>
</div> </div>
</div> </div>
@@ -55,7 +61,11 @@
<span class="truncate inline-block max-w-[calc(100%-2rem)]"> <span class="truncate inline-block max-w-[calc(100%-2rem)]">
{{ issuerInfoObject?.displayName }} {{ issuerInfoObject?.displayName }}
</span> </span>
<span class="inline-flex items-center">
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
>
<router-link <router-link
:to="{ :to="{
path: '/did/' + encodeURIComponent(issuer), path: '/did/' + encodeURIComponent(issuer),
@@ -113,7 +123,7 @@
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
></font-awesome> ></font-awesome>
<a <a
:href="addScheme(url)" :href="ensureScheme(url)"
target="_blank" target="_blank"
class="underline text-blue-500" class="underline text-blue-500"
> >
@@ -204,63 +214,11 @@
</div> </div>
</div> </div>
<div v-if="activeDid && isRegistered"> <GiftedDialog
<div class="text-center"> ref="giveDialogToThis"
<p class="mt-2 mt-4 text-center">Record a contribution from:</p> :to-project-id="projectId"
</div> :is-from-project-view="true"
<ul />
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
>
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
<font-awesome
icon="hand"
class="fa-fw text-blue-500 text-5xl cursor-pointer"
/>
<h3
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
You
</h3>
</li>
<li @click="openGiftDialogToProject()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 5)"
:key="contact.did"
@click="openGiftDialogToProject(contact)"
>
<EntityIcon
:contact="contact"
:icon-size="64"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
{{ contact.name || "(no name)" }}
</h3>
</li>
<li>
<span
v-if="allContacts.length >= 5"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
@click="onClickAllContactsGifting()"
>
... or someone else...
</span>
</li>
</ul>
</div>
<GiftedDialog ref="giveDialogToThis" :to-project-id="projectId" />
<!-- Offers & Gifts to & from this --> <!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> <div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
@@ -526,7 +484,12 @@
</button> </button>
</div> </div>
</div> </div>
<GiftedDialog ref="giveDialogFromThis" :from-project-id="projectId" /> <GiftedDialog
ref="giveDialogFromThis"
:from-project-id="projectId"
:show-projects="true"
:is-from-project-view="true"
/>
<h3 class="text-lg font-bold mb-3 mt-4"> <h3 class="text-lg font-bold mb-3 mt-4">
Benefitted From This Project Benefitted From This Project
@@ -632,7 +595,7 @@ import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { import {
db, db,
@@ -646,6 +609,7 @@ import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue"; import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { useClipboard } from "@vueuse/core";
/** /**
* Project View Component * Project View Component
* @author Matthew Raymer * @author Matthew Raymer
@@ -842,6 +806,28 @@ export default class ProjectViewView extends Vue {
}); });
} }
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this project was copied to the clipboard.",
},
2000,
);
});
}
// Isn't there a better way to make this available to the template? // Isn't there a better way to make this available to the template?
expandText() { expandText() {
this.expanded = true; this.expanded = true;
@@ -1237,21 +1223,52 @@ export default class ProjectViewView extends Vue {
); );
} }
openGiftDialogToProject(contact?: libsUtil.GiverReceiverInputInfo) { openGiftDialogToProject(
(this.$refs.giveDialogToThis as GiftedDialog).open( contact?: libsUtil.GiverReceiverInputInfo | "Unnamed",
contact, ) {
undefined, if (contact === "Unnamed") {
undefined, // Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
(contact?.name || "Someone not named") + ` gave to this project`, (this.$refs.giveDialogToThis as GiftedDialog).open(
); undefined,
undefined,
undefined,
"Given by Unnamed to this project",
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.giveDialogToThis as GiftedDialog).selectGiver();
} else {
// Open straight to Step 2 with current user as giver and current project as recipient
(this.$refs.giveDialogToThis as GiftedDialog).open(
{
did: this.activeDid,
name: "You",
},
{
did: this.issuer,
name: this.name,
handleId: this.projectId,
image: this.imageUrl,
},
undefined,
`Given to ${this.name}`,
);
}
} }
openGiftDialogFromProject() { openGiftDialogFromProject() {
// Set the project as giver and the current user as recipient
(this.$refs.giveDialogFromThis as GiftedDialog).open( (this.$refs.giveDialogFromThis as GiftedDialog).open(
undefined, {
did: undefined,
name: this.name,
handleId: this.projectId,
image: this.imageUrl,
},
{ did: this.activeDid, name: "You" }, { did: this.activeDid, name: "You" },
undefined, undefined,
`This project gave to you`, `${this.name} gave to you`,
undefined,
undefined,
); );
} }
@@ -1304,7 +1321,7 @@ export default class ProjectViewView extends Vue {
} }
// return an HTTPS URL if it's not a global URL // return an HTTPS URL if it's not a global URL
addScheme(url: string) { ensureScheme(url: string) {
if (!libsUtil.isGlobalUri(url)) { if (!libsUtil.isGlobalUri(url)) {
return "https://" + url; return "https://" + url;
} }
@@ -1465,7 +1482,13 @@ export default class ProjectViewView extends Vue {
} }
openHiddenDidDialog() { openHiddenDidDialog() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
(this.$refs.hiddenDidDialog as HiddenDidDialog).open( (this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"project/" + shortestProjectId,
"creator", "creator",
this.issuerVisibleToDids, this.issuerVisibleToDids,
this.allContacts, this.allContacts,

View File

@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Copied", title: "Copied",
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.", text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
}, },
5000, 5000,
); );

View File

@@ -16,6 +16,7 @@
</button> </button>
Individual Profile Individual Profile
</h1> </h1>
<div class="text-sm text-center text-slate-500"></div>
</div> </div>
<!-- Loading Animation --> <!-- Loading Animation -->
@@ -32,6 +33,12 @@
<div class="text-sm"> <div class="text-sm">
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome> <font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }} {{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</div> </div>
<p v-if="profile.description" class="mt-4 text-slate-600"> <p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }} {{ profile.description }}
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import { import {
APP_SERVER,
DEFAULT_PARTNER_API_SERVER, DEFAULT_PARTNER_API_SERVER,
NotificationIface, NotificationIface,
USE_DEXIE_DB, USE_DEXIE_DB,
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Settings } from "@/db/tables/settings"; import { Settings } from "@/db/tables/settings";
import { useClipboard } from "@vueuse/core";
@Component({ @Component({
components: { components: {
LMap, LMap,
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
if (response.status === 200) { if (response.status === 200) {
const result = await response.json(); const result = await response.json();
this.profile = result.data; this.profile = result.data;
if (this.profile && this.profile.rowId !== profileId) {
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
this.profile.rowId = profileId;
}
} else { } else {
throw new Error("Failed to load profile"); throw new Error("Failed to load profile");
} }
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
this.isLoading = false; this.isLoading = false;
} }
} }
onCopyLinkClick() {
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this profile was copied to the clipboard.",
},
2000,
);
});
}
} }
</script> </script>