Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4bf045049 | |||
| 1639e7cf25 | |||
| 8f2bebe8ae | |||
| 810f307442 | |||
| a4bdd2e922 | |||
| 08e1ce6486 | |||
| e88eea7f36 | |||
| ea156fac13 | |||
| a95d5db24a | |||
| 453256f874 | |||
| 7bf488d4fe | |||
| 230773a917 | |||
| 8a64da2b5f | |||
| cf1137737a | |||
| 82f51b6f93 | |||
| 83722e0057 | |||
| 3bb2498e28 | |||
| 80f05ba9e9 | |||
| bb555cd6ee | |||
| 1dd7c6e3b1 | |||
| e8423b1a00 | |||
| b4a521c6d4 | |||
| a64c7c2848 | |||
| 37907ee3ad | |||
| 43da8586e5 | |||
| 94443c93bc | |||
| 2a675eca6a | |||
| 94fb76cfdc | |||
| 7dde4d4d30 | |||
| 79d93994c2 | |||
| bab4a62540 | |||
| f84a2c2750 | |||
| 2321e1d6e8 | |||
| af976ba838 | |||
| d08541fdae | |||
| fa92beed27 | |||
| 9e1ae2abe5 | |||
| ad39ea05c2 | |||
| 151c8154c4 | |||
| 21a6348afc | |||
| 210605c8e4 | |||
| 33a340326f | |||
| 3f8596aacc | |||
| fd112bd447 | |||
| 7d6b210ee1 | |||
| 6c28828c0a | |||
| 6af239378c | |||
| 4ff7d908d4 | |||
| 17c901b1de | |||
| f7b5dbf4ce | |||
| 7f02ba29a3 | |||
| 20c4613533 | |||
| a44fc1d6d0 | |||
| b86543b404 | |||
| 7d0007e4d9 | |||
| ddd32e7f44 | |||
| 8a9bb100ea | |||
| c48b8246f9 | |||
| b32a3d85e9 | |||
| 8571c78a53 | |||
| eba68e2aaa | |||
| e2df848e96 | |||
| 9acba28b85 | |||
| bef56fce10 | |||
| fccc4edb63 | |||
| 0a42edf595 | |||
| f4f5fc7730 | |||
| eeaacaf202 | |||
| d9aebfebd3 | |||
| 7078f7b9e6 | |||
| d316f4924b | |||
| 1df2d3ed05 | |||
| 4e877c15f6 | |||
| ef95708d02 | |||
| 7cbdc7a099 | |||
| c748869c44 | |||
| 60e11e23d4 | |||
| 883687f1c3 | |||
| 4466ceed99 | |||
| 6d6e5266b4 | |||
| 581a374b05 | |||
| 1009574721 | |||
| 50cae65214 | |||
| 48a46cf6f1 | |||
| 60b2bf35fb | |||
| cb5a7135ac | |||
| a7a9e35766 | |||
| f029835e15 | |||
| 017a172df3 | |||
| 7837122a95 | |||
| 0093255246 | |||
| 30bd53fb6f | |||
| ca22930012 | |||
| c7c5bda014 | |||
| 19aa572c95 | |||
| 03fae5dd95 | |||
| 80818a8861 | |||
| d29a8d9637 | |||
| f0b0231515 | |||
| b73d2a3b58 | |||
| 22cba5babf | |||
| 708ac51f23 | |||
| a91ffc88b9 | |||
| d727c2841b | |||
| 226a97732d | |||
| c94dd7743b | |||
| 64e38cb8ff | |||
| e61ac31710 | |||
| 3fbf68b117 | |||
| d4390483d9 | |||
| 8dea2091af | |||
| e3696e3ac5 | |||
|
|
027825b155 | ||
| 911203c190 | |||
| 2da0394003 | |||
| 4a65d095db | |||
| 8ea5779312 | |||
| 144ab76716 | |||
|
|
8da2c8cc30 | ||
|
|
570b31e2d6 | ||
|
|
07f542ca16 | ||
|
|
62e0fc51c2 | ||
|
|
94b600e527 | ||
|
|
5388e6052c | ||
|
|
21fe5a0279 | ||
|
|
ffba89a7b5 | ||
|
|
31954d2690 | ||
| 340d0a5219 | |||
| 2d2785d6a0 | |||
| 41d6e5fc73 | |||
| 7412d67c33 | |||
| 83db5302ad | |||
| 75f9f20ea3 | |||
| e43c45ebea | |||
| 708032311a | |||
| 5dead960ae | |||
| 12d81b79c7 | |||
| f3dc81e6eb | |||
| ef5f81932d | |||
| 214a264179 | |||
| 9b183a4b6c | |||
| f365cc9e3c | |||
| 9059f7a9a7 | |||
| e6cd86618e | |||
| c3fd27b140 | |||
| cf2e800dec | |||
| b60383cfe9 | |||
| c7d93db6f2 | |||
| 5e771e4a24 | |||
| 4dd2c044d5 | |||
| 3bfd54362e | |||
|
|
b6e344a15e | ||
|
|
3d1c46aef8 | ||
| ce05f7d003 | |||
| 313cd79e60 | |||
| 121991b53a | |||
|
|
cbf8cb9f46 | ||
|
|
fe0668e4b3 | ||
| a230506d96 | |||
| c49c55d394 | |||
| ae572afff6 | |||
| ccea2486e4 | |||
| 155343a9d7 | |||
| 85ad295eb9 | |||
| 64322b2804 | |||
| 3e556dfa52 | |||
| 252952e017 | |||
| 251986d2bc | |||
| 49bb1c07b7 | |||
| 67f34f9826 | |||
| 476d35452a | |||
| 26582030df | |||
| ae857f4c8f | |||
| c602c5ce50 | |||
| e4543457e2 | |||
| c58f012d2c | |||
| 792e9cb648 | |||
| acee761906 | |||
| cae2bbc4ff | |||
|
|
a5c3600673 | ||
| 0eb64ed716 | |||
| f1bb1b51aa | |||
| 92b924643e | |||
| ca90447700 | |||
| 750700e75e | |||
| c696de33f3 |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
# I tried and failed to set things here with vue-cli-service but
|
||||||
|
# things may be more reliable with vite so let's try again.
|
||||||
4
.env.production
Normal file
4
.env.production
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Only the variables that start with VITE_ are seen in the application process.env in Vue.
|
||||||
|
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||||
|
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||||
|
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||||
@@ -2,6 +2,7 @@ module.exports = {
|
|||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
|
es2022: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"plugin:vue/vue3-essential",
|
"plugin:vue/vue3-essential",
|
||||||
@@ -9,9 +10,9 @@ module.exports = {
|
|||||||
"@vue/typescript/recommended",
|
"@vue/typescript/recommended",
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
],
|
],
|
||||||
parserOptions: {
|
// parserOptions: {
|
||||||
ecmaVersion: 2020,
|
// ecmaVersion: 2020,
|
||||||
},
|
// },
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
|||||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -5,12 +5,106 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
### Changed in DB
|
## [0.3.14]
|
||||||
- ?
|
### Added
|
||||||
|
- Clearer give-confirmation screen
|
||||||
|
- BX currency https://thebx.medium.com/
|
||||||
|
- Deselection of project on gifted details page
|
||||||
|
### Fixed
|
||||||
|
- Don't show registration pop-up for a new contact that is registered
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
## [0.2.17] - 2024.03.01
|
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
|
||||||
|
### Added
|
||||||
|
- Photos on projects
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
|
||||||
|
### Fixed
|
||||||
|
- Photo share (share_target) failed because requests were sent to server
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
|
||||||
|
### Added
|
||||||
|
- Choose a file for gifts, and a URL for gifts & profiles
|
||||||
|
### Fixed
|
||||||
|
- Multiple button pushes were required to switch camera
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
|
||||||
|
### Added
|
||||||
|
- Share an image
|
||||||
|
- Choose a file on the device for a profile image
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
|
||||||
|
### Added
|
||||||
|
- Offers on contacts page
|
||||||
|
- Checks on front page until they show as registered
|
||||||
|
### Changed
|
||||||
|
- Scanned contacts now add immediately and prompt for registration.
|
||||||
|
- Better UI for gives on contact page
|
||||||
|
- Better UI for all confirmation messages
|
||||||
|
### Fixed
|
||||||
|
- Repeated elements at top of main feed
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
|
||||||
|
### Added
|
||||||
|
- Profile image for user
|
||||||
|
### Fixed
|
||||||
|
- Slow loading of home page feed
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
|
||||||
|
### Added
|
||||||
|
- Filter on home page feed
|
||||||
|
- Ability to set time of daily notification
|
||||||
|
- Jump to app on click of notification
|
||||||
|
### Changed
|
||||||
|
- Built with vite
|
||||||
|
- Descriptions on home page to include projects
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.6] - 2024.03.24 - 3a07e31d6313ab95711265562d9023c42916e141
|
||||||
|
### Added
|
||||||
|
- Button to mirror photo during video
|
||||||
|
- More detailed onboarding help screen
|
||||||
|
- Public-data blurb
|
||||||
|
### Changed in DB or environment
|
||||||
|
- Nothing
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.5] - 2024.03.23 - 28754bdfb1e11aa221dd49a5dce4219b69cf6a9d
|
||||||
|
### Added
|
||||||
|
- Photo on gift records
|
||||||
|
### Fixed
|
||||||
|
- Environment variable for BVC meetings project
|
||||||
|
- Environment variables and build enhancements for test vs prod
|
||||||
|
### Changed in DB or environment
|
||||||
|
- New environment variable for image API server
|
||||||
|
- Test that a new browser session will get the right default APIs.
|
||||||
|
- Test that a new browser session will send the right BVC meetings project.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36
|
||||||
### Added
|
### Added
|
||||||
- Shortcut page for Bountiful Voluntaryist Community
|
- Shortcut page for Bountiful Voluntaryist Community
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -10,43 +10,59 @@ See [project.task.yaml](project.task.yaml) for current priorities.
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
|
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and hot-reloads for development
|
### Compile and hot-reloads for development
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the test & production app
|
||||||
```
|
```
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lints and fixes files
|
### Lint and fix files
|
||||||
```
|
```
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and minifies for production
|
### Compile and minify for test & production
|
||||||
|
|
||||||
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
|
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
|
||||||
|
|
||||||
* `npx prettier --write ./sw_scripts/`
|
* `npx prettier --write ./sw_scripts/`
|
||||||
|
|
||||||
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
|
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
|
||||||
|
|
||||||
* If production: change src/constants/app.ts DEFAULT_*_SERVER to be "PROD" and package.json to remove "_Test". Also record what version is on production.
|
* Record what version is currently on production.
|
||||||
|
|
||||||
* `npm run build`
|
* Run the correct build:
|
||||||
|
|
||||||
* Get on the server and back up the time-safari folder.
|
* Test
|
||||||
|
```
|
||||||
|
# (Let's replace this with a .env.development or .env.staging file.)
|
||||||
|
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
|
||||||
|
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
* Production
|
||||||
|
```
|
||||||
|
# This picks up values from .env.production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
* Get on the server and back up 3 DBs and the time-safari folder.
|
||||||
|
|
||||||
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
||||||
|
|
||||||
* Revert src/constants/app.ts and package.json (if that was prod).
|
* Commit changes. 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.
|
||||||
|
|
||||||
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Tag if you didn't before. Also record what version is on production.
|
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
||||||
|
|
||||||
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -79,11 +95,11 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
|||||||
|
|
||||||
### Manual walk-through test
|
### Manual walk-through test
|
||||||
|
|
||||||
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
|
|
||||||
- Use a mobile user as well as a desktop user.
|
|
||||||
- Backup seed & data & get a CSV dump from Endorser Mobile.
|
- Backup seed & data & get a CSV dump from Endorser Mobile.
|
||||||
|
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
|
||||||
|
- Use a mobile user as well as a desktop user.
|
||||||
- Check that the version is updated.
|
- Check that the version is updated.
|
||||||
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
||||||
- Make sure that it's using the test API (under Identity in 'Advanced').
|
- Make sure that it's using the test API (under Identity in 'Advanced').
|
||||||
- Clear the browser data again. (See "Reset" below.)
|
- Clear the browser data again. (See "Reset" below.)
|
||||||
- Go to the account page before visiting the home page to see that there is no ID.
|
- Go to the account page before visiting the home page to see that there is no ID.
|
||||||
@@ -93,15 +109,19 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
|||||||
- Copy the contact URL.
|
- Copy the contact URL.
|
||||||
- On each page, verify the messaging, and that they cannot take action.
|
- On each page, verify the messaging, and that they cannot take action.
|
||||||
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
|
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
|
||||||
- On the contacts page, check that they can add User #0 even without their own ID.
|
- On the contacts page, check that they can add a contact even without their own ID.
|
||||||
- As User #0 in another browser on the test API, add a give & a project.
|
- Install the PWA.
|
||||||
- `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
- As User 0 in another browser on the test API, add a give & a project.
|
||||||
- With the new user on the home page, see the feed that shows User #0 in network but without the name.
|
- Note that some combinations of desktop with mobile emulation stretch the image.
|
||||||
- As the new user on the contacts page, add User #0 as a contact.
|
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||||
- On the home page, see the feed that shows User #0 with a name.
|
- Add new user as a contact (which allows them to see User 0).
|
||||||
|
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
|
||||||
|
- As the new user, import contacts & identifiers.
|
||||||
|
- As the new user on the contacts page, add User 0 as a contact.
|
||||||
|
- On the home page, see the feed that shows User 0 with a name.
|
||||||
- Switch back to the generated identifier.
|
- Switch back to the generated identifier.
|
||||||
- On the account page, check that they see messages on limits.
|
- On the account page, check that they see messages on limits.
|
||||||
- As User #0, register the ID.
|
- As User 0, register the ID.
|
||||||
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
|
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
|
||||||
- On the contacts page, check that they cannot register someone else yet.
|
- On the contacts page, check that they cannot register someone else yet.
|
||||||
- Walk through the functions on each page.
|
- Walk through the functions on each page.
|
||||||
@@ -109,13 +129,14 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
|||||||
- Export & import, both seed and contacts & settings.
|
- Export & import, both seed and contacts & settings.
|
||||||
- Choose location on the search map.
|
- Choose location on the search map.
|
||||||
- Offer, deliver a give, and confirm. Create a third user and test connections.
|
- Offer, deliver a give, and confirm. Create a third user and test connections.
|
||||||
|
- On mobile, share an image with the app.
|
||||||
- Switch to "no identifier" to see that things look OK without any ID.
|
- Switch to "no identifier" to see that things look OK without any ID.
|
||||||
|
|
||||||
### Clear/Reset data & restart
|
### Clear/Reset data & restart
|
||||||
|
|
||||||
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
|
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
|
||||||
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
|
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
|
||||||
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers`.)
|
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals`; in Firefox, go to `about:serviceworkers`.)
|
||||||
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
|
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
|
||||||
|
|
||||||
(If you find more, add them to the HelpNotificationsView.vue file.)
|
(If you find more, add them to the HelpNotificationsView.vue file.)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: ["@vue/cli-plugin-babel/preset"],
|
|
||||||
};
|
|
||||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<title>TimeSafari</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19250
package-lock.json
generated
19250
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
124
package.json
124
package.json
@@ -1,94 +1,98 @@
|
|||||||
{
|
{
|
||||||
"name": "TimeSafari_Test",
|
"name": "TimeSafari",
|
||||||
"version": "0.2.17",
|
"version": "0.3.15-beta",
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"dev": "vite",
|
||||||
"build": "vue-cli-service build",
|
"serve": "vite preview",
|
||||||
"lint": "vue-cli-service lint"
|
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
|
||||||
|
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||||
|
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||||
|
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^5.3.5",
|
"@dicebear/collection": "^5.4.1",
|
||||||
"@dicebear/core": "^5.3.5",
|
"@dicebear/core": "^5.4.1",
|
||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
|
"@peculiar/asn1-ecc": "^2.3.8",
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||||
"@tweenjs/tween.js": "^21.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@simplewebauthn/server": "^10.0.0",
|
||||||
"@types/luxon": "^3.4.2",
|
"@tweenjs/tween.js": "^21.1.1",
|
||||||
"@veramo/core": "^5.4.1",
|
"@veramo/core": "^5.6.0",
|
||||||
"@veramo/credential-w3c": "^5.4.1",
|
"@veramo/credential-w3c": "^5.6.0",
|
||||||
"@veramo/data-store": "^5.4.1",
|
"@veramo/data-store": "^5.6.0",
|
||||||
"@veramo/did-manager": "^5.4.1",
|
"@veramo/did-manager": "^5.6.0",
|
||||||
"@veramo/did-provider-ethr": "^5.4.1",
|
"@veramo/did-provider-ethr": "^5.6.0",
|
||||||
"@veramo/did-resolver": "^5.4.1",
|
"@veramo/did-provider-peer": "^6.0.0",
|
||||||
"@veramo/key-manager": "^5.4.1",
|
"@veramo/did-resolver": "^5.6.0",
|
||||||
"@vueuse/core": "^10.4.1",
|
"@veramo/key-manager": "^5.6.0",
|
||||||
|
"@vueuse/core": "^10.9.0",
|
||||||
"@zxing/text-encoding": "^0.9.0",
|
"@zxing/text-encoding": "^0.9.0",
|
||||||
"axios": "^1.5.0",
|
"asn1-ber": "^1.2.2",
|
||||||
"buffer": "^6.0.3",
|
"axios": "^1.6.8",
|
||||||
|
"cbor-x": "^1.5.9",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"core-js": "^3.32.1",
|
"dexie": "^3.2.7",
|
||||||
"dexie": "^3.2.4",
|
"dexie-export-import": "^4.1.1",
|
||||||
"dexie-export-import": "^4.0.7",
|
"did-jwt": "^7.4.7",
|
||||||
"did-jwt": "^7.2.7",
|
"ethereum-cryptography": "^2.1.3",
|
||||||
"ethereum-cryptography": "^2.1.2",
|
|
||||||
"ethereumjs-util": "^7.1.5",
|
"ethereumjs-util": "^7.1.5",
|
||||||
"ethr-did-resolver": "^8.1.2",
|
"ethr-did-resolver": "^8.1.2",
|
||||||
"git-describe": "^4.1.1",
|
|
||||||
"jdenticon": "^3.2.0",
|
"jdenticon": "^3.2.0",
|
||||||
"js-generate-password": "^0.1.9",
|
"js-generate-password": "^0.1.9",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"localstorage-slim": "^2.5.0",
|
"localstorage-slim": "^2.7.0",
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"merkletreejs": "^0.3.11",
|
"merkletreejs": "^0.3.11",
|
||||||
"moment": "^2.29.4",
|
|
||||||
"notiwind": "^2.0.2",
|
"notiwind": "^2.0.2",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pina": "^0.20.2204228",
|
"pina": "^0.20.2204228",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"qr-code-generator-vue3": "^1.4.21",
|
"qr-code-generator-vue3": "^1.4.21",
|
||||||
"ramda": "^0.29.0",
|
"ramda": "^0.29.1",
|
||||||
"readable-stream": "^4.4.2",
|
"readable-stream": "^4.5.2",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.14",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
|
"simple-vue-camera": "^1.1.3",
|
||||||
"three": "^0.156.1",
|
"three": "^0.156.1",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.4.21",
|
||||||
"vue-axios": "^3.5.2",
|
"vue-axios": "^3.5.2",
|
||||||
"vue-facing-decorator": "^3.0.2",
|
"vue-facing-decorator": "^3.0.4",
|
||||||
"vue-qrcode-reader": "^5.4.1",
|
"vue-picture-cropper": "^0.7.0",
|
||||||
"vue-router": "^4.2.4",
|
"vue-qrcode-reader": "^5.5.3",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
"web-did-resolver": "^2.0.27"
|
"web-did-resolver": "^2.0.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/leaflet": "^1.9.4",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/ramda": "^0.29.3",
|
"@types/leaflet": "^1.9.8",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/ramda": "^0.29.11",
|
||||||
"@types/three": "^0.155.1",
|
"@types/three": "^0.155.1",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.6.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"@vue/cli-plugin-babel": "~5.0.8",
|
|
||||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
|
||||||
"@vue/cli-plugin-pwa": "~5.0.8",
|
|
||||||
"@vue/cli-plugin-router": "~5.0.8",
|
|
||||||
"@vue/cli-plugin-typescript": "~5.0.8",
|
|
||||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
|
||||||
"@vue/cli-service": "~5.0.8",
|
|
||||||
"@vue/eslint-config-typescript": "^11.0.3",
|
"@vue/eslint-config-typescript": "^11.0.3",
|
||||||
"autoprefixer": "^10.4.15",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.53.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
"eslint-plugin-vue": "^9.23.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "~5.2.2"
|
"typescript": "~5.2.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vite-plugin-pwa": "^0.19.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +1,4 @@
|
|||||||
|
|
||||||
tasks :
|
tasks :
|
||||||
|
|
||||||
- .2 fix give dialog from "more contacts" off home page to allow giving to this user
|
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d
|
||||||
- .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page
|
|
||||||
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window)
|
|
||||||
- .2 don't show a warning on a totally new project when the authorized agent is set
|
|
||||||
- .2 anchor hash into BTC
|
|
||||||
- .2 list the "show more" contacts alphabetically
|
|
||||||
|
|
||||||
- 32 image on give :
|
|
||||||
- Show a camera to take a picture
|
|
||||||
- Scale the image to a reasonable size
|
|
||||||
- Upload to a public readable place
|
|
||||||
- check the rate limits
|
|
||||||
- use CID (hash?)
|
|
||||||
- put the image URL in the claim
|
|
||||||
- Rates - images erased?
|
|
||||||
- image not associated with JWT ULID since that's assigned later
|
|
||||||
|
|
||||||
- 24 compelling UI for credential presentations
|
|
||||||
- discover who in my network has activity on a project
|
|
||||||
|
|
||||||
- 24 compelling UI for statistics (eg. World?)
|
|
||||||
|
|
||||||
- 01 in the feed, group by project or contact or topic or time/$ (via BC)
|
|
||||||
- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"?
|
|
||||||
- .2 add links between projects
|
|
||||||
- 24 make the contact browsing on the front page something that invites more action
|
|
||||||
|
|
||||||
- .5 change server plan & project endpoints to use jwtId as identifier rather than rowid
|
|
||||||
- 16 edit offers & gives, or revoke allowing re-creation
|
|
||||||
- .1 When available in the server, give message for 'nonAmountGiven' on offers on ProjectsView page.
|
|
||||||
- .1 Add help instructions for "Encryption key has changed" error. (It is a problem if localStorage is cleared, but the contacts & settings remain and they have to restore their seeds.)
|
|
||||||
- .1 show better error when user with no ID goes to the "My Project" page
|
|
||||||
- 01 in front page prompt for ideas for gratitude :
|
|
||||||
- randomize (not show in order)
|
|
||||||
- checkboxes - show non-person-oriented messages, show only contacts, show only projects
|
|
||||||
|
|
||||||
- 08 allow user to add a time when they want their daily notification
|
|
||||||
|
|
||||||
- .5 prompt for the name directly when they visit the QR scan page
|
|
||||||
- 01 mark a project as inactive
|
|
||||||
- 01 add share button for sending a message to confirmers when we can't see the claim (like the "visible" links)
|
|
||||||
- .5 add TimeSafari as a shareable app https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
|
|
||||||
- .5 choose a project's alternative agent ("authorized representative") via a contact chooser (not just copy-paste a DID)
|
|
||||||
- .5 find out why clicking quickly back-and-forth onto the "my project" page often shows error "You need an identifier to load your projects." (easier to reproduce on desktop?)
|
|
||||||
- .5 bug - it didn't show the "fulfills offer" on the claim detail page for a give that had one - https://test.timesafari.app/claim/01HMFWRPA3PD6Q9EYFKX3MC41J
|
|
||||||
- 01 replace all "confirm" prompts with nicer modal
|
|
||||||
- .1 hide project-create button on project page if not registered
|
|
||||||
- .1 hide offer & give buttons on project list page if not registered
|
|
||||||
- .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page
|
|
||||||
- .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads
|
|
||||||
|
|
||||||
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.)
|
|
||||||
- refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute)
|
|
||||||
- the confirm button on each give on the ProjectViewView page doesn't have all the context of the ClaimView page, so it can show sometimes inappropriately; consider consolidation
|
|
||||||
- make the "give" on contact screen work like other give (allowing donation vs current blank)
|
|
||||||
- .2 on ClaimView, the "ask someone" should refer to "visible" IDs, or to confirmations only if confirmations are visible
|
|
||||||
- message "send them to this page" on ClaimView should be a link (for installed app)
|
|
||||||
- When we update a version, desktop browser users have seen nothing happen after clicking on the contact page QR and on the account page "Help"; errors show in the console. Reload fixed it. If this happens on mobile, ask the user to reload.
|
|
||||||
|
|
||||||
- 01 show my VCs - most interesting, or via search
|
|
||||||
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others
|
|
||||||
|
|
||||||
- show feed of offers, new projects, etc -- maybe limited to my search area
|
|
||||||
|
|
||||||
- revenue to support server operation
|
|
||||||
|
|
||||||
- copy button for seed
|
|
||||||
- .5 If notifications are not enabled, add message to front page with link/button to enable
|
|
||||||
- make server endpoint for full English description of limits
|
|
||||||
- create a help-desk document & add screenshots
|
|
||||||
|
|
||||||
- .1 update "offer" units to have same functionality as "give" units
|
|
||||||
- .5 add a link to any 'give' records that fulfill an offer on ClaimView
|
|
||||||
- 01 on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed)
|
|
||||||
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list)
|
|
||||||
- bug (that is hard to reproduce) - got error adding on Firefox user #0 as contact for themselves
|
|
||||||
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function"
|
|
||||||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
|
|
||||||
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
|
|
||||||
- 04 look at other examples for better onboarding UI, eg friend.tech
|
|
||||||
- .5 Add inactive flag / end date, start date to project
|
|
||||||
- .3 check that Android shows "back" buttons on screens without bottom tray
|
|
||||||
- .1 Make give description text box into something that expands as they type?
|
|
||||||
- .2 Show a warning if both giver and recipient are the same (but still allow?)
|
|
||||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
|
|
||||||
- .5 Display a more appealing confirmation on the map when erasing the marker
|
|
||||||
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
|
|
||||||
- switch some checks for activeDid to check for isRegistered
|
|
||||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
|
||||||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
|
|
||||||
- warn if they're using the web (android only?)
|
|
||||||
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
|
|
||||||
https://web.dev/articles/get-installed-related-apps
|
|
||||||
- .5 fix the "onboarding help" list of instructions so that it always formats right (currently doesn't show numbers aligned on Google Pixel 6a, iPhone 11 Pro, iPhone 12 mini)
|
|
||||||
- .5 make the "onboarding help" it so that it doesn't cover the QR icon on the contacts page
|
|
||||||
- .5 fix masked icon (because some of the top-right of the binoculars is cut off)
|
|
||||||
|
|
||||||
- contacts v+ :
|
|
||||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
|
||||||
- .2 show error to user when adding a duplicate contact
|
|
||||||
- 01 parse input more robustly (with CSV lib and not commas)
|
|
||||||
|
|
||||||
- stats v1 :
|
|
||||||
- 01 show numeric stats
|
|
||||||
- 04 show different graphic for projects vs people (gnome?) on world
|
|
||||||
- 01 link to world for specific stats
|
|
||||||
- .5 don't load another instance of a bush if it already exists
|
|
||||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
|
|
||||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
|
||||||
|
|
||||||
- .5 show seed phrase in a QR code for transfer to another device
|
|
||||||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
|
|
||||||
- .5 don't show "Offer" on project screen if they aren't registered
|
|
||||||
|
|
||||||
- 24 Move to Vite
|
|
||||||
- 32 accept images for projects
|
|
||||||
- 32 accept images for contacts
|
|
||||||
- import project interactions from GitHub/GitLab and manage signing
|
|
||||||
|
|
||||||
- show total time offered to & fulfilled to a project
|
|
||||||
- show total time offered by & fulfilled by a contact
|
|
||||||
|
|
||||||
- linking between projects or plans :
|
|
||||||
- show total time given to & from a project
|
|
||||||
- terminology:
|
|
||||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
|
||||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
|
||||||
|
|
||||||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
|
|
||||||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
|
|
||||||
|
|
||||||
- Stats :
|
|
||||||
- 01 point out user's location on the world
|
|
||||||
- 01 present a credential selected from the stats
|
|
||||||
- 04 show gives spreading to other places
|
|
||||||
- badge for most gives/receives/confirms per day/week/month
|
|
||||||
- badge for amount given/offered to your project
|
|
||||||
- set a goal of given/offers
|
|
||||||
|
|
||||||
- automated tests, eg. pup-test or cypress
|
|
||||||
|
|
||||||
- Notifications (wake on the phone, push notifications)
|
|
||||||
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee
|
|
||||||
- pull instead of push, maybe via scheduled runs
|
|
||||||
- have a notification pop-up on Mac screen
|
|
||||||
|
|
||||||
- 16 Connect with phone contacts - this may be a whole different app, because we want a quick link A) to the same phone contact and B) from the phone contact app
|
|
||||||
|
|
||||||
- Support KERI AIDs
|
|
||||||
- Support Peer DIDs
|
|
||||||
- Support messaging through DIDComm
|
|
||||||
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
|
|
||||||
|
|
||||||
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
|
|
||||||
|
|
||||||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections -- and that allows spam
|
|
||||||
- .1 When Chrome shows compatibility https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
|
||||||
then change the canShare check in this app to check the real canShare() method.
|
|
||||||
|
|
||||||
log :
|
|
||||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
|
||||||
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
|
||||||
</noscript>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- built files will be auto injected -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
252
src/App.vue
252
src/App.vue
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
||||||
<!-- https://github.com/emmanuelsw/notiwind -->
|
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||||
<NotificationGroup group="alert">
|
<NotificationGroup group="alert">
|
||||||
<div
|
<div
|
||||||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
||||||
@@ -129,6 +129,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</NotificationGroup>
|
</NotificationGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
This "group" of "modal" is the prompt for an answer.
|
||||||
|
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
|
||||||
|
-->
|
||||||
<NotificationGroup group="modal">
|
<NotificationGroup group="modal">
|
||||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||||
<Notification
|
<Notification
|
||||||
@@ -142,12 +146,96 @@
|
|||||||
move="transition duration-500"
|
move="transition duration-500"
|
||||||
move-delay="delay-300"
|
move-delay="delay-300"
|
||||||
>
|
>
|
||||||
|
<!-- see NotificationIface in constants/app.ts -->
|
||||||
<div
|
<div
|
||||||
v-for="notification in notifications"
|
v-for="notification in notifications"
|
||||||
:key="notification.id"
|
:key="notification.id"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
|
<!--
|
||||||
|
Type of "confirm" will post a message.
|
||||||
|
With onYes function, show a "Yes" button to call that function.
|
||||||
|
With onNo function, show a "No" button to call that function,
|
||||||
|
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="notification.type === 'confirm'"
|
||||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||||
|
<span class="font-semibold text-lg">
|
||||||
|
{{ notification.title }}
|
||||||
|
</span>
|
||||||
|
<p class="text-sm mb-2">{{ notification.text }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="notification.onYes"
|
||||||
|
@click="
|
||||||
|
notification.onYes();
|
||||||
|
close(notification.id);
|
||||||
|
"
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="notification.onNo"
|
||||||
|
@click="
|
||||||
|
notification.onNo(stopAsking);
|
||||||
|
close(notification.id);
|
||||||
|
stopAsking = false; // reset value
|
||||||
|
"
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="notification.promptToStopAsking && notification.onNo"
|
||||||
|
for="toggleStopAsking"
|
||||||
|
class="flex items-center justify-between cursor-pointer my-4"
|
||||||
|
@click="stopAsking = !stopAsking"
|
||||||
|
>
|
||||||
|
<!-- label -->
|
||||||
|
<span class="ml-2">... and do not ask again.</span>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div class="relative ml-2">
|
||||||
|
<!-- input -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="stopAsking"
|
||||||
|
name="stopAsking"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
notification.onCancel
|
||||||
|
? notification.onCancel(stopAsking)
|
||||||
|
: null;
|
||||||
|
close(notification.id);
|
||||||
|
stopAsking = false; // reset value
|
||||||
|
"
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||||
|
>
|
||||||
|
{{ notification.onYes ? "Cancel" : "Close" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="notification.type === 'notification-permission'"
|
v-if="notification.type === 'notification-permission'"
|
||||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||||
@@ -157,7 +245,7 @@
|
|||||||
>
|
>
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||||
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
||||||
Would you like to <b>turn on</b> notifications for this app?
|
Would you like to be notified of new activity once a day?
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-lg mb-4">
|
<p v-else class="text-lg mb-4">
|
||||||
Waiting for system initialization, which may take up to 10
|
Waiting for system initialization, which may take up to 10
|
||||||
@@ -165,22 +253,42 @@
|
|||||||
<fa icon="spinner" spin />
|
<fa icon="spinner" spin />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<div v-if="serviceWorkerReady">
|
||||||
v-if="serviceWorkerReady"
|
<span class="flex flex-row justify-center">
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
<span class="mt-2">Yes, tell me at: </span>
|
||||||
@click="
|
<input
|
||||||
close(notification.id);
|
type="number"
|
||||||
turnOnNotifications();
|
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
|
||||||
"
|
v-model="hourInput"
|
||||||
>
|
/>
|
||||||
Turn on Notifications
|
<span
|
||||||
</button>
|
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
|
||||||
|
@click="hourAm = !hourAm"
|
||||||
|
>
|
||||||
|
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
|
||||||
|
<span v-else> PM <fa icon="chevron-up" /> </span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
if (checkHour()) {
|
||||||
|
close(notification.id);
|
||||||
|
turnOnNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Turn on Daily Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="close(notification.id)"
|
@click="close(notification.id)"
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Maybe Later
|
No, Not Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,8 +371,11 @@
|
|||||||
<style></style>
|
<style></style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Vue, Component } from "vue-facing-decorator";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { Vue, Component } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
|
||||||
interface ServiceWorkerMessage {
|
interface ServiceWorkerMessage {
|
||||||
type: string;
|
type: string;
|
||||||
data: string;
|
data: string;
|
||||||
@@ -288,6 +399,10 @@ interface VapidResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
|
||||||
|
notifyTime: { utcHour: number };
|
||||||
|
}
|
||||||
|
|
||||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
|
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
|
||||||
import { db } from "@/db/index";
|
import { db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
@@ -297,8 +412,11 @@ import { sendTestThroughPushServer } from "@/libs/util";
|
|||||||
export default class App extends Vue {
|
export default class App extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
stopAsking = false;
|
||||||
b64 = "";
|
b64 = "";
|
||||||
serviceWorkerReady = false;
|
hourAm = true;
|
||||||
|
hourInput = "8";
|
||||||
|
serviceWorkerReady = true;
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
@@ -309,25 +427,29 @@ export default class App extends Vue {
|
|||||||
pushUrl = settings.webPushServer;
|
pushUrl = settings.webPushServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios
|
if (pushUrl.startsWith("http://localhost")) {
|
||||||
.get(pushUrl + "/web-push/vapid")
|
console.log("Not checking for VAPID in this local environment.");
|
||||||
.then((response: VapidResponse) => {
|
} else {
|
||||||
this.b64 = response.data?.vapidKey || "";
|
await axios
|
||||||
console.log("Got vapid key:", this.b64);
|
.get(pushUrl + "/web-push/vapid")
|
||||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
.then((response: VapidResponse) => {
|
||||||
console.log("New service worker is now controlling the page");
|
this.b64 = response.data?.vapidKey || "";
|
||||||
|
console.log("Got vapid key:", this.b64);
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||||
|
console.log("New service worker is now controlling the page");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
if (!this.b64) {
|
||||||
if (!this.b64) {
|
this.$notify(
|
||||||
this.$notify(
|
{
|
||||||
{
|
group: "alert",
|
||||||
group: "alert",
|
type: "danger",
|
||||||
type: "danger",
|
title: "Error Setting Notifications",
|
||||||
title: "Error Setting Notifications",
|
text: "Could not set notifications.",
|
||||||
text: "Could not set notifications.",
|
},
|
||||||
},
|
-1,
|
||||||
-1,
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (window.location.host.startsWith("localhost")) {
|
if (window.location.host.startsWith("localhost")) {
|
||||||
@@ -427,6 +549,48 @@ export default class App extends Vue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this allows us to show an error without closing the dialog
|
||||||
|
checkHour() {
|
||||||
|
if (!libsUtil.isNumeric(this.hourInput)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Not a Number",
|
||||||
|
text: "The time must be an hour number.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hourNum = libsUtil.numberOrZero(this.hourInput);
|
||||||
|
if (!Number.isInteger(hourNum)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Not a Whole Number",
|
||||||
|
text: "The time must be a whole hour number.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hourNum < 1 || 12 < hourNum) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Not a Whole Number",
|
||||||
|
text: "The time must be an hour between 1 and 12.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async turnOnNotifications() {
|
public async turnOnNotifications() {
|
||||||
return this.askPermission()
|
return this.askPermission()
|
||||||
.then((permission) => {
|
.then((permission) => {
|
||||||
@@ -452,13 +616,25 @@ export default class App extends Vue {
|
|||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
this.sendSubscriptionToServer(subscription);
|
// we already checked that this is a valid hour number
|
||||||
return subscription;
|
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
|
||||||
|
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
|
||||||
|
const hourNum = adjHourNum % 24;
|
||||||
|
const utcHour =
|
||||||
|
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
|
||||||
|
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
|
||||||
|
|
||||||
|
const subscriptionWithTime: PushSubscriptionWithTime = {
|
||||||
|
notifyTime: { utcHour: finalUtcHour },
|
||||||
|
...subscription.toJSON(),
|
||||||
|
};
|
||||||
|
await this.sendSubscriptionToServer(subscriptionWithTime);
|
||||||
|
return subscriptionWithTime;
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Subscription object is not available.");
|
throw new Error("Subscription object is not available.");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(async (subscription) => {
|
.then(async (subscription: PushSubscriptionWithTime) => {
|
||||||
console.log(
|
console.log(
|
||||||
"Subscription data sent to server and all finished successfully.",
|
"Subscription data sent to server and all finished successfully.",
|
||||||
);
|
);
|
||||||
@@ -549,7 +725,7 @@ export default class App extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendSubscriptionToServer(
|
private sendSubscriptionToServer(
|
||||||
subscription: PushSubscription,
|
subscription: PushSubscriptionWithTime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log("About to send subscription...", subscription);
|
console.log("About to send subscription...", subscription);
|
||||||
return fetch("/web-push/subscribe", {
|
return fetch("/web-push/subscribe", {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||||
|
|||||||
@@ -5,20 +5,36 @@
|
|||||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||||
import { avataaars } from "@dicebear/collection";
|
import { avataaars } from "@dicebear/collection";
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class EntityIcon extends Vue {
|
export default class EntityIcon extends Vue {
|
||||||
@Prop entityId = "";
|
@Prop contact: Contact;
|
||||||
|
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
|
||||||
@Prop iconSize = 0;
|
@Prop iconSize = 0;
|
||||||
|
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
||||||
|
|
||||||
generateIcon() {
|
generateIcon() {
|
||||||
const options: StyleOptions<object> = {
|
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||||
seed: this.entityId || "",
|
if (imageUrl) {
|
||||||
size: this.iconSize,
|
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||||
};
|
} else {
|
||||||
const avatar = createAvatar(avataaars, options);
|
const identifier = this.contact?.did || this.entityId;
|
||||||
const svgString = avatar.toString();
|
if (!identifier) {
|
||||||
return svgString;
|
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||||
|
}
|
||||||
|
// https://api.dicebear.com/8.x/avataaars/svg?seed=
|
||||||
|
// ... does not render things with the same seed as this library.
|
||||||
|
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
|
||||||
|
// ... which looks similar to '' at the dicebear site but which is different.
|
||||||
|
const options: StyleOptions<object> = {
|
||||||
|
seed: (identifier as string) || "",
|
||||||
|
size: this.iconSize,
|
||||||
|
};
|
||||||
|
const avatar = createAvatar(avataaars, options);
|
||||||
|
const svgString = avatar.toString();
|
||||||
|
return svgString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
219
src/components/FeedFilters.vue
Normal file
219
src/components/FeedFilters.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1>
|
||||||
|
|
||||||
|
<p class="mb-4 font-bold">Show only activities that…</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between cursor-pointer"
|
||||||
|
@click="toggleHasVisibleDid()"
|
||||||
|
>
|
||||||
|
<!-- label -->
|
||||||
|
<div>Include someone visible to me</div>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div class="relative ml-2">
|
||||||
|
<!-- input -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="hasVisibleDid"
|
||||||
|
name="toggleFilterFromMyContacts"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<em>or</em>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between cursor-pointer"
|
||||||
|
@click="
|
||||||
|
hasSearchBox
|
||||||
|
? toggleNearby()
|
||||||
|
: $router.push({ name: 'search-area' })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- label -->
|
||||||
|
<div>Are nearby</div>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div v-if="hasSearchBox" class="relative ml-2">
|
||||||
|
<!-- input -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="isNearby"
|
||||||
|
name="toggleFilterNearby"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative ml-2">
|
||||||
|
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
|
||||||
|
Select Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4">
|
||||||
|
<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="setAll()"
|
||||||
|
>
|
||||||
|
Set All
|
||||||
|
</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="clearAll()"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</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="done()"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Component } from "vue-facing-decorator";
|
||||||
|
import {
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LRectangle,
|
||||||
|
LTileLayer,
|
||||||
|
} from "@vue-leaflet/vue-leaflet";
|
||||||
|
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
LRectangle,
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LTileLayer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class FeedFilters extends Vue {
|
||||||
|
onCloseIfChanged = () => {};
|
||||||
|
hasSearchBox = false;
|
||||||
|
hasVisibleDid = false;
|
||||||
|
isNearby = false;
|
||||||
|
settingChanged = false;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
async open(onCloseIfChanged: () => void) {
|
||||||
|
this.onCloseIfChanged = onCloseIfChanged;
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
this.hasVisibleDid = !!settings?.filterFeedByVisible;
|
||||||
|
this.isNearby = !!settings?.filterFeedByNearby;
|
||||||
|
if (settings?.searchBoxes && settings.searchBoxes.length > 0) {
|
||||||
|
this.hasSearchBox = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingChanged = false;
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHasVisibleDid() {
|
||||||
|
this.settingChanged = true;
|
||||||
|
this.hasVisibleDid = !this.hasVisibleDid;
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
filterFeedByVisible: this.hasVisibleDid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNearby() {
|
||||||
|
this.settingChanged = true;
|
||||||
|
this.isNearby = !this.isNearby;
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
filterFeedByNearby: this.isNearby,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll() {
|
||||||
|
if (this.hasVisibleDid || this.isNearby) {
|
||||||
|
this.settingChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
filterFeedByNearby: false,
|
||||||
|
filterFeedByVisible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hasVisibleDid = false;
|
||||||
|
this.isNearby = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAll() {
|
||||||
|
if (!this.hasVisibleDid || !this.isNearby) {
|
||||||
|
this.settingChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
filterFeedByNearby: true,
|
||||||
|
filterFeedByVisible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hasVisibleDid = true;
|
||||||
|
this.isNearby = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.settingChanged) {
|
||||||
|
this.onCloseIfChanged();
|
||||||
|
}
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
done() {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dialogFeedFilters.dialog-overlay {
|
||||||
|
z-index: 99999;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,31 +2,30 @@
|
|||||||
<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">
|
<h1 class="text-xl font-bold text-center mb-4">
|
||||||
{{ message }} {{ giver?.name || "somebody not named" }}
|
{{ customTitle }}
|
||||||
</h1>
|
</h1>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
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 given"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row">
|
<div class="flex flex-row justify-center">
|
||||||
<span
|
<span
|
||||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center text-blue-500 px-2 py-2"
|
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()"
|
@click="changeUnitCode()"
|
||||||
>
|
>
|
||||||
{{ libsUtil.UNIT_SHORT[unitCode] }}
|
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||||
@click="decrement()"
|
@click="amountInput === '0' ? null : decrement()"
|
||||||
v-if="amountInput !== '0'"
|
|
||||||
>
|
>
|
||||||
<fa icon="chevron-left" />
|
<fa icon="chevron-left" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||||
v-model="amountInput"
|
v-model="amountInput"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -36,31 +35,51 @@
|
|||||||
<fa icon="chevron-right" />
|
<fa icon="chevron-right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-right">
|
<div class="mt-4 flex justify-center">
|
||||||
<span v-if="showGivenToUser" class="mr-16">
|
|
||||||
<input type="checkbox" class="mr-2" v-model="givenToUser" />
|
|
||||||
<label class="text-sm">Given to you</label>
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
<input type="checkbox" class="mr-2" v-model="isTrade" />
|
<router-link
|
||||||
<label class="text-sm">Trade (not a gift)</label>
|
:to="{
|
||||||
|
name: 'gifted-details',
|
||||||
|
query: {
|
||||||
|
amountInput,
|
||||||
|
description,
|
||||||
|
giverDid: giver?.did,
|
||||||
|
giverName: giver?.name,
|
||||||
|
offerId,
|
||||||
|
projectId,
|
||||||
|
recipientDid: receiver?.did,
|
||||||
|
recipientName: receiver?.name,
|
||||||
|
unitCode,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
Photo & Details ...
|
||||||
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center mb-2 mt-6 italic">
|
<p class="text-center mb-2 mt-6 italic">
|
||||||
Sign & Send to publish to the world
|
Sign & Send to publish to the world
|
||||||
|
<fa
|
||||||
|
icon="circle-info"
|
||||||
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
|
@click="explainData()"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
<button
|
||||||
@click="confirm"
|
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"
|
||||||
>
|
@click="confirm"
|
||||||
Sign & Send
|
>
|
||||||
</button>
|
Sign & Send
|
||||||
<button
|
</button>
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
<button
|
||||||
@click="cancel"
|
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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,7 +91,7 @@ import { NotificationIface } from "@/constants/app";
|
|||||||
import {
|
import {
|
||||||
createAndSubmitGive,
|
createAndSubmitGive,
|
||||||
didInfo,
|
didInfo,
|
||||||
GiverInputInfo,
|
GiverReceiverInputInfo,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
import { accountsDB, db } from "@/db/index";
|
import { accountsDB, db } from "@/db/index";
|
||||||
@@ -83,9 +102,7 @@ import { Contact } from "@/db/tables/contacts";
|
|||||||
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 message = "";
|
|
||||||
@Prop projectId = "";
|
@Prop projectId = "";
|
||||||
@Prop showGivenToUser = false;
|
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
@@ -93,22 +110,32 @@ export default class GiftedDialog extends Vue {
|
|||||||
apiServer = "";
|
apiServer = "";
|
||||||
|
|
||||||
amountInput = "0";
|
amountInput = "0";
|
||||||
giver?: GiverInputInfo; // undefined means no identified giver agent
|
callbackOnSuccess?: (amount: number) => void = () => {};
|
||||||
|
customTitle?: string;
|
||||||
description = "";
|
description = "";
|
||||||
givenToUser = false;
|
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||||
isTrade = false;
|
isTrade = false;
|
||||||
offerId = "";
|
offerId = "";
|
||||||
|
receiver?: GiverReceiverInputInfo;
|
||||||
unitCode = "HUR";
|
unitCode = "HUR";
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
libsUtil = libsUtil;
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
async open(giver?: GiverInputInfo, offerId?: string) {
|
async open(
|
||||||
|
giver?: GiverReceiverInputInfo,
|
||||||
|
receiver?: GiverReceiverInputInfo,
|
||||||
|
offerId?: string,
|
||||||
|
customTitle?: string,
|
||||||
|
callbackOnSuccess?: (amount: number) => void,
|
||||||
|
) {
|
||||||
|
this.customTitle = customTitle;
|
||||||
this.description = "";
|
this.description = "";
|
||||||
this.giver = giver || {};
|
this.giver = giver;
|
||||||
|
this.receiver = receiver;
|
||||||
// if we show "given to user" selection, default checkbox to true
|
// if we show "given to user" selection, default checkbox to true
|
||||||
this.givenToUser = this.showGivenToUser;
|
|
||||||
this.amountInput = "0";
|
this.amountInput = "0";
|
||||||
|
this.callbackOnSuccess = callbackOnSuccess;
|
||||||
this.offerId = offerId || "";
|
this.offerId = offerId || "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -123,7 +150,7 @@ export default class GiftedDialog extends Vue {
|
|||||||
const allAccounts = await accountsDB.accounts.toArray();
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||||
|
|
||||||
if (!this.giver.name) {
|
if (this.giver && !this.giver.name) {
|
||||||
this.giver.name = didInfo(
|
this.giver.name = didInfo(
|
||||||
this.giver.did,
|
this.giver.did,
|
||||||
this.activeDid,
|
this.activeDid,
|
||||||
@@ -178,12 +205,50 @@ export default class GiftedDialog extends Vue {
|
|||||||
eraseValues() {
|
eraseValues() {
|
||||||
this.description = "";
|
this.description = "";
|
||||||
this.giver = undefined;
|
this.giver = undefined;
|
||||||
this.givenToUser = this.showGivenToUser;
|
|
||||||
this.amountInput = "0";
|
this.amountInput = "0";
|
||||||
this.unitCode = "HUR";
|
this.unitCode = "HUR";
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm() {
|
async confirm() {
|
||||||
|
if (!this.activeDid) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "You must select an identifier before you can record a give.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parseFloat(this.amountInput) < 0) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
text: "You may not send a negative number.",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.description && !parseFloat(this.amountInput)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: `You must enter a description or some number of ${
|
||||||
|
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||||
|
}.`,
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
@@ -197,6 +262,7 @@ export default class GiftedDialog extends Vue {
|
|||||||
// this is asynchronous, but we don't need to wait for it to complete
|
// this is asynchronous, but we don't need to wait for it to complete
|
||||||
await this.recordGive(
|
await this.recordGive(
|
||||||
(this.giver?.did as string) || null,
|
(this.giver?.did as string) || null,
|
||||||
|
(this.receiver?.did as string) || null,
|
||||||
this.description,
|
this.description,
|
||||||
parseFloat(this.amountInput),
|
parseFloat(this.amountInput),
|
||||||
this.unitCode,
|
this.unitCode,
|
||||||
@@ -208,42 +274,18 @@ export default class GiftedDialog extends Vue {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param giverDid may be null
|
* @param giverDid may be null
|
||||||
|
* @param recipientDid may be null
|
||||||
* @param description may be an empty string
|
* @param description may be an empty string
|
||||||
* @param amountInput may be 0
|
* @param amount may be 0
|
||||||
* @param unitCode may be omitted, defaults to "HUR"
|
* @param unitCode may be omitted, defaults to "HUR"
|
||||||
*/
|
*/
|
||||||
public async recordGive(
|
async recordGive(
|
||||||
giverDid: string | null,
|
giverDid: string | null,
|
||||||
|
recipientDid: string | null,
|
||||||
description: string,
|
description: string,
|
||||||
amountInput: number,
|
amount: number,
|
||||||
unitCode: string = "HUR",
|
unitCode: string = "HUR",
|
||||||
) {
|
) {
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identifier before you can record a give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description && !amountInput) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
const result = await createAndSubmitGive(
|
const result = await createAndSubmitGive(
|
||||||
@@ -251,9 +293,9 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.apiServer,
|
this.apiServer,
|
||||||
identity,
|
identity,
|
||||||
giverDid,
|
giverDid,
|
||||||
this.givenToUser ? this.activeDid : undefined,
|
this.receiver?.did as string,
|
||||||
description,
|
description,
|
||||||
amountInput,
|
amount,
|
||||||
unitCode,
|
unitCode,
|
||||||
this.projectId,
|
this.projectId,
|
||||||
this.offerId,
|
this.offerId,
|
||||||
@@ -285,11 +327,14 @@ export default class GiftedDialog extends Vue {
|
|||||||
},
|
},
|
||||||
7000,
|
7000,
|
||||||
);
|
);
|
||||||
|
if (this.callbackOnSuccess) {
|
||||||
|
this.callbackOnSuccess(amount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error with give recordation caught:", error);
|
console.error("Error with give recordation caught:", error);
|
||||||
const message =
|
const errorMessage =
|
||||||
error.userMessage ||
|
error.userMessage ||
|
||||||
error.response?.data?.error?.message ||
|
error.response?.data?.error?.message ||
|
||||||
"There was an error recording the give.";
|
"There was an error recording the give.";
|
||||||
@@ -298,7 +343,7 @@ export default class GiftedDialog extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: message,
|
text: errorMessage,
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -328,6 +373,18 @@ export default class GiftedDialog extends Vue {
|
|||||||
result.response?.data?.error?.message
|
result.response?.data?.error?.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
explainData() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Data Sharing",
|
||||||
|
text: libsUtil.PRIVACY_MESSAGE,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<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">Here's one:</h1>
|
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||||
|
Here's one:
|
||||||
|
<div
|
||||||
|
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
<fa icon="xmark" class="w-[1em]"></fa>
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
<span class="flex justify-between">
|
<span class="flex justify-between">
|
||||||
<span
|
<span
|
||||||
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
|
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
|
||||||
@@ -33,7 +41,7 @@
|
|||||||
<span class="flex justify-between">
|
<span class="flex justify-between">
|
||||||
<span />
|
<span />
|
||||||
<button
|
<button
|
||||||
class="text-center bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4"
|
class="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.5 py-2 rounded-md mt-4"
|
||||||
@click="nextIdeaPastContacts()"
|
@click="nextIdeaPastContacts()"
|
||||||
>
|
>
|
||||||
Skip Contacts <fa icon="forward" />
|
Skip Contacts <fa icon="forward" />
|
||||||
@@ -52,7 +60,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4"
|
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 mt-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
>
|
||||||
That's it!
|
That's it!
|
||||||
|
|||||||
177
src/components/ImageMethodDialog.vue
Normal file
177
src/components/ImageMethodDialog.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||||
|
<div class="dialog relative">
|
||||||
|
<div class="text-lg text-center font-light relative z-50">
|
||||||
|
<div
|
||||||
|
id="ViewHeading"
|
||||||
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||||
|
>
|
||||||
|
Camera or Other?
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||||
|
@click="close()"
|
||||||
|
>
|
||||||
|
<fa icon="xmark" class="w-[1em]"></fa>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-center mt-8">
|
||||||
|
<div class>
|
||||||
|
<fa
|
||||||
|
icon="camera"
|
||||||
|
class="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-2 rounded-md"
|
||||||
|
@click="openPhotoDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<input type="file" @change="uploadImageFile" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<span class="mt-2">
|
||||||
|
... or paste a URL:
|
||||||
|
<input type="text" v-model="imageUrl" class="border-2" />
|
||||||
|
</span>
|
||||||
|
<span class="ml-2">
|
||||||
|
<fa
|
||||||
|
v-if="imageUrl"
|
||||||
|
icon="check"
|
||||||
|
class="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-2 rounded-md cursor-pointer"
|
||||||
|
@click="acceptUrl"
|
||||||
|
/>
|
||||||
|
<!-- so that there's no shifting when it becomes visible -->
|
||||||
|
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PhotoDialog ref="photoDialog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios from "axios";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import PhotoDialog from "@/components/PhotoDialog.vue";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
const inputImageFileNameRef = ref<Blob>();
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: { PhotoDialog },
|
||||||
|
})
|
||||||
|
export default class ImageMethodDialog extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
claimType: string;
|
||||||
|
crop: boolean = false;
|
||||||
|
imageCallback: (imageUrl?: string) => void = () => {};
|
||||||
|
imageUrl?: string;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||||
|
this.claimType = claimType;
|
||||||
|
this.crop = !!crop;
|
||||||
|
this.imageCallback = setImageFn;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
openPhotoDialog(blob?: Blob, fileName?: string) {
|
||||||
|
this.visible = false;
|
||||||
|
|
||||||
|
(this.$refs.photoDialog as PhotoDialog).open(
|
||||||
|
this.imageCallback,
|
||||||
|
this.claimType,
|
||||||
|
this.crop,
|
||||||
|
blob,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadImageFile(event: Event) {
|
||||||
|
this.visible = false;
|
||||||
|
|
||||||
|
inputImageFileNameRef.value = event.target.files[0];
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||||
|
// ... plus it has a `type` property from my testing
|
||||||
|
const file = inputImageFileNameRef.value;
|
||||||
|
if (file != null) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const data = e.target?.result as ArrayBuffer;
|
||||||
|
if (data) {
|
||||||
|
const blob = new Blob([new Uint8Array(data)], {
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
this.openPhotoDialog(blob, file.name as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file as Blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptUrl() {
|
||||||
|
this.visible = false;
|
||||||
|
if (this.crop) {
|
||||||
|
try {
|
||||||
|
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
||||||
|
responseType: "blob", // This ensures the data is returned as a Blob
|
||||||
|
});
|
||||||
|
const fullUrl = new URL(this.imageUrl as string);
|
||||||
|
const fileName = fullUrl.pathname.split("/").pop() as string;
|
||||||
|
(this.$refs.photoDialog as PhotoDialog).open(
|
||||||
|
this.imageCallback,
|
||||||
|
this.claimType,
|
||||||
|
this.crop,
|
||||||
|
urlBlobResponse.data as Blob,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error retrieving that image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.imageCallback(this.imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,30 +43,33 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||||
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
|
:placeholder="datePlaceholder()"
|
||||||
v-model="expirationDateInput"
|
v-model="expirationDateInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center mt-6 mb-2 italic">
|
<p class="text-center mt-6 mb-2 italic">
|
||||||
Sign & Send to publish to the world
|
Sign & Send to publish to the world
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
<button
|
||||||
@click="confirm"
|
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"
|
||||||
>
|
@click="confirm"
|
||||||
Sign & Send
|
>
|
||||||
</button>
|
Sign & Send
|
||||||
<button
|
</button>
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
<button
|
||||||
@click="cancel"
|
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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
@@ -79,8 +82,7 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|||||||
export default class OfferDialog extends Vue {
|
export default class OfferDialog extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
@Prop message = "";
|
@Prop projectId? = "";
|
||||||
@Prop projectId = "";
|
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
@@ -89,16 +91,20 @@ export default class OfferDialog extends Vue {
|
|||||||
amountUnitCode = "HUR";
|
amountUnitCode = "HUR";
|
||||||
description = "";
|
description = "";
|
||||||
expirationDateInput = "";
|
expirationDateInput = "";
|
||||||
|
recipientDid? = "";
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
libsUtil = libsUtil;
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
async open() {
|
async open(recipientDid?: string) {
|
||||||
try {
|
try {
|
||||||
|
this.recipientDid = recipientDid;
|
||||||
|
|
||||||
await db.open();
|
await db.open();
|
||||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error retrieving settings from database:", err);
|
console.error("Error retrieving settings from database:", err);
|
||||||
@@ -138,6 +144,12 @@ export default class OfferDialog extends Vue {
|
|||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
datePlaceholder() {
|
||||||
|
return (
|
||||||
|
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.close();
|
this.close();
|
||||||
this.eraseValues();
|
this.eraseValues();
|
||||||
@@ -220,6 +232,7 @@ export default class OfferDialog extends Vue {
|
|||||||
amount,
|
amount,
|
||||||
unitCode,
|
unitCode,
|
||||||
expirationDateInput,
|
expirationDateInput,
|
||||||
|
this.recipientDid,
|
||||||
this.projectId,
|
this.projectId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
441
src/components/PhotoDialog.vue
Normal file
441
src/components/PhotoDialog.vue
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||||
|
<div class="dialog relative">
|
||||||
|
<div class="text-lg text-center font-light relative z-50">
|
||||||
|
<div
|
||||||
|
id="ViewHeading"
|
||||||
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||||
|
>
|
||||||
|
<span v-if="uploading"> Uploading... </span>
|
||||||
|
<span v-else-if="blob"> Look Good? </span>
|
||||||
|
<span v-else> Say "Cheese"! </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||||
|
@click="close()"
|
||||||
|
>
|
||||||
|
<fa icon="xmark" class="w-[1em]"></fa>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="flex justify-center">
|
||||||
|
<fa
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin fa-3x text-center block px-12 py-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="blob">
|
||||||
|
<div v-if="crop">
|
||||||
|
<VuePictureCropper
|
||||||
|
:boxStyle="{
|
||||||
|
backgroundColor: '#f8f8f8',
|
||||||
|
margin: 'auto',
|
||||||
|
}"
|
||||||
|
:img="createBlobURL(blob)"
|
||||||
|
:options="{
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: 'crop',
|
||||||
|
aspectRatio: 9 / 9,
|
||||||
|
}"
|
||||||
|
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||||
|
/>
|
||||||
|
<!-- This gives a round cropper.
|
||||||
|
:presetMode="{
|
||||||
|
mode: 'round',
|
||||||
|
}"
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<img
|
||||||
|
:src="createBlobURL(blob)"
|
||||||
|
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||||||
|
<button
|
||||||
|
@click="uploadImage"
|
||||||
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||||
|
>
|
||||||
|
<span>Upload</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showRetry"
|
||||||
|
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="retryImage"
|
||||||
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||||
|
>
|
||||||
|
<span>Retry</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else ref="cameraContainer">
|
||||||
|
<!--
|
||||||
|
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
|
||||||
|
:resolution="{ width: 375, height: 812 }"
|
||||||
|
-->
|
||||||
|
<camera
|
||||||
|
facingMode="environment"
|
||||||
|
autoplay
|
||||||
|
ref="camera"
|
||||||
|
@started="cameraStarted()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="takeImage()"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||||
|
>
|
||||||
|
<fa icon="camera" class="w-[1em]"></fa>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="swapMirrorClass()"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||||
|
>
|
||||||
|
<fa icon="left-right" class="w-[1em]"></fa>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
||||||
|
<button
|
||||||
|
@click="switchCamera()"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||||
|
>
|
||||||
|
<fa icon="rotate" class="w-[1em]"></fa>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</camera>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios from "axios";
|
||||||
|
import Camera from "simple-vue-camera";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||||
|
|
||||||
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||||
|
import { getIdentity } from "@/libs/util";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
|
||||||
|
@Component({ components: { Camera, VuePictureCropper } })
|
||||||
|
export default class PhotoDialog extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
activeDeviceNumber = 0;
|
||||||
|
activeDid = "";
|
||||||
|
blob?: Blob;
|
||||||
|
claimType = "";
|
||||||
|
crop = false;
|
||||||
|
fileName?: string;
|
||||||
|
mirror = false;
|
||||||
|
numDevices = 0;
|
||||||
|
setImageCallback: (arg: string) => void = () => {};
|
||||||
|
showRetry = true;
|
||||||
|
uploading = false;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
URL = window.URL || window.webkitURL;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error retrieving settings from database:", err);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: err.message || "There was an error retrieving your settings.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open(
|
||||||
|
setImageFn: (arg: string) => void,
|
||||||
|
claimType: string,
|
||||||
|
crop?: boolean,
|
||||||
|
blob?: Blob, // for image upload, just to use the cropping function
|
||||||
|
inputFileName?: string,
|
||||||
|
) {
|
||||||
|
this.visible = true;
|
||||||
|
this.claimType = claimType;
|
||||||
|
this.crop = !!crop;
|
||||||
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||||
|
if (bottomNav) {
|
||||||
|
bottomNav.style.display = "none";
|
||||||
|
}
|
||||||
|
this.setImageCallback = setImageFn;
|
||||||
|
if (blob) {
|
||||||
|
this.blob = blob;
|
||||||
|
this.fileName = inputFileName;
|
||||||
|
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
|
||||||
|
this.showRetry = false;
|
||||||
|
} else {
|
||||||
|
this.blob = undefined;
|
||||||
|
this.fileName = undefined;
|
||||||
|
this.showRetry = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.visible = false;
|
||||||
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||||
|
if (bottomNav) {
|
||||||
|
bottomNav.style.display = "";
|
||||||
|
}
|
||||||
|
this.blob = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cameraStarted() {
|
||||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||||
|
if (cameraComponent) {
|
||||||
|
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
||||||
|
this.mirror = cameraComponent.facingMode === "user";
|
||||||
|
// figure out which device is active
|
||||||
|
const currentDeviceId = cameraComponent.currentDeviceID();
|
||||||
|
const devices = await cameraComponent.devices(["videoinput"]);
|
||||||
|
this.activeDeviceNumber = devices.findIndex(
|
||||||
|
(device) => device.deviceId === currentDeviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchCamera() {
|
||||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||||
|
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
||||||
|
const devices = await cameraComponent?.devices(["videoinput"]);
|
||||||
|
await cameraComponent?.changeCamera(
|
||||||
|
devices[this.activeDeviceNumber].deviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeImage(/* payload: MouseEvent */) {
|
||||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This logic to set the image height & width correctly.
|
||||||
|
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
|
||||||
|
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
|
||||||
|
* Now that I've done it, I can't explain why it works.
|
||||||
|
*/
|
||||||
|
let imageHeight = cameraComponent?.resolution?.height;
|
||||||
|
let imageWidth = cameraComponent?.resolution?.width;
|
||||||
|
const initialImageRatio = imageWidth / imageHeight;
|
||||||
|
const windowRatio = window.innerWidth / window.innerHeight;
|
||||||
|
if (initialImageRatio > 1 && windowRatio < 1) {
|
||||||
|
// the image is wider than it is tall, and the window is taller than it is wide
|
||||||
|
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
|
||||||
|
// We're gonna force it opposite.
|
||||||
|
imageHeight = cameraComponent?.resolution?.width;
|
||||||
|
imageWidth = cameraComponent?.resolution?.height;
|
||||||
|
} else if (initialImageRatio < 1 && windowRatio > 1) {
|
||||||
|
// the image is taller than it is wide, and the window is wider than it is tall
|
||||||
|
// Haven't seen this happen, but we'll do it just in case.
|
||||||
|
imageHeight = cameraComponent?.resolution?.width;
|
||||||
|
imageWidth = cameraComponent?.resolution?.height;
|
||||||
|
}
|
||||||
|
const newImageRatio = imageWidth / imageHeight;
|
||||||
|
if (newImageRatio < windowRatio) {
|
||||||
|
// the image is a taller ratio than the window, so fit the height first
|
||||||
|
imageHeight = window.innerHeight / 2;
|
||||||
|
imageWidth = imageHeight * newImageRatio;
|
||||||
|
} else {
|
||||||
|
// the image is a wider ratio than the window, so fit the width first
|
||||||
|
imageWidth = window.innerWidth / 2;
|
||||||
|
imageHeight = imageWidth / newImageRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolution is only necessary because of that mobile portrait-orientation case.
|
||||||
|
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
|
||||||
|
this.blob =
|
||||||
|
(await cameraComponent?.snapshot({
|
||||||
|
height: imageHeight,
|
||||||
|
width: imageWidth,
|
||||||
|
})) || undefined;
|
||||||
|
// png is default
|
||||||
|
this.fileName = "snapshot.png";
|
||||||
|
if (!this.blob) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error taking the picture. Please try again.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBlobURL(blob: Blob): string {
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryImage() {
|
||||||
|
this.blob = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/****
|
||||||
|
|
||||||
|
Here's an approach to photo capture without a library. It has similar quirks.
|
||||||
|
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
|
||||||
|
|
||||||
|
<button id="start-camera" @click="cameraClicked">Start Camera</button>
|
||||||
|
<video id="video" width="320" height="240" autoplay></video>
|
||||||
|
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
|
||||||
|
<canvas id="canvas" width="320" height="240"></canvas>
|
||||||
|
|
||||||
|
async cameraClicked() {
|
||||||
|
const video = document.querySelector("#video");
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: true,
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
if (video instanceof HTMLVideoElement) {
|
||||||
|
video.srcObject = stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photoSnapped() {
|
||||||
|
const video = document.querySelector("#video");
|
||||||
|
const canvas = document.querySelector("#canvas");
|
||||||
|
if (
|
||||||
|
canvas instanceof HTMLCanvasElement &&
|
||||||
|
video instanceof HTMLVideoElement
|
||||||
|
) {
|
||||||
|
canvas
|
||||||
|
?.getContext("2d")
|
||||||
|
?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
// ... or set the blob:
|
||||||
|
// canvas?.toBlob(
|
||||||
|
// (blob) => {
|
||||||
|
// this.blob = blob;
|
||||||
|
// },
|
||||||
|
// "image/jpeg",
|
||||||
|
// 1,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// data url of the image
|
||||||
|
const image_data_url = canvas?.toDataURL("image/jpeg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
****/
|
||||||
|
|
||||||
|
async uploadImage() {
|
||||||
|
this.uploading = true;
|
||||||
|
|
||||||
|
if (this.crop) {
|
||||||
|
this.blob = (await cropper?.getBlob()) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = await getIdentity(this.activeDid);
|
||||||
|
const token = await accessToken(identifier);
|
||||||
|
const headers = {
|
||||||
|
Authorization: "Bearer " + token,
|
||||||
|
};
|
||||||
|
const formData = new FormData();
|
||||||
|
if (!this.blob) {
|
||||||
|
// yeah, this should never happen, but it helps with subsequent type checking
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error finding the picture. Please try again.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
this.uploading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.append("image", this.blob, this.fileName || "snapshot.png");
|
||||||
|
formData.append("claimType", this.claimType);
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||||
|
formData,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
this.uploading = false;
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
this.setImageCallback(response.data.url as string);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading the image", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error saving the picture.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
this.uploading = false;
|
||||||
|
this.blob = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
swapMirrorClass() {
|
||||||
|
this.mirror = !this.mirror;
|
||||||
|
if (this.mirror) {
|
||||||
|
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
|
||||||
|
} else {
|
||||||
|
(this.$refs.cameraContainer as HTMLElement).classList.remove(
|
||||||
|
"mirror-video",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirror-video {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
-webkit-transform: scaleX(-1); /* For Safari */
|
||||||
|
-moz-transform: scaleX(-1); /* For Firefox */
|
||||||
|
-ms-transform: scaleX(-1); /* For IE */
|
||||||
|
-o-transform: scaleX(-1); /* For Opera */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-html="generateIdenticon()" class="w-fit"></div>
|
<a
|
||||||
|
v-if="linkToFull && imageUrl"
|
||||||
|
:href="imageUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
>
|
||||||
|
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-html="generateIdenticon()"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toSvg } from "jdenticon";
|
import { toSvg } from "jdenticon";
|
||||||
@@ -21,11 +33,17 @@ const BLANK_CONFIG = {
|
|||||||
export default class ProjectIcon extends Vue {
|
export default class ProjectIcon extends Vue {
|
||||||
@Prop entityId = "";
|
@Prop entityId = "";
|
||||||
@Prop iconSize = 0;
|
@Prop iconSize = 0;
|
||||||
|
@Prop imageUrl = "";
|
||||||
|
@Prop linkToFull = false;
|
||||||
|
|
||||||
generateIdenticon() {
|
generateIdenticon() {
|
||||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
if (this.imageUrl) {
|
||||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
|
||||||
return svgString;
|
} else {
|
||||||
|
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||||
|
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||||
|
return svgString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
<fa icon="house-chimney" class="fa-fw" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
:to="{ name: 'discover' }"
|
:to="{ name: 'discover' }"
|
||||||
class="block text-center py-3 px-1"
|
class="block text-center py-3 px-1"
|
||||||
>
|
>
|
||||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
<fa icon="magnifying-glass" class="fa-fw" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<!-- Projects -->
|
<!-- Projects -->
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
:to="{ name: 'projects' }"
|
:to="{ name: 'projects' }"
|
||||||
class="block text-center py-3 px-1"
|
class="block text-center py-3 px-1"
|
||||||
>
|
>
|
||||||
<fa icon="folder-open" class="fa-fw"></fa>
|
<fa icon="hand" class="fa-fw" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<!-- Contacts -->
|
<!-- Contacts -->
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
:to="{ name: 'contacts' }"
|
:to="{ name: 'contacts' }"
|
||||||
class="block text-center py-3 px-1"
|
class="block text-center py-3 px-1"
|
||||||
>
|
>
|
||||||
<fa icon="users" class="fa-fw"></fa>
|
<fa icon="users" class="fa-fw" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
:to="{ name: 'account' }"
|
:to="{ name: 'account' }"
|
||||||
class="block text-center py-3 px-1"
|
class="block text-center py-3 px-1"
|
||||||
>
|
>
|
||||||
<fa icon="circle-user" class="fa-fw"></fa>
|
<fa icon="circle-user" class="fa-fw" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -12,14 +12,26 @@ export enum AppString {
|
|||||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||||
|
|
||||||
|
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
|
||||||
|
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
|
||||||
|
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
|
||||||
|
|
||||||
NO_CONTACT_NAME = "(no name)",
|
NO_CONTACT_NAME = "(no name)",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER;
|
export const DEFAULT_ENDORSER_API_SERVER =
|
||||||
|
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||||
|
AppString.TEST_ENDORSER_API_SERVER;
|
||||||
|
|
||||||
|
export const DEFAULT_IMAGE_API_SERVER =
|
||||||
|
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
|
||||||
|
AppString.TEST_IMAGE_API_SERVER;
|
||||||
|
|
||||||
export const DEFAULT_PUSH_SERVER =
|
export const DEFAULT_PUSH_SERVER =
|
||||||
window.location.protocol + "//" + window.location.host;
|
window.location.protocol + "//" + window.location.host;
|
||||||
|
|
||||||
|
export const IMAGE_TYPE_PROFILE = "profile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The possible values for "group" and "type" are in App.vue.
|
* The possible values for "group" and "type" are in App.vue.
|
||||||
* From the notiwind package
|
* From the notiwind package
|
||||||
@@ -28,5 +40,9 @@ export interface NotificationIface {
|
|||||||
group: string; // "alert" | "modal"
|
group: string; // "alert" | "modal"
|
||||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text?: string;
|
||||||
|
onCancel?: (stopAsking: boolean) => Promise<void>;
|
||||||
|
onNo?: (stopAsking: boolean) => Promise<void>;
|
||||||
|
onYes?: () => Promise<void>;
|
||||||
|
promptToStopAsking?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
SettingsSchema,
|
SettingsSchema,
|
||||||
} from "./tables/settings";
|
} from "./tables/settings";
|
||||||
|
import { Temp, TempSchema } from "./tables/temp";
|
||||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||||
|
|
||||||
// Define types for tables that hold sensitive and non-sensitive data
|
// Define types for tables that hold sensitive and non-sensitive data
|
||||||
@@ -16,6 +17,7 @@ type NonsensitiveTables = {
|
|||||||
contacts: Table<Contact>;
|
contacts: Table<Contact>;
|
||||||
logs: Table<Log>;
|
logs: Table<Log>;
|
||||||
settings: Table<Settings>;
|
settings: Table<Settings>;
|
||||||
|
temp: Table<Temp>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||||
@@ -25,14 +27,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
|||||||
|
|
||||||
// Initialize Dexie databases for sensitive and non-sensitive data
|
// Initialize Dexie databases for sensitive and non-sensitive data
|
||||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||||
const SensitiveSchemas = { ...AccountsSchema };
|
|
||||||
|
|
||||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||||
const NonsensitiveSchemas = {
|
|
||||||
...ContactSchema,
|
|
||||||
...LogSchema,
|
|
||||||
...SettingsSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Manage the encryption key. If not present in localStorage, create and store it.
|
// Manage the encryption key. If not present in localStorage, create and store it.
|
||||||
const secret =
|
const secret =
|
||||||
@@ -42,11 +37,18 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
|||||||
// Apply encryption to the sensitive database using the secret key
|
// Apply encryption to the sensitive database using the secret key
|
||||||
encrypted(accountsDB, { secretKey: secret });
|
encrypted(accountsDB, { secretKey: secret });
|
||||||
|
|
||||||
// Define the schema for our databases
|
// Define the schemas for our databases
|
||||||
accountsDB.version(1).stores(SensitiveSchemas);
|
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
||||||
// v1 was contacts & settings
|
accountsDB.version(1).stores(AccountsSchema);
|
||||||
// v2 added logs
|
// v1 also had contacts & settings
|
||||||
db.version(2).stores(NonsensitiveSchemas);
|
// v2 added Log
|
||||||
|
db.version(2).stores({
|
||||||
|
...ContactSchema,
|
||||||
|
...LogSchema,
|
||||||
|
...SettingsSchema,
|
||||||
|
});
|
||||||
|
// v3 added Temp
|
||||||
|
db.version(3).stores(TempSchema);
|
||||||
|
|
||||||
// Event handler to initialize the non-sensitive database with default settings
|
// Event handler to initialize the non-sensitive database with default settings
|
||||||
db.on("populate", () => {
|
db.on("populate", () => {
|
||||||
|
|||||||
@@ -3,41 +3,46 @@
|
|||||||
*/
|
*/
|
||||||
export type Account = {
|
export type Account = {
|
||||||
/**
|
/**
|
||||||
* Auto-generated ID by Dexie.
|
* Auto-generated ID by Dexie
|
||||||
*/
|
*/
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The date the account was created.
|
* The date the account was created
|
||||||
*/
|
*/
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The derivation path for the account.
|
* The derivation path for the account, if this is from a mnemonic
|
||||||
*/
|
*/
|
||||||
derivationPath: string;
|
derivationPath?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decentralized Identifier (DID) for the account.
|
* Decentralized Identifier (DID) for the account
|
||||||
*/
|
*/
|
||||||
did: string;
|
did: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stringified JSON containing underlying key material.
|
* Stringified JSON containing underlying key material, if generated from a mnemonic
|
||||||
* Based on the IIdentifier type from Veramo.
|
* Based on the IIdentifier type from Veramo
|
||||||
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
||||||
*/
|
*/
|
||||||
identity: string;
|
identity?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public key in hexadecimal format.
|
* The mnemonic phrase for the account, if this is from a mnemonic
|
||||||
|
*/
|
||||||
|
mnemonic?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Webauthn credential ID in hex, if this is from a passkey
|
||||||
|
*/
|
||||||
|
passkeyCredIdHex?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public key in hexadecimal format
|
||||||
*/
|
*/
|
||||||
publicKeyHex: string;
|
publicKeyHex: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* The mnemonic passphrase for the account.
|
|
||||||
*/
|
|
||||||
mnemonic: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface Contact {
|
|||||||
did: string;
|
did: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||||
|
profileImageUrl?: string;
|
||||||
publicKeyBase64?: string;
|
publicKeyBase64?: string;
|
||||||
seesMe?: boolean;
|
seesMe?: boolean;
|
||||||
registered?: boolean;
|
registered?: boolean;
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ export type Settings = {
|
|||||||
|
|
||||||
activeDid?: string; // Active Decentralized ID
|
activeDid?: string; // Active Decentralized ID
|
||||||
apiServer?: string; // API server URL
|
apiServer?: string; // API server URL
|
||||||
firstName?: string; // User's first name
|
|
||||||
|
filterFeedByNearby?: boolean; // filter by nearby
|
||||||
|
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
|
||||||
|
|
||||||
|
firstName?: string; // user's full name
|
||||||
|
hideRegisterPromptOnNewContact?: boolean;
|
||||||
isRegistered?: boolean;
|
isRegistered?: boolean;
|
||||||
lastName?: string; // deprecated - put all names in firstName
|
lastName?: string; // deprecated - put all names in firstName
|
||||||
lastNotifiedClaimId?: string; // Last notified claim ID
|
lastNotifiedClaimId?: string;
|
||||||
lastViewedClaimId?: string; // Last viewed claim ID
|
lastViewedClaimId?: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
||||||
reminderOn?: boolean; // Toggle to enable or disable reminders
|
reminderOn?: boolean; // Toggle to enable or disable reminders
|
||||||
|
|
||||||
@@ -31,13 +37,17 @@ export type Settings = {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
showContactGivesInline?: boolean; // Display contact inline or not
|
showContactGivesInline?: boolean; // Display contact inline or not
|
||||||
showShortcutBvc?: boolean; // Show shortcut for BVC actions
|
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
|
||||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||||
warnIfProdServer?: boolean; // Warn if using a production server
|
warnIfProdServer?: boolean; // Warn if using a production server
|
||||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||||
webPushServer?: string; // Web Push server URL
|
webPushServer?: string; // Web Push server URL
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isAnyFeedFilterOn(settings: Settings): boolean {
|
||||||
|
return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for the Settings table in the database.
|
* Schema for the Settings table in the database.
|
||||||
*/
|
*/
|
||||||
|
|||||||
13
src/db/tables/temp.ts
Normal file
13
src/db/tables/temp.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
||||||
|
|
||||||
|
export type Temp = {
|
||||||
|
id: string;
|
||||||
|
blob?: Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for the Temp table in the database.
|
||||||
|
*/
|
||||||
|
export const TempSchema = {
|
||||||
|
temp: "id",
|
||||||
|
};
|
||||||
@@ -11,6 +11,8 @@ import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
|||||||
|
|
||||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
||||||
|
|
||||||
|
export const LOCAL_KMS_NAME = "local";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@@ -31,7 +33,7 @@ export const newIdentifier = (
|
|||||||
keys: [
|
keys: [
|
||||||
{
|
{
|
||||||
kid: publicHex,
|
kid: publicHex,
|
||||||
kms: "local",
|
kms: LOCAL_KMS_NAME,
|
||||||
meta: { derivationPath: derivationPath },
|
meta: { derivationPath: derivationPath },
|
||||||
privateKeyHex: privateHex,
|
privateKeyHex: privateHex,
|
||||||
publicKeyHex: publicHex,
|
publicKeyHex: publicHex,
|
||||||
@@ -64,6 +66,10 @@ export const deriveAddress = (
|
|||||||
return [address, privateHex, publicHex, derivationPath];
|
return [address, privateHex, publicHex, derivationPath];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateRandomBytes = (numBytes: number): Uint8Array => {
|
||||||
|
return getRandomBytesSync(numBytes);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@@ -113,7 +119,7 @@ export const sign = async (privateKeyHex: string) => {
|
|||||||
* The SimpleSigner returns a configured function for signing data.
|
* The SimpleSigner returns a configured function for signing data.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
|
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
|
||||||
* signer(data, (err, signature) => {
|
* signer(data, (err, signature) => {
|
||||||
* ...
|
* ...
|
||||||
* })
|
* })
|
||||||
|
|||||||
102
src/libs/crypto/passkeyHelpers.ts
Normal file
102
src/libs/crypto/passkeyHelpers.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
||||||
|
import { AsnParser } from "@peculiar/asn1-schema";
|
||||||
|
import { ECDSASigValue } from "@peculiar/asn1-ecc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
|
||||||
|
*
|
||||||
|
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
||||||
|
*/
|
||||||
|
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
|
||||||
|
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
|
||||||
|
let rBytes = new Uint8Array(parsedSignature.r);
|
||||||
|
let sBytes = new Uint8Array(parsedSignature.s);
|
||||||
|
|
||||||
|
if (shouldRemoveLeadingZero(rBytes)) {
|
||||||
|
rBytes = rBytes.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRemoveLeadingZero(sBytes)) {
|
||||||
|
sBytes = sBytes.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
|
||||||
|
|
||||||
|
return finalSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
|
||||||
|
* should be removed based on the following logic:
|
||||||
|
*
|
||||||
|
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
|
||||||
|
* then remove the leading 0x0 byte"
|
||||||
|
*/
|
||||||
|
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
|
||||||
|
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
||||||
|
/**
|
||||||
|
* Combine multiple Uint8Arrays into a single Uint8Array
|
||||||
|
*/
|
||||||
|
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
let pointer = 0;
|
||||||
|
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
|
||||||
|
|
||||||
|
const toReturn = new Uint8Array(totalLength);
|
||||||
|
|
||||||
|
arrays.forEach((arr) => {
|
||||||
|
toReturn.set(arr, pointer);
|
||||||
|
pointer += arr.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
||||||
|
let webCrypto: unknown = undefined;
|
||||||
|
export function getWebCrypto() {
|
||||||
|
/**
|
||||||
|
* Hello there! If you came here wondering why this method is asynchronous when use of
|
||||||
|
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
|
||||||
|
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
|
||||||
|
* become synchronous if we make this synchronous (since nothing else in that method is async)
|
||||||
|
* which represents a breaking API change in this library's core API.
|
||||||
|
*
|
||||||
|
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
|
||||||
|
* to keep this method asynchronous.
|
||||||
|
*/
|
||||||
|
const toResolve = new Promise((resolve, reject) => {
|
||||||
|
if (webCrypto) {
|
||||||
|
return resolve(webCrypto);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
|
||||||
|
* support (and Node v20+)
|
||||||
|
*/
|
||||||
|
const _globalThisCrypto = _getWebCryptoInternals.stubThisGlobalThisCrypto();
|
||||||
|
if (_globalThisCrypto) {
|
||||||
|
webCrypto = _globalThisCrypto;
|
||||||
|
return resolve(webCrypto);
|
||||||
|
}
|
||||||
|
// We tried to access it both in Node and globally, so bail out
|
||||||
|
return reject(new MissingWebCrypto());
|
||||||
|
});
|
||||||
|
return toResolve;
|
||||||
|
}
|
||||||
|
export class MissingWebCrypto extends Error {
|
||||||
|
constructor() {
|
||||||
|
const message = "An instance of the Crypto API could not be located";
|
||||||
|
super(message);
|
||||||
|
this.name = "MissingWebCrypto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Make it possible to stub return values during testing
|
||||||
|
export const _getWebCryptoInternals = {
|
||||||
|
stubThisGlobalThisCrypto: () => globalThis.crypto,
|
||||||
|
// Make it possible to reset the `webCrypto` at the top of the file
|
||||||
|
setCachedCrypto: (newCrypto: unknown) => {
|
||||||
|
webCrypto = newCrypto;
|
||||||
|
},
|
||||||
|
};
|
||||||
570
src/libs/didPeer.ts
Normal file
570
src/libs/didPeer.ts
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
import asn1 from "asn1-ber";
|
||||||
|
import { Buffer } from "buffer/";
|
||||||
|
import { decode as cborDecode } from "cbor-x";
|
||||||
|
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt";
|
||||||
|
import { DIDResolutionResult } from "did-resolver";
|
||||||
|
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||||
|
import {
|
||||||
|
startAuthentication,
|
||||||
|
startRegistration,
|
||||||
|
} from "@simplewebauthn/browser";
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
|
||||||
|
import {
|
||||||
|
Base64URLString,
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
} from "@simplewebauthn/types";
|
||||||
|
|
||||||
|
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers";
|
||||||
|
|
||||||
|
const PEER_DID_PREFIX = "did:peer:";
|
||||||
|
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
|
||||||
|
export interface JWK {
|
||||||
|
kty: string;
|
||||||
|
crv: string;
|
||||||
|
x: string;
|
||||||
|
y: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(anythingB64: string) {
|
||||||
|
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayToBase64Url(anything: Uint8Array) {
|
||||||
|
return toBase64Url(Buffer.from(anything).toString("base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCredential(passkeyName?: string) {
|
||||||
|
const options: PublicKeyCredentialCreationOptionsJSON =
|
||||||
|
await generateRegistrationOptions({
|
||||||
|
rpName: "Time Safari",
|
||||||
|
rpID: window.location.hostname,
|
||||||
|
userName: passkeyName || "Time Safari User",
|
||||||
|
// Don't prompt users for additional information about the authenticator
|
||||||
|
// (Recommended for smoother UX)
|
||||||
|
attestationType: "none",
|
||||||
|
authenticatorSelection: {
|
||||||
|
// Defaults
|
||||||
|
residentKey: "preferred",
|
||||||
|
userVerification: "preferred",
|
||||||
|
// Optional
|
||||||
|
authenticatorAttachment: "platform",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
||||||
|
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
||||||
|
const attResp = await startRegistration(options);
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: attResp,
|
||||||
|
expectedChallenge: options.challenge,
|
||||||
|
expectedOrigin: window.location.origin,
|
||||||
|
expectedRPID: window.location.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
// references for parsing auth data and getting the public key
|
||||||
|
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
||||||
|
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
||||||
|
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
||||||
|
|
||||||
|
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
|
||||||
|
if (attResp.rawId !== credIdBase64Url) {
|
||||||
|
console.log("Warning! The raw ID does not match the credential ID.")
|
||||||
|
}
|
||||||
|
const credIdHex = Buffer.from(
|
||||||
|
base64URLStringToArrayBuffer(credIdBase64Url),
|
||||||
|
).toString("hex");
|
||||||
|
const { publicKeyJwk } = cborToKeys(
|
||||||
|
verification.registrationInfo?.credentialPublicKey as Uint8Array,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authData: verification.registrationInfo?.attestationObject,
|
||||||
|
credIdHex: credIdHex,
|
||||||
|
publicKeyJwk: publicKeyJwk,
|
||||||
|
publicKeyBytes: verification.registrationInfo
|
||||||
|
?.credentialPublicKey as Uint8Array,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeerDid(publicKeyBytes: Uint8Array) {
|
||||||
|
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
||||||
|
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
||||||
|
const methodSpecificId = bytesToMultibase(
|
||||||
|
publicKeyBytes,
|
||||||
|
"base58btc",
|
||||||
|
"p256-pub",
|
||||||
|
);
|
||||||
|
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function peerDidToPublicKeyBytes(did: string) {
|
||||||
|
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerSetup {
|
||||||
|
public authenticatorData?: ArrayBuffer;
|
||||||
|
public challenge?: Uint8Array;
|
||||||
|
public clientDataJsonBase64Url?: Base64URLString;
|
||||||
|
public signature?: Base64URLString;
|
||||||
|
|
||||||
|
public async createJwtSimplewebauthn(
|
||||||
|
issuerDid: string,
|
||||||
|
payload: object,
|
||||||
|
credIdHex: string,
|
||||||
|
) {
|
||||||
|
const credentialId = arrayBufferToBase64URLString(
|
||||||
|
Buffer.from(credIdHex, "hex").buffer,
|
||||||
|
);
|
||||||
|
const fullPayload = {
|
||||||
|
...payload,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: issuerDid,
|
||||||
|
};
|
||||||
|
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
|
||||||
|
// const payloadHash: Uint8Array = sha256(this.challenge);
|
||||||
|
const options: PublicKeyCredentialRequestOptionsJSON =
|
||||||
|
await generateAuthenticationOptions({
|
||||||
|
challenge: this.challenge,
|
||||||
|
rpID: window.location.hostname,
|
||||||
|
allowCredentials: [{ id: credentialId }],
|
||||||
|
});
|
||||||
|
// console.log("simple authentication options", options);
|
||||||
|
|
||||||
|
const clientAuth = await startAuthentication(options);
|
||||||
|
// console.log("simple credential get", clientAuth);
|
||||||
|
|
||||||
|
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
|
||||||
|
this.authenticatorData = Buffer.from(
|
||||||
|
clientAuth.response.authenticatorData,
|
||||||
|
"base64",
|
||||||
|
).buffer;
|
||||||
|
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
|
||||||
|
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
||||||
|
this.signature = clientAuth.response.signature;
|
||||||
|
|
||||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
||||||
|
const headerBase64 = Buffer.from(JSON.stringify(header))
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const dataInJwt = {
|
||||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
||||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: issuerDid,
|
||||||
|
};
|
||||||
|
const dataInJwtString = JSON.stringify(dataInJwt);
|
||||||
|
const payloadBase64 = Buffer.from(dataInJwtString)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const signature = clientAuth.response.signature;
|
||||||
|
|
||||||
|
return headerBase64 + "." + payloadBase64 + "." + signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createJwtNavigator(
|
||||||
|
issuerDid: string,
|
||||||
|
payload: object,
|
||||||
|
credIdHex: string,
|
||||||
|
) {
|
||||||
|
const fullPayload = {
|
||||||
|
...payload,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: issuerDid,
|
||||||
|
};
|
||||||
|
const dataToSignString = JSON.stringify(fullPayload);
|
||||||
|
const dataToSignBuffer = Buffer.from(dataToSignString);
|
||||||
|
const credentialId = Buffer.from(credIdHex, "hex");
|
||||||
|
|
||||||
|
// console.log("lower credentialId", credentialId);
|
||||||
|
this.challenge = new Uint8Array(dataToSignBuffer);
|
||||||
|
const options = {
|
||||||
|
publicKey: {
|
||||||
|
allowCredentials: [
|
||||||
|
{
|
||||||
|
id: credentialId,
|
||||||
|
type: "public-key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
challenge: this.challenge.buffer,
|
||||||
|
rpID: window.location.hostname,
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get(options);
|
||||||
|
// console.log("nav credential get", credential);
|
||||||
|
|
||||||
|
this.authenticatorData = credential?.response.authenticatorData;
|
||||||
|
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
|
||||||
|
this.authenticatorData,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
|
||||||
|
credential?.response.clientDataJSON,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
||||||
|
const headerBase64 = Buffer.from(JSON.stringify(header))
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const dataInJwt = {
|
||||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
||||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: issuerDid,
|
||||||
|
};
|
||||||
|
const dataInJwtString = JSON.stringify(dataInJwt);
|
||||||
|
const payloadBase64 = Buffer.from(dataInJwtString)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const origSignature = Buffer.from(credential?.response.signature)
|
||||||
|
.toString("base64")
|
||||||
|
this.signature = origSignature
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return a low-level signing function, similar to createJWS approach
|
||||||
|
// async webAuthnES256KSigner(credentialID: string) {
|
||||||
|
// return async (data: string | Uint8Array) => {
|
||||||
|
// // get signature from WebAuthn
|
||||||
|
// const signature = await this.generateWebAuthnSignature(data);
|
||||||
|
//
|
||||||
|
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
||||||
|
// const signatureBuffer = Buffer.from(signature);
|
||||||
|
// console.log("lower signature inside signer", signature);
|
||||||
|
// console.log("lower buffer signature inside signer", signatureBuffer);
|
||||||
|
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
||||||
|
// // Decode the DER-encoded signature to extract R and S values
|
||||||
|
// const reader = new asn1.BerReader(signatureBuffer);
|
||||||
|
// console.log("lower after reader");
|
||||||
|
// reader.readSequence();
|
||||||
|
// console.log("lower after read sequence");
|
||||||
|
// const r = reader.readString(asn1.Ber.Integer, true);
|
||||||
|
// console.log("lower after r");
|
||||||
|
// const s = reader.readString(asn1.Ber.Integer, true);
|
||||||
|
// console.log("lower after r & s");
|
||||||
|
//
|
||||||
|
// // Ensure R and S are 32 bytes each
|
||||||
|
// const rBuffer = Buffer.from(r);
|
||||||
|
// const sBuffer = Buffer.from(s);
|
||||||
|
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
||||||
|
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
||||||
|
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
||||||
|
// const rPadded =
|
||||||
|
// rWithoutPrefix.length < 32
|
||||||
|
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
||||||
|
// : rWithoutPrefix;
|
||||||
|
// const sPadded =
|
||||||
|
// rWithoutPrefix.length < 32
|
||||||
|
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
||||||
|
// : sWithoutPrefix;
|
||||||
|
//
|
||||||
|
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
||||||
|
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
||||||
|
// console.log(
|
||||||
|
// "lower combinedSignature",
|
||||||
|
// combinedSignature.length,
|
||||||
|
// combinedSignature,
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// const combSig64 = combinedSignature.toString("base64");
|
||||||
|
// console.log("lower combSig64", combSig64);
|
||||||
|
// const combSig64Url = combSig64
|
||||||
|
// .replace(/\+/g, "-")
|
||||||
|
// .replace(/\//g, "_")
|
||||||
|
// .replace(/=+$/, "");
|
||||||
|
// console.log("lower combSig64Url", combSig64Url);
|
||||||
|
// return combSig64Url;
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'd love to use this but it doesn't verify.
|
||||||
|
// Requires:
|
||||||
|
// npm install @noble/curves
|
||||||
|
// ... and this import:
|
||||||
|
// import { p256 } from "@noble/curves/p256";
|
||||||
|
export async function verifyJwtP256(
|
||||||
|
credIdHex: string,
|
||||||
|
issuerDid: string,
|
||||||
|
authenticatorData: ArrayBuffer,
|
||||||
|
challenge: Uint8Array,
|
||||||
|
clientDataJsonBase64Url: Base64URLString,
|
||||||
|
signature: Base64URLString,
|
||||||
|
) {
|
||||||
|
const authDataFromBase = Buffer.from(authenticatorData);
|
||||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
||||||
|
const sigBuffer = Buffer.from(signature, "base64");
|
||||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
||||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||||
|
|
||||||
|
// Hash the client data
|
||||||
|
const hash = sha256(clientDataFromBase);
|
||||||
|
|
||||||
|
// Construct the preimage
|
||||||
|
const preimage = Buffer.concat([authDataFromBase, hash]);
|
||||||
|
|
||||||
|
const isValid = p256.verify(
|
||||||
|
finalSigBuffer,
|
||||||
|
new Uint8Array(preimage),
|
||||||
|
publicKeyBytes,
|
||||||
|
);
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJwtSimplewebauthn(
|
||||||
|
credIdHex: string,
|
||||||
|
issuerDid: string,
|
||||||
|
authenticatorData: ArrayBuffer,
|
||||||
|
challenge: Uint8Array,
|
||||||
|
clientDataJsonBase64Url: Base64URLString,
|
||||||
|
signature: Base64URLString,
|
||||||
|
) {
|
||||||
|
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
|
||||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||||
|
const credId = arrayBufferToBase64URLString(
|
||||||
|
Buffer.from(credIdHex, "hex").buffer,
|
||||||
|
);
|
||||||
|
const authOpts: VerifyAuthenticationResponseOpts = {
|
||||||
|
authenticator: {
|
||||||
|
credentialID: credId,
|
||||||
|
credentialPublicKey: publicKeyBytes,
|
||||||
|
counter: 0,
|
||||||
|
},
|
||||||
|
expectedChallenge: arrayToBase64Url(challenge),
|
||||||
|
expectedOrigin: window.location.origin,
|
||||||
|
expectedRPID: window.location.hostname,
|
||||||
|
response: {
|
||||||
|
authenticatorAttachment: "platform",
|
||||||
|
clientExtensionResults: {},
|
||||||
|
id: credId,
|
||||||
|
rawId: credId,
|
||||||
|
response: {
|
||||||
|
authenticatorData: authData,
|
||||||
|
clientDataJSON: clientDataJsonBase64Url,
|
||||||
|
signature: signature,
|
||||||
|
},
|
||||||
|
type: "public-key",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const verification = await verifyAuthenticationResponse(authOpts);
|
||||||
|
return verification.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJwtWebCrypto(
|
||||||
|
credId: Base64URLString,
|
||||||
|
issuerDid: string,
|
||||||
|
authenticatorData: ArrayBuffer,
|
||||||
|
challenge: Uint8Array,
|
||||||
|
clientDataJsonBase64Url: Base64URLString,
|
||||||
|
signature: Base64URLString,
|
||||||
|
) {
|
||||||
|
const authDataFromBase = Buffer.from(authenticatorData);
|
||||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
||||||
|
const sigBuffer = Buffer.from(signature, "base64");
|
||||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
||||||
|
|
||||||
|
// Hash the client data
|
||||||
|
const hash = sha256(clientDataFromBase);
|
||||||
|
|
||||||
|
// Construct the preimage
|
||||||
|
const preimage = Buffer.concat([authDataFromBase, hash]);
|
||||||
|
|
||||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||||
|
|
||||||
|
const WebCrypto = await getWebCrypto();
|
||||||
|
const verifyAlgorithm = {
|
||||||
|
name: "ECDSA",
|
||||||
|
hash: { name: "SHA-256" },
|
||||||
|
};
|
||||||
|
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
|
||||||
|
const keyAlgorithm = {
|
||||||
|
name: "ECDSA",
|
||||||
|
namedCurve: publicKeyJwk.crv,
|
||||||
|
};
|
||||||
|
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
|
||||||
|
"jwk",
|
||||||
|
publicKeyJwk,
|
||||||
|
keyAlgorithm,
|
||||||
|
false,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
const verified = await WebCrypto.subtle.verify(
|
||||||
|
verifyAlgorithm,
|
||||||
|
publicKeyCryptoKey,
|
||||||
|
finalSigBuffer,
|
||||||
|
preimage,
|
||||||
|
);
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
|
||||||
|
if (!did.startsWith("did:peer:0z")) {
|
||||||
|
throw new Error(
|
||||||
|
"This only verifies a peer DID, method 0, encoded base58btc.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
||||||
|
// (another reference is the @aviarytech/did-peer resolver)
|
||||||
|
const id = did.split(":")[2];
|
||||||
|
const multibase = id.slice(1);
|
||||||
|
const encnumbasis = multibase.slice(1);
|
||||||
|
const didDocument = {
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/did/v1",
|
||||||
|
"https://w3id.org/security/suites/jws-2020/v1",
|
||||||
|
],
|
||||||
|
assertionMethod: [did + "#" + encnumbasis],
|
||||||
|
authentication: [did + "#" + encnumbasis],
|
||||||
|
capabilityDelegation: [did + "#" + encnumbasis],
|
||||||
|
capabilityInvocation: [did + "#" + encnumbasis],
|
||||||
|
id: did,
|
||||||
|
keyAgreement: undefined,
|
||||||
|
service: undefined,
|
||||||
|
verificationMethod: [
|
||||||
|
{
|
||||||
|
controller: did,
|
||||||
|
id: did + "#" + encnumbasis,
|
||||||
|
publicKeyMultibase: multibase,
|
||||||
|
type: "EcdsaSecp256k1VerificationKey2019",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
didDocument,
|
||||||
|
didDocumentMetadata: {},
|
||||||
|
didResolutionMetadata: { contentType: "application/did+ld+json" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert COSE public key to PEM format
|
||||||
|
function COSEtoPEM(cose: Buffer) {
|
||||||
|
// const alg = cose.get(3); // Algorithm
|
||||||
|
const x = cose[-2]; // x-coordinate
|
||||||
|
const y = cose[-3]; // y-coordinate
|
||||||
|
|
||||||
|
// Ensure the coordinates are in the correct format
|
||||||
|
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
|
||||||
|
|
||||||
|
// Convert to PEM format
|
||||||
|
const pem = `-----BEGIN PUBLIC KEY-----
|
||||||
|
${pubKeyBuffer.toString("base64")}
|
||||||
|
-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
|
return pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(input: string) {
|
||||||
|
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
|
||||||
|
const str = atob(input + pad);
|
||||||
|
const bytes = new Uint8Array(str.length);
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
bytes[i] = str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlEncode(buffer: ArrayBuffer) {
|
||||||
|
const str = String.fromCharCode(...new Uint8Array(buffer));
|
||||||
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// from @simplewebauthn/browser
|
||||||
|
function arrayBufferToBase64URLString(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let str = "";
|
||||||
|
for (const charCode of bytes) {
|
||||||
|
str += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
const base64String = btoa(str);
|
||||||
|
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// from @simplewebauthn/browser
|
||||||
|
function base64URLStringToArrayBuffer(base64URLString: string) {
|
||||||
|
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padLength = (4 - (base64.length % 4)) % 4;
|
||||||
|
const padded = base64.padEnd(base64.length + padLength, "=");
|
||||||
|
const binary = atob(padded);
|
||||||
|
const buffer = new ArrayBuffer(binary.length);
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cborToKeys(publicKeyBytes: Uint8Array) {
|
||||||
|
const jwkObj = cborDecode(publicKeyBytes);
|
||||||
|
if (
|
||||||
|
jwkObj[1] != 2 || // kty "EC"
|
||||||
|
jwkObj[3] != -7 || // alg "ES256"
|
||||||
|
jwkObj[-1] != 1 || // crv "P-256"
|
||||||
|
jwkObj[-2].length != 32 || // x
|
||||||
|
jwkObj[-3].length != 32 // y
|
||||||
|
) {
|
||||||
|
throw new Error("Unable to extract key.");
|
||||||
|
}
|
||||||
|
const publicKeyJwk = {
|
||||||
|
alg: "ES256",
|
||||||
|
crv: "P-256",
|
||||||
|
kty: "EC",
|
||||||
|
x: arrayToBase64Url(jwkObj[-2]),
|
||||||
|
y: arrayToBase64Url(jwkObj[-3]),
|
||||||
|
};
|
||||||
|
const publicKeyBuffer = Buffer.concat([
|
||||||
|
Buffer.from(jwkObj[-2]),
|
||||||
|
Buffer.from(jwkObj[-3]),
|
||||||
|
]);
|
||||||
|
return { publicKeyJwk, publicKeyBuffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pemToCryptoKey(pem: string) {
|
||||||
|
const binaryDerString = atob(
|
||||||
|
pem
|
||||||
|
.split("\n")
|
||||||
|
.filter((x) => !x.includes("-----"))
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||||
|
for (let i = 0; i < binaryDerString.length; i++) {
|
||||||
|
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
// console.log("binaryDer", binaryDer.buffer);
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
binaryDer.buffer,
|
||||||
|
{
|
||||||
|
name: "RSASSA-PKCS1-v1_5",
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Axios,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosResponse,
|
||||||
|
RawAxiosRequestHeaders,
|
||||||
|
} from "axios";
|
||||||
|
import * as didJwt from "did-jwt";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
|
||||||
import * as didJwt from "did-jwt";
|
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
|
||||||
import { Axios, AxiosResponse } from "axios";
|
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||||
|
import { NonsensitiveDexie } from "@/db/index";
|
||||||
|
import { getIdentity } from "@/libs/util";
|
||||||
|
|
||||||
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
||||||
// the object in RegisterAction claims
|
// the object in RegisterAction claims
|
||||||
@@ -25,14 +35,14 @@ export interface AgreeVerifiableCredential {
|
|||||||
object: Record<string, any>;
|
object: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiverInputInfo {
|
export interface GiverReceiverInputInfo {
|
||||||
did?: string;
|
did?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiverOutputInfo {
|
export interface GiverOutputInfo {
|
||||||
action: string;
|
action: string;
|
||||||
giver?: GiverInputInfo;
|
giver?: GiverReceiverInputInfo;
|
||||||
description?: string;
|
description?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
unitCode?: string;
|
unitCode?: string;
|
||||||
@@ -49,8 +59,8 @@ export interface GenericVerifiableCredential {
|
|||||||
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericServerRecord extends GenericVerifiableCredential {
|
export interface GenericCredWrapper extends GenericVerifiableCredential {
|
||||||
handleId?: string;
|
handleId: string;
|
||||||
id: string;
|
id: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
@@ -58,16 +68,18 @@ export interface GenericServerRecord extends GenericVerifiableCredential {
|
|||||||
claim: Record<string, any>;
|
claim: Record<string, any>;
|
||||||
claimType?: string;
|
claimType?: string;
|
||||||
}
|
}
|
||||||
export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
|
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = {
|
||||||
"@context": SCHEMA_ORG_CONTEXT,
|
"@context": SCHEMA_ORG_CONTEXT,
|
||||||
"@type": "",
|
"@type": "",
|
||||||
claim: {},
|
claim: {},
|
||||||
|
handleId: "",
|
||||||
id: "",
|
id: "",
|
||||||
issuedAt: "",
|
issuedAt: "",
|
||||||
issuer: "",
|
issuer: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface GiveServerRecord {
|
// a summary record; the VC is found the fullClaim field
|
||||||
|
export interface GiveSummaryRecord {
|
||||||
agentDid: string;
|
agentDid: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
amountConfirmed: number;
|
amountConfirmed: number;
|
||||||
@@ -81,7 +93,8 @@ export interface GiveServerRecord {
|
|||||||
unit: string;
|
unit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfferServerRecord {
|
// a summary record; the VC is found the fullClaim field
|
||||||
|
export interface OfferSummaryRecord {
|
||||||
amount: number;
|
amount: number;
|
||||||
amountGiven: number;
|
amountGiven: number;
|
||||||
amountGivenConfirmed: number;
|
amountGivenConfirmed: number;
|
||||||
@@ -98,15 +111,18 @@ export interface OfferServerRecord {
|
|||||||
validThrough: string;
|
validThrough: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanServerRecord {
|
// a summary record; the VC is not currently part of this record
|
||||||
|
export interface PlanSummaryRecord {
|
||||||
agentDid?: string; // optional, if the issuer wants someone else to manage as well
|
agentDid?: string; // optional, if the issuer wants someone else to manage as well
|
||||||
description: string;
|
description: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
fulfillsPlanHandleId: string;
|
fulfillsPlanHandleId: string;
|
||||||
issuerDid: string;
|
|
||||||
handleId: string;
|
handleId: string;
|
||||||
|
image?: string;
|
||||||
|
issuerDid: string;
|
||||||
locLat?: number;
|
locLat?: number;
|
||||||
locLon?: number;
|
locLon?: number;
|
||||||
|
name?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
@@ -120,6 +136,7 @@ export interface GiveVerifiableCredential {
|
|||||||
description?: string;
|
description?: string;
|
||||||
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
|
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
|
image?: string;
|
||||||
object?: { amountOfThisGood: number; unitCode: string };
|
object?: { amountOfThisGood: number; unitCode: string };
|
||||||
recipient?: { identifier: string };
|
recipient?: { identifier: string };
|
||||||
}
|
}
|
||||||
@@ -136,6 +153,7 @@ export interface OfferVerifiableCredential {
|
|||||||
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
||||||
};
|
};
|
||||||
offeredBy?: { identifier: string };
|
offeredBy?: { identifier: string };
|
||||||
|
recipient?: { identifier: string };
|
||||||
validThrough?: string;
|
validThrough?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +176,7 @@ export interface PlanVerifiableCredential {
|
|||||||
* Represents data about a project
|
* Represents data about a project
|
||||||
*
|
*
|
||||||
* @deprecated
|
* @deprecated
|
||||||
* We should use PlanServerRecord instead.
|
* We should use PlanSummaryRecord instead.
|
||||||
**/
|
**/
|
||||||
export interface PlanData {
|
export interface PlanData {
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +191,7 @@ export interface PlanData {
|
|||||||
* URL referencing information about the project
|
* URL referencing information about the project
|
||||||
**/
|
**/
|
||||||
handleId: string;
|
handleId: string;
|
||||||
|
image?: string;
|
||||||
/**
|
/**
|
||||||
* The DID of the issuer
|
* The DID of the issuer
|
||||||
*/
|
*/
|
||||||
@@ -183,7 +202,7 @@ export interface PlanData {
|
|||||||
rowid?: string;
|
rowid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateLimits {
|
export interface EndorserRateLimits {
|
||||||
doneClaimsThisWeek: string;
|
doneClaimsThisWeek: string;
|
||||||
doneRegistrationsThisMonth: string;
|
doneRegistrationsThisMonth: string;
|
||||||
maxClaimsPerWeek: string;
|
maxClaimsPerWeek: string;
|
||||||
@@ -192,6 +211,12 @@ export interface RateLimits {
|
|||||||
nextWeekBeginDateTime: string;
|
nextWeekBeginDateTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageRateLimits {
|
||||||
|
doneImagesThisWeek: string;
|
||||||
|
maxImagesPerWeek: string;
|
||||||
|
nextWeekBeginDateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VerifiableCredential {
|
export interface VerifiableCredential {
|
||||||
"@context": string;
|
"@context": string;
|
||||||
"@type": string;
|
"@type": string;
|
||||||
@@ -246,6 +271,10 @@ export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
|||||||
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
||||||
const HIDDEN_DID = "did:none:HIDDEN";
|
const HIDDEN_DID = "did:none:HIDDEN";
|
||||||
|
|
||||||
|
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
||||||
|
max: 500,
|
||||||
|
});
|
||||||
|
|
||||||
export function isDid(did: string) {
|
export function isDid(did: string) {
|
||||||
return did.startsWith("did:");
|
return did.startsWith("did:");
|
||||||
}
|
}
|
||||||
@@ -259,7 +288,7 @@ export function isEmptyOrHiddenDid(did?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return true for any nested string where func(input) === true
|
* @return true for any string within this primitive/object/array where func(input) === true
|
||||||
*
|
*
|
||||||
* Similar logic is found in endorser-mobile.
|
* Similar logic is found in endorser-mobile.
|
||||||
*/
|
*/
|
||||||
@@ -294,6 +323,12 @@ export function containsHiddenDid(obj: any) {
|
|||||||
return testRecursivelyOnStrings(isHiddenDid, obj);
|
return testRecursivelyOnStrings(isHiddenDid, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const containsNonHiddenDid = (obj: any) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return testRecursivelyOnStrings((s: any) => isDid(s) && !isHiddenDid(s), obj);
|
||||||
|
};
|
||||||
|
|
||||||
export function stripEndorserPrefix(claimId: string) {
|
export function stripEndorserPrefix(claimId: string) {
|
||||||
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
|
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
|
||||||
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
|
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
|
||||||
@@ -371,7 +406,8 @@ export function contactForDid(
|
|||||||
* @param activeDid
|
* @param activeDid
|
||||||
* @param contact
|
* @param contact
|
||||||
* @param allMyDids
|
* @param allMyDids
|
||||||
* @return { known: boolean, displayName: string } where known is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Anonymous"
|
* @return { known: boolean, displayName: string, profileImageUrl?: string }
|
||||||
|
* where 'known' is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Unnamed"
|
||||||
*/
|
*/
|
||||||
export function didInfoForContact(
|
export function didInfoForContact(
|
||||||
did: string | undefined,
|
did: string | undefined,
|
||||||
@@ -379,22 +415,26 @@ export function didInfoForContact(
|
|||||||
contact?: Contact,
|
contact?: Contact,
|
||||||
allMyDids: string[] = [],
|
allMyDids: string[] = [],
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
): { known: boolean; displayName: string } {
|
): { known: boolean; displayName: string; profileImageUrl?: string } {
|
||||||
if (!did) return { displayName: "Someone Anonymous", known: false };
|
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
|
||||||
if (contact) {
|
if (did === activeDid) {
|
||||||
|
return { displayName: "You", known: true };
|
||||||
|
} else if (contact) {
|
||||||
return {
|
return {
|
||||||
displayName: contact.name || "Contact With No Name",
|
displayName: contact.name || "Contact With No Name",
|
||||||
known: !!contact.name,
|
known: !!contact.name,
|
||||||
|
profileImageUrl: contact.profileImageUrl,
|
||||||
};
|
};
|
||||||
} else if (did === activeDid) {
|
|
||||||
return { displayName: "You", known: true };
|
|
||||||
} else {
|
} else {
|
||||||
const myId = R.find(R.equals(did), allMyDids);
|
const myId = R.find(R.equals(did), allMyDids);
|
||||||
return myId
|
return myId
|
||||||
? { displayName: "You (Alt ID)", known: true }
|
? { displayName: "You (Alt ID)", known: true }
|
||||||
: isHiddenDid(did)
|
: isHiddenDid(did)
|
||||||
? { displayName: "Someone Outside Your Network", known: false }
|
? { displayName: "Someone Totally Outside Your View", known: false }
|
||||||
: { displayName: "Someone Outside Contacts", known: false };
|
: {
|
||||||
|
displayName: "Someone Visible But Outside Your Contact List",
|
||||||
|
known: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +453,71 @@ export function didInfo(
|
|||||||
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
|
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getHeaders(identity: IIdentifier | null) {
|
||||||
|
const headers: RawAxiosRequestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param handleId nullable, in which case "undefined" will be returned
|
||||||
|
* @param identity nullable, in which case no private info will be returned
|
||||||
|
* @param axios
|
||||||
|
* @param apiServer
|
||||||
|
*/
|
||||||
|
export async function getPlanFromCache(
|
||||||
|
handleId: string | null,
|
||||||
|
identity: IIdentifier | null,
|
||||||
|
axios: Axios,
|
||||||
|
apiServer: string,
|
||||||
|
): Promise<PlanSummaryRecord | undefined> {
|
||||||
|
if (!handleId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cred = planCache.get(handleId);
|
||||||
|
if (!cred) {
|
||||||
|
const url =
|
||||||
|
apiServer +
|
||||||
|
"/api/v2/report/plans?handleId=" +
|
||||||
|
encodeURIComponent(handleId);
|
||||||
|
const headers = await getHeaders(identity);
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(url, { headers });
|
||||||
|
if (resp.status === 200 && resp.data?.data?.length > 0) {
|
||||||
|
cred = resp.data.data[0];
|
||||||
|
planCache.set(handleId, cred);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to load plan with handle",
|
||||||
|
handleId,
|
||||||
|
" Got data:",
|
||||||
|
resp.data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Failed to load plan with handle",
|
||||||
|
handleId,
|
||||||
|
" Got error:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cred;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPlanInCache(
|
||||||
|
handleId: string,
|
||||||
|
planSummary: PlanSummaryRecord,
|
||||||
|
) {
|
||||||
|
planCache.set(handleId, planSummary);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||||
*
|
*
|
||||||
@@ -434,6 +539,7 @@ export async function createAndSubmitGive(
|
|||||||
fulfillsProjectHandleId?: string,
|
fulfillsProjectHandleId?: string,
|
||||||
fulfillsOfferHandleId?: string,
|
fulfillsOfferHandleId?: string,
|
||||||
isTrade: boolean = false,
|
isTrade: boolean = false,
|
||||||
|
imageUrl?: string,
|
||||||
): Promise<CreateAndSubmitClaimResult> {
|
): Promise<CreateAndSubmitClaimResult> {
|
||||||
const vcClaim: GiveVerifiableCredential = {
|
const vcClaim: GiveVerifiableCredential = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -460,8 +566,11 @@ export async function createAndSubmitGive(
|
|||||||
identifier: fulfillsOfferHandleId,
|
identifier: fulfillsOfferHandleId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (imageUrl) {
|
||||||
|
vcClaim.image = imageUrl;
|
||||||
|
}
|
||||||
return createAndSubmitClaim(
|
return createAndSubmitClaim(
|
||||||
vcClaim as GenericServerRecord,
|
vcClaim as GenericCredWrapper,
|
||||||
identity,
|
identity,
|
||||||
apiServer,
|
apiServer,
|
||||||
axios,
|
axios,
|
||||||
@@ -485,6 +594,7 @@ export async function createAndSubmitOffer(
|
|||||||
amount?: number,
|
amount?: number,
|
||||||
unitCode?: string,
|
unitCode?: string,
|
||||||
expirationDate?: string,
|
expirationDate?: string,
|
||||||
|
recipientDid?: string,
|
||||||
fulfillsProjectHandleId?: string,
|
fulfillsProjectHandleId?: string,
|
||||||
): Promise<CreateAndSubmitClaimResult> {
|
): Promise<CreateAndSubmitClaimResult> {
|
||||||
const vcClaim: OfferVerifiableCredential = {
|
const vcClaim: OfferVerifiableCredential = {
|
||||||
@@ -502,6 +612,9 @@ export async function createAndSubmitOffer(
|
|||||||
if (description) {
|
if (description) {
|
||||||
vcClaim.itemOffered = { description };
|
vcClaim.itemOffered = { description };
|
||||||
}
|
}
|
||||||
|
if (recipientDid) {
|
||||||
|
vcClaim.recipient = { identifier: recipientDid };
|
||||||
|
}
|
||||||
if (fulfillsProjectHandleId) {
|
if (fulfillsProjectHandleId) {
|
||||||
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||||
vcClaim.itemOffered.isPartOf = {
|
vcClaim.itemOffered.isPartOf = {
|
||||||
@@ -510,7 +623,7 @@ export async function createAndSubmitOffer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return createAndSubmitClaim(
|
return createAndSubmitClaim(
|
||||||
vcClaim as GenericServerRecord,
|
vcClaim as GenericCredWrapper,
|
||||||
identity,
|
identity,
|
||||||
apiServer,
|
apiServer,
|
||||||
axios,
|
axios,
|
||||||
@@ -603,6 +716,12 @@ export async function createAndSubmitClaim(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AcceptAction is when someone accepts some contract or pledge.
|
||||||
|
*
|
||||||
|
* @param claim has properties '@context' & '@type'
|
||||||
|
* @return true if the claim is a schema.org AcceptAction
|
||||||
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const isAccept = (claim: Record<string, any>) => {
|
export const isAccept = (claim: Record<string, any>) => {
|
||||||
return (
|
return (
|
||||||
@@ -681,7 +800,7 @@ const claimSummary = (claim: Record<string, any>) => {
|
|||||||
similar code is also contained in endorser-mobile
|
similar code is also contained in endorser-mobile
|
||||||
**/
|
**/
|
||||||
export const claimSpecialDescription = (
|
export const claimSpecialDescription = (
|
||||||
record: GenericServerRecord,
|
record: GenericCredWrapper,
|
||||||
activeDid: string,
|
activeDid: string,
|
||||||
identifiers: Array<string>,
|
identifiers: Array<string>,
|
||||||
contacts: Array<Contact>,
|
contacts: Array<Contact>,
|
||||||
@@ -775,13 +894,13 @@ export const claimSpecialDescription = (
|
|||||||
"...]"
|
"...]"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return issuer + " declared " + claimSummary(claim as GenericServerRecord);
|
return issuer + " declared " + claimSummary(claim as GenericCredWrapper);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BVC_MEETUPS_PROJECT_CLAIM_ID =
|
export const BVC_MEETUPS_PROJECT_CLAIM_ID =
|
||||||
//"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H";
|
import.meta.env.VITE_BVC_MEETUPS_PROJECT_CLAIM_ID ||
|
||||||
"https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK";
|
"https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; // this won't resolve as a URL on production; it's a URN only found in the test system
|
||||||
|
|
||||||
export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
|
export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
|
||||||
return {
|
return {
|
||||||
@@ -799,3 +918,136 @@ export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function register(
|
||||||
|
activeDid: string,
|
||||||
|
apiServer: string,
|
||||||
|
axios: Axios,
|
||||||
|
contact: Contact,
|
||||||
|
) {
|
||||||
|
const identity = await getIdentity(activeDid);
|
||||||
|
|
||||||
|
const vcClaim: RegisterVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "RegisterAction",
|
||||||
|
agent: { identifier: identity.did },
|
||||||
|
object: SERVICE_ID,
|
||||||
|
participant: { identifier: contact.did },
|
||||||
|
};
|
||||||
|
// Make a payload for the claim
|
||||||
|
const vcPayload = {
|
||||||
|
vc: {
|
||||||
|
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||||
|
type: ["VerifiableCredential"],
|
||||||
|
credentialSubject: vcClaim,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Create a signature using private key of identity
|
||||||
|
if (identity.keys[0].privateKeyHex == null) {
|
||||||
|
return { error: "Private key not found." };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
|
||||||
|
const signer = await SimpleSigner(privateKeyHex);
|
||||||
|
const alg = undefined;
|
||||||
|
// Create a JWT for the request
|
||||||
|
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||||
|
alg: alg,
|
||||||
|
issuer: identity.did,
|
||||||
|
signer: signer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the xhr request payload
|
||||||
|
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||||
|
const url = apiServer + "/api/v2/claim";
|
||||||
|
const headers = await getHeaders(identity);
|
||||||
|
|
||||||
|
const resp = await axios.post(url, payload, { headers });
|
||||||
|
if (resp.data?.success?.handleId) {
|
||||||
|
return { success: true };
|
||||||
|
} else if (resp.data?.success?.embeddedRecordError) {
|
||||||
|
let message =
|
||||||
|
"There was some problem with the registration and so it may not be complete.";
|
||||||
|
if (typeof resp.data.success.embeddedRecordError == "string") {
|
||||||
|
message += " " + resp.data.success.embeddedRecordError;
|
||||||
|
}
|
||||||
|
return { error: message };
|
||||||
|
} else {
|
||||||
|
console.error(resp);
|
||||||
|
return { error: "Got a server error when registering." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setVisibilityUtil(
|
||||||
|
activeDid: string,
|
||||||
|
apiServer: string,
|
||||||
|
axios: Axios,
|
||||||
|
db: NonsensitiveDexie,
|
||||||
|
contact: Contact,
|
||||||
|
visibility: boolean,
|
||||||
|
) {
|
||||||
|
if (!activeDid) {
|
||||||
|
return { error: "Cannot set visibility without an identifier." };
|
||||||
|
}
|
||||||
|
const url =
|
||||||
|
apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe");
|
||||||
|
const identity = await getIdentity(activeDid);
|
||||||
|
const headers = await getHeaders(identity);
|
||||||
|
const payload = JSON.stringify({ did: contact.did });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await axios.post(url, payload, { headers });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
db.contacts.update(contact.did, { seesMe: visibility });
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Got some bad server response when setting visibility: ",
|
||||||
|
resp.status,
|
||||||
|
resp,
|
||||||
|
);
|
||||||
|
const message =
|
||||||
|
resp.data.error?.message || "Got some error setting visibility.";
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Got some error when setting visibility:", err);
|
||||||
|
return { error: "Check connectivity and try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches rate limits from the Endorser server.
|
||||||
|
*
|
||||||
|
* @param apiServer endorser server URL string
|
||||||
|
* @param axios Axios instance
|
||||||
|
* @param {IIdentifier} identity - The identity object to check rate limits for.
|
||||||
|
* @returns {Promise<AxiosResponse>} The Axios response object.
|
||||||
|
*/
|
||||||
|
export async function fetchEndorserRateLimits(
|
||||||
|
apiServer: string,
|
||||||
|
axios: Axios,
|
||||||
|
identity: IIdentifier,
|
||||||
|
) {
|
||||||
|
const url = `${apiServer}/api/report/rateLimits`;
|
||||||
|
const headers = await getHeaders(identity);
|
||||||
|
return await axios.get(url, { headers } as AxiosRequestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches rate limits from the image server.
|
||||||
|
*
|
||||||
|
* @param apiServer image server URL string
|
||||||
|
* @param axios Axios instance
|
||||||
|
* @param {IIdentifier} identity - The identity object to check rate limits for.
|
||||||
|
* @returns {Promise<AxiosResponse>} The Axios response object.
|
||||||
|
*/
|
||||||
|
export async function fetchImageRateLimits(
|
||||||
|
apiServer: string,
|
||||||
|
axios: Axios,
|
||||||
|
identity: IIdentifier,
|
||||||
|
) {
|
||||||
|
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
|
||||||
|
const headers = await getHeaders(identity);
|
||||||
|
return await axios.get(url, { headers } as AxiosRequestConfig);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,19 +9,15 @@ import { accountsDB, db } from "@/db/index";
|
|||||||
import { Account } from "@/db/tables/accounts";
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||||
import { GenericServerRecord, containsHiddenDid } from "@/libs/endorserServer";
|
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
|
||||||
import * as serverUtil from "@/libs/endorserServer";
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
export const PRIVACY_MESSAGE =
|
||||||
const Buffer = require("buffer/").Buffer;
|
"The data you send be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow.";
|
||||||
|
|
||||||
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, too),
|
|
||||||
// and make sure they can take all actions while the notification shows.
|
|
||||||
export const ONBOARD_MESSAGE =
|
|
||||||
"1) Read through all their yellow prompts. 2) Add them to your Contacts by scanning with the QR icon that is by the input box. 3) Click the person icon to register them. 4) Show them your QR so they'll scan you. 5) Have them enable notifications.";
|
|
||||||
|
|
||||||
/* eslint-disable prettier/prettier */
|
/* eslint-disable prettier/prettier */
|
||||||
export const UNIT_SHORT: Record<string, string> = {
|
export const UNIT_SHORT: Record<string, string> = {
|
||||||
|
"BX": "BX",
|
||||||
"BTC": "BTC",
|
"BTC": "BTC",
|
||||||
"ETH": "ETH",
|
"ETH": "ETH",
|
||||||
"HUR": "Hours",
|
"HUR": "Hours",
|
||||||
@@ -31,6 +27,7 @@ export const UNIT_SHORT: Record<string, string> = {
|
|||||||
|
|
||||||
/* eslint-disable prettier/prettier */
|
/* eslint-disable prettier/prettier */
|
||||||
export const UNIT_LONG: Record<string, string> = {
|
export const UNIT_LONG: Record<string, string> = {
|
||||||
|
"BX": "Buxbe",
|
||||||
"BTC": "Bitcoin",
|
"BTC": "Bitcoin",
|
||||||
"ETH": "Ethereum",
|
"ETH": "Ethereum",
|
||||||
"HUR": "hours",
|
"HUR": "hours",
|
||||||
@@ -58,9 +55,13 @@ export function iconForUnitCode(unitCode: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// from https://stackoverflow.com/a/175787/845494
|
// from https://stackoverflow.com/a/175787/845494
|
||||||
|
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
|
||||||
//
|
//
|
||||||
export function isNumeric(str: string): boolean {
|
export function isNumeric(str: string): boolean {
|
||||||
return !isNaN(+str);
|
// This ignore commentary is because typescript complains when you pass a string to isNaN.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return !isNaN(str) && !isNaN(parseFloat(str));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function numberOrZero(str: string): number {
|
export function numberOrZero(str: string): number {
|
||||||
@@ -71,7 +72,7 @@ export const isGlobalUri = (uri: string) => {
|
|||||||
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const giveIsConfirmable = (veriClaim: GenericServerRecord) => {
|
export const isGiveAction = (veriClaim: GenericCredWrapper) => {
|
||||||
return veriClaim.claimType === "GiveAction";
|
return veriClaim.claimType === "GiveAction";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,12 +88,12 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
|||||||
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
*/
|
*/
|
||||||
export const isGiveRecordTheUserCanConfirm = (
|
export const isGiveRecordTheUserCanConfirm = (
|
||||||
veriClaim: GenericServerRecord,
|
veriClaim: GenericCredWrapper,
|
||||||
activeDid: string,
|
activeDid: string,
|
||||||
confirmerIdList: string[] = [],
|
confirmerIdList: string[] = [],
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
giveIsConfirmable(veriClaim) &&
|
isGiveAction(veriClaim) &&
|
||||||
!confirmerIdList.includes(activeDid) &&
|
!confirmerIdList.includes(activeDid) &&
|
||||||
veriClaim.issuer !== activeDid &&
|
veriClaim.issuer !== activeDid &&
|
||||||
!containsHiddenDid(veriClaim.claim)
|
!containsHiddenDid(veriClaim.claim)
|
||||||
@@ -103,9 +104,9 @@ export const isGiveRecordTheUserCanConfirm = (
|
|||||||
* @returns the DID of the person who offered, or undefined if hidden
|
* @returns the DID of the person who offered, or undefined if hidden
|
||||||
* @param veriClaim is expected to have fields: claim and issuer
|
* @param veriClaim is expected to have fields: claim and issuer
|
||||||
*/
|
*/
|
||||||
export const offerGiverDid: (
|
export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = (
|
||||||
arg0: GenericServerRecord,
|
veriClaim,
|
||||||
) => string | undefined = (veriClaim) => {
|
) => {
|
||||||
let giver;
|
let giver;
|
||||||
if (
|
if (
|
||||||
veriClaim.claim.offeredBy?.identifier &&
|
veriClaim.claim.offeredBy?.identifier &&
|
||||||
@@ -122,7 +123,7 @@ export const offerGiverDid: (
|
|||||||
* @returns true if the user can fulfill the offer
|
* @returns true if the user can fulfill the offer
|
||||||
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
*/
|
*/
|
||||||
export const canFulfillOffer = (veriClaim: GenericServerRecord) => {
|
export const canFulfillOffer = (veriClaim: GenericCredWrapper) => {
|
||||||
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
|
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
|
|||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Attempted to load Offer records for DID ${activeDid} but no identifier was found`,
|
`Attempted to load identity ${activeDid} but no identifier was found`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
@@ -239,7 +240,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sendTestThroughPushServer = async (
|
export const sendTestThroughPushServer = async (
|
||||||
subscription: PushSubscription,
|
subscriptionJSON: PushSubscriptionJSON,
|
||||||
skipFilter: boolean,
|
skipFilter: boolean,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
await db.open();
|
await db.open();
|
||||||
@@ -254,28 +255,11 @@ export const sendTestThroughPushServer = async (
|
|||||||
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
|
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
|
||||||
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
||||||
|
|
||||||
const auth = Buffer.from(subscription.getKey("auth"));
|
|
||||||
const authB64 = auth
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
const p256dh = Buffer.from(subscription.getKey("p256dh"));
|
|
||||||
const p256dhB64 = p256dh
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
const newPayload = {
|
const newPayload = {
|
||||||
endpoint: subscription.endpoint,
|
// eslint-disable-next-line prettier/prettier
|
||||||
keys: {
|
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
|
||||||
auth: authB64,
|
|
||||||
p256dh: p256dhB64,
|
|
||||||
},
|
|
||||||
message: `Test, where you will see this message ${
|
|
||||||
skipFilter ? "un" : ""
|
|
||||||
}filtered.`,
|
|
||||||
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
||||||
|
...subscriptionJSON,
|
||||||
};
|
};
|
||||||
console.log("Sending a test web push message:", newPayload);
|
console.log("Sending a test web push message:", newPayload);
|
||||||
const payloadStr = JSON.stringify(newPayload);
|
const payloadStr = JSON.stringify(newPayload);
|
||||||
|
|||||||
61
src/main.ts
61
src/main.ts
@@ -1,5 +1,5 @@
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import { createApp } from "vue";
|
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import "./registerServiceWorker";
|
import "./registerServiceWorker";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
@@ -11,15 +11,22 @@ import "./assets/styles/tailwind.css";
|
|||||||
|
|
||||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||||
import {
|
import {
|
||||||
|
faArrowDown,
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRotateBackward,
|
||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
|
faArrowUp,
|
||||||
faBan,
|
faBan,
|
||||||
faBitcoinSign,
|
faBitcoinSign,
|
||||||
faBurst,
|
faBurst,
|
||||||
faCalendar,
|
faCalendar,
|
||||||
|
faCamera,
|
||||||
|
faCheck,
|
||||||
|
faChevronDown,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
|
faChevronUp,
|
||||||
faCircle,
|
faCircle,
|
||||||
faCircleCheck,
|
faCircleCheck,
|
||||||
faCircleInfo,
|
faCircleInfo,
|
||||||
@@ -30,6 +37,7 @@ import {
|
|||||||
faComment,
|
faComment,
|
||||||
faCopy,
|
faCopy,
|
||||||
faDollar,
|
faDollar,
|
||||||
|
faEllipsis,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
@@ -41,8 +49,11 @@ import {
|
|||||||
faGlobe,
|
faGlobe,
|
||||||
faHammer,
|
faHammer,
|
||||||
faHand,
|
faHand,
|
||||||
|
faHandHoldingDollar,
|
||||||
faHandHoldingHeart,
|
faHandHoldingHeart,
|
||||||
faHouseChimney,
|
faHouseChimney,
|
||||||
|
faImagePortrait,
|
||||||
|
faLeftRight,
|
||||||
faLocationDot,
|
faLocationDot,
|
||||||
faLongArrowAltLeft,
|
faLongArrowAltLeft,
|
||||||
faLongArrowAltRight,
|
faLongArrowAltRight,
|
||||||
@@ -58,6 +69,7 @@ import {
|
|||||||
faRotate,
|
faRotate,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
|
faSquare,
|
||||||
faSquareCaretDown,
|
faSquareCaretDown,
|
||||||
faSquareCaretUp,
|
faSquareCaretUp,
|
||||||
faSquarePlus,
|
faSquarePlus,
|
||||||
@@ -69,15 +81,22 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
|
faArrowDown,
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRotateBackward,
|
||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
|
faArrowUp,
|
||||||
faBan,
|
faBan,
|
||||||
faBitcoinSign,
|
faBitcoinSign,
|
||||||
faBurst,
|
faBurst,
|
||||||
faCalendar,
|
faCalendar,
|
||||||
|
faCamera,
|
||||||
|
faCheck,
|
||||||
|
faChevronDown,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
|
faChevronUp,
|
||||||
faCircle,
|
faCircle,
|
||||||
faCircleCheck,
|
faCircleCheck,
|
||||||
faCircleInfo,
|
faCircleInfo,
|
||||||
@@ -88,6 +107,7 @@ library.add(
|
|||||||
faComment,
|
faComment,
|
||||||
faCopy,
|
faCopy,
|
||||||
faDollar,
|
faDollar,
|
||||||
|
faEllipsis,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
@@ -99,8 +119,11 @@ library.add(
|
|||||||
faGlobe,
|
faGlobe,
|
||||||
faHammer,
|
faHammer,
|
||||||
faHand,
|
faHand,
|
||||||
|
faHandHoldingDollar,
|
||||||
faHandHoldingHeart,
|
faHandHoldingHeart,
|
||||||
faHouseChimney,
|
faHouseChimney,
|
||||||
|
faImagePortrait,
|
||||||
|
faLeftRight,
|
||||||
faLocationDot,
|
faLocationDot,
|
||||||
faLongArrowAltLeft,
|
faLongArrowAltLeft,
|
||||||
faLongArrowAltRight,
|
faLongArrowAltRight,
|
||||||
@@ -116,6 +139,7 @@ library.add(
|
|||||||
faRotate,
|
faRotate,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
|
faSquare,
|
||||||
faSquareCaretDown,
|
faSquareCaretDown,
|
||||||
faSquareCaretUp,
|
faSquareCaretUp,
|
||||||
faSquarePlus,
|
faSquarePlus,
|
||||||
@@ -127,11 +151,40 @@ library.add(
|
|||||||
);
|
);
|
||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
import Camera from "simple-vue-camera";
|
||||||
|
|
||||||
createApp(App)
|
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
|
||||||
|
function setupGlobalErrorHandler(app: VueApp) {
|
||||||
|
// @ts-expect-error 'cause we cannot see why config is not defined
|
||||||
|
app.config.errorHandler = (
|
||||||
|
err: Error,
|
||||||
|
instance: ComponentPublicInstance | null,
|
||||||
|
info: string,
|
||||||
|
) => {
|
||||||
|
console.error(
|
||||||
|
"Ouch! Global Error Handler. Info:",
|
||||||
|
info,
|
||||||
|
"Error:",
|
||||||
|
err,
|
||||||
|
"Instance:",
|
||||||
|
instance,
|
||||||
|
);
|
||||||
|
// Want to show a nice notiwind notification but can't figure out how.
|
||||||
|
alert(
|
||||||
|
(err.message || "Something bad happened") +
|
||||||
|
" - Try reloading or restarting the app.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
.component("fa", FontAwesomeIcon)
|
.component("fa", FontAwesomeIcon)
|
||||||
|
.component("camera", Camera)
|
||||||
.use(createPinia())
|
.use(createPinia())
|
||||||
.use(VueAxios, axios)
|
.use(VueAxios, axios)
|
||||||
.use(router)
|
.use(router)
|
||||||
.use(Notifications)
|
.use(Notifications);
|
||||||
.mount("#app");
|
|
||||||
|
setupGlobalErrorHandler(app);
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { register } from "register-service-worker";
|
import { register } from "register-service-worker";
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (import.meta.env.NODE_ENV === "production") {
|
||||||
register("/sw_scripts-combined.js", {
|
register("/sw_scripts-combined.js", {
|
||||||
ready() {
|
ready() {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -31,213 +31,174 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: "/account",
|
path: "/account",
|
||||||
name: "account",
|
name: "account",
|
||||||
component: () =>
|
component: () => import("../views/AccountViewView.vue"),
|
||||||
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/claim/:id?",
|
path: "/claim/:id?",
|
||||||
name: "claim",
|
name: "claim",
|
||||||
component: () =>
|
component: () => import("../views/ClaimView.vue"),
|
||||||
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/confirm-contact",
|
path: "/confirm-contact",
|
||||||
name: "confirm-contact",
|
name: "confirm-contact",
|
||||||
component: () =>
|
component: () => import("../views/ConfirmContactView.vue"),
|
||||||
import(
|
},
|
||||||
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue"
|
{
|
||||||
),
|
path: "/confirm-gift/:id?",
|
||||||
|
name: "confirm-gift",
|
||||||
|
component: () => import("@/views/ConfirmGiftView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-amounts",
|
path: "/contact-amounts",
|
||||||
name: "contact-amounts",
|
name: "contact-amounts",
|
||||||
component: () =>
|
component: () => import("../views/ContactAmountsView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-gives",
|
path: "/contact-gift",
|
||||||
name: "contact-gives",
|
name: "contact-gift",
|
||||||
component: () =>
|
component: () => import("../views/ContactGiftingView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-qr",
|
path: "/contact-qr",
|
||||||
name: "contact-qr",
|
name: "contact-qr",
|
||||||
component: () =>
|
component: () => import("../views/ContactQRScanShowView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contacts",
|
path: "/contacts",
|
||||||
name: "contacts",
|
name: "contacts",
|
||||||
component: () =>
|
component: () => import("../views/ContactsView.vue"),
|
||||||
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
},
|
||||||
|
{
|
||||||
|
path: "/did/:did?",
|
||||||
|
name: "did",
|
||||||
|
component: () => import("../views/DIDView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/discover",
|
path: "/discover",
|
||||||
name: "discover",
|
name: "discover",
|
||||||
component: () =>
|
component: () => import("../views/DiscoverView.vue"),
|
||||||
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
|
},
|
||||||
|
{
|
||||||
|
path: "/gifted-details",
|
||||||
|
name: "gifted-details",
|
||||||
|
component: () => import("../views/GiftedDetails.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/help",
|
path: "/help",
|
||||||
name: "help",
|
name: "help",
|
||||||
component: () =>
|
component: () => import("../views/HelpView.vue"),
|
||||||
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
name: "home",
|
|
||||||
component: () =>
|
|
||||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/help-notifications",
|
path: "/help-notifications",
|
||||||
name: "help-notifications",
|
name: "help-notifications",
|
||||||
component: () =>
|
component: () => import("../views/HelpNotificationsView.vue"),
|
||||||
import(
|
},
|
||||||
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
|
{
|
||||||
),
|
path: "/help-onboarding",
|
||||||
|
name: "help-onboarding",
|
||||||
|
component: () => import("../views/HelpOnboardingView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "home",
|
||||||
|
component: () => import("../views/HomeView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/identity-switcher",
|
path: "/identity-switcher",
|
||||||
name: "identity-switcher",
|
name: "identity-switcher",
|
||||||
component: () =>
|
component: () => import("../views/IdentitySwitcherView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/import-account",
|
path: "/import-account",
|
||||||
name: "import-account",
|
name: "import-account",
|
||||||
component: () =>
|
component: () => import("../views/ImportAccountView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/import-derive",
|
path: "/import-derive",
|
||||||
name: "import-derive",
|
name: "import-derive",
|
||||||
component: () =>
|
component: () => import("../views/ImportDerivedAccountView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/new-edit-account",
|
path: "/new-edit-account",
|
||||||
name: "new-edit-account",
|
name: "new-edit-account",
|
||||||
component: () =>
|
component: () => import("../views/NewEditAccountView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "new-edit-account" */ "../views/NewEditAccountView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/new-edit-project",
|
path: "/new-edit-project",
|
||||||
name: "new-edit-project",
|
name: "new-edit-project",
|
||||||
component: () =>
|
component: () => import("../views/NewEditProjectView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/new-identifier",
|
path: "/new-identifier",
|
||||||
name: "new-identifier",
|
name: "new-identifier",
|
||||||
component: () =>
|
component: () => import("../views/NewIdentifierView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/project/:id?",
|
path: "/project/:id?",
|
||||||
name: "project",
|
name: "project",
|
||||||
component: () =>
|
component: () => import("../views/ProjectViewView.vue"),
|
||||||
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/projects",
|
path: "/projects",
|
||||||
name: "projects",
|
name: "projects",
|
||||||
component: () =>
|
component: () => import("../views/ProjectsView.vue"),
|
||||||
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
|
||||||
beforeEnter: enterOrStart,
|
beforeEnter: enterOrStart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/quick-action-bvc",
|
path: "/quick-action-bvc",
|
||||||
name: "quick-action-bvc",
|
name: "quick-action-bvc",
|
||||||
component: () =>
|
component: () => import("../views/QuickActionBvcView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "quick-action-bvc" */ "../views/QuickActionBvcView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/quick-action-bvc-begin",
|
path: "/quick-action-bvc-begin",
|
||||||
name: "quick-action-bvc-begin",
|
name: "quick-action-bvc-begin",
|
||||||
component: () =>
|
component: () => import("../views/QuickActionBvcBeginView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "quick-action-bvc-begin" */ "../views/QuickActionBvcBeginView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/quick-action-bvc-end",
|
path: "/quick-action-bvc-end",
|
||||||
name: "quick-action-bvc-end",
|
name: "quick-action-bvc-end",
|
||||||
component: () =>
|
component: () => import("../views/QuickActionBvcEndView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "quick-action-bvc-end" */ "../views/QuickActionBvcEndView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/scan-contact",
|
path: "/scan-contact",
|
||||||
name: "scan-contact",
|
name: "scan-contact",
|
||||||
component: () =>
|
component: () => import("../views/ContactScanView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/search-area",
|
path: "/search-area",
|
||||||
name: "search-area",
|
name: "search-area",
|
||||||
component: () =>
|
component: () => import("../views/SearchAreaView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/seed-backup",
|
path: "/seed-backup",
|
||||||
name: "seed-backup",
|
name: "seed-backup",
|
||||||
component: () =>
|
component: () => import("../views/SeedBackupView.vue"),
|
||||||
import(
|
},
|
||||||
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
|
{
|
||||||
),
|
path: "/shared-photo",
|
||||||
|
name: "shared-photo",
|
||||||
|
component: () => import("@/views/SharedPhotoView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/start",
|
path: "/start",
|
||||||
name: "start",
|
name: "start",
|
||||||
component: () =>
|
component: () => import("../views/StartView.vue"),
|
||||||
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/statistics",
|
path: "/statistics",
|
||||||
name: "statistics",
|
name: "statistics",
|
||||||
component: () =>
|
component: () => import("../views/StatisticsView.vue"),
|
||||||
import(
|
|
||||||
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/test",
|
path: "/test",
|
||||||
name: "test",
|
name: "test",
|
||||||
component: () =>
|
component: () => import("../views/TestView.vue"),
|
||||||
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @type {*} */
|
/** @type {*} */
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(process.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1
src/util.d.ts
vendored
1
src/util.d.ts
vendored
@@ -1,4 +1,5 @@
|
|||||||
// from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts
|
// from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts
|
||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* The `node:util` module supports the needs of Node.js internal APIs. Many of the
|
* The `node:util` module supports the needs of Node.js internal APIs. Many of the
|
||||||
* utilities are useful for application and module developers as well. To access
|
* utilities are useful for application and module developers as well. To access
|
||||||
|
|||||||
@@ -4,16 +4,6 @@
|
|||||||
|
|
||||||
<!-- 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 -->
|
|
||||||
<div class="text-lg text-center font-light relative px-7">
|
|
||||||
<h1
|
|
||||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
||||||
@click="$router.back()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
|
||||||
Your Identity
|
Your Identity
|
||||||
@@ -24,7 +14,7 @@
|
|||||||
<span class="whitespace-nowrap">
|
<span class="whitespace-nowrap">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'contact-qr' }"
|
:to="{ name: 'contact-qr' }"
|
||||||
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
|
class="text-xs bg-slate-500 text-white px-1.5 py-1 rounded-md"
|
||||||
>
|
>
|
||||||
<fa icon="qrcode" class="fa-fw"></fa>
|
<fa icon="qrcode" class="fa-fw"></fa>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -37,7 +27,7 @@
|
|||||||
<span>
|
<span>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'help' }"
|
:to="{ name: 'help' }"
|
||||||
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
|
class="text-xs 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-1.5 py-1 rounded-md ml-1"
|
||||||
>
|
>
|
||||||
Help
|
Help
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -55,7 +45,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'start' }"
|
:to="{ name: 'start' }"
|
||||||
class="inline-block text-md uppercase bg-amber-600 text-white px-4 py-2 rounded-md"
|
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Create An Identifier
|
Create An Identifier
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -63,20 +53,88 @@
|
|||||||
|
|
||||||
<!-- Identity Details -->
|
<!-- Identity Details -->
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||||
<h2 v-if="givenName" class="text-xl font-semibold mb-2">
|
<div v-if="givenName">
|
||||||
{{ givenName }}
|
<h2 class="text-xl font-semibold mb-2">
|
||||||
<router-link :to="{ name: 'new-edit-account' }">
|
{{ givenName }}
|
||||||
<fa icon="pen" class="text-xs text-blue-500 mb-1"></fa>
|
<router-link :to="{ name: 'new-edit-account' }">
|
||||||
</router-link>
|
<fa icon="pen" class="text-xs text-blue-500 ml-2 mb-1"></fa>
|
||||||
</h2>
|
</router-link>
|
||||||
<span v-else>
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="block w-full text-center text-md bg-amber-200 text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'new-edit-account' }"
|
:to="{ name: 'new-edit-account' }"
|
||||||
class="block w-full text-center text-md bg-amber-200 text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
|
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Set Your Name
|
Set Your Name
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<span v-if="profileImageUrl" class="flex justify-between">
|
||||||
|
<EntityIcon
|
||||||
|
:icon-size="96"
|
||||||
|
:profileImageUrl="profileImageUrl"
|
||||||
|
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||||
|
@click="showLargeIdenticonUrl = profileImageUrl"
|
||||||
|
/>
|
||||||
|
<fa
|
||||||
|
icon="trash-can"
|
||||||
|
@click="confirmDeleteImage"
|
||||||
|
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div v-else class="text-center">
|
||||||
|
<div class @click="openImageDialog()">
|
||||||
|
<fa
|
||||||
|
icon="camera"
|
||||||
|
class="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-2 rounded-l"
|
||||||
|
/>
|
||||||
|
<fa
|
||||||
|
icon="image-portrait"
|
||||||
|
class="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-2 rounded-r"
|
||||||
|
@click="openImageDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageMethodDialog ref="imageMethodDialog" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex justify-center text-center">
|
||||||
|
People {{ profileImageUrl ? "without your image" : "" }} see this
|
||||||
|
<br />
|
||||||
|
(if you've let them see your activity):
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<EntityIcon
|
||||||
|
:entityId="activeDid"
|
||||||
|
:iconSize="64"
|
||||||
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
|
@click="showLargeIdenticonId = activeDid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
|
||||||
|
class="fixed z-[100] top-0 inset-x-0 w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
:entityId="showLargeIdenticonId"
|
||||||
|
:iconSize="512"
|
||||||
|
:profileImageUrl="showLargeIdenticonUrl"
|
||||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||||
|
@click="
|
||||||
|
showLargeIdenticonId = undefined;
|
||||||
|
showLargeIdenticonUrl = undefined;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||||
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
|
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
|
||||||
@@ -91,12 +149,18 @@
|
|||||||
</button>
|
</button>
|
||||||
<span v-show="showDidCopy">Copied</span>
|
<span v-show="showDidCopy">Copied</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="text-blue-500 text-sm font-bold">
|
||||||
|
<router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }">
|
||||||
|
Activity
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Registration notice -->
|
<!-- Registration notice -->
|
||||||
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
|
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
|
||||||
<div
|
<div
|
||||||
v-if="!loadingLimits && !limits?.nextWeekBeginDateTime"
|
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
|
||||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
|
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
|
||||||
>
|
>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
@@ -105,13 +169,15 @@
|
|||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'contact-qr' }"
|
:to="{ name: 'contact-qr' }"
|
||||||
class="inline-block text-md uppercase bg-amber-600 text-white px-4 py-2 rounded-md"
|
class="inline-block text-md 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-4 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Share Your Info
|
Share Your Info
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
||||||
|
<!-- label -->
|
||||||
|
<div class="mb-2 font-bold">Notifications</div>
|
||||||
<div
|
<div
|
||||||
v-if="!notificationMaybeChanged"
|
v-if="!notificationMaybeChanged"
|
||||||
class="flex items-center justify-between cursor-pointer"
|
class="flex items-center justify-between cursor-pointer"
|
||||||
@@ -140,16 +206,29 @@
|
|||||||
Notification status may have changed. Refresh this page to see the
|
Notification status may have changed. Refresh this page to see the
|
||||||
latest setting.
|
latest setting.
|
||||||
</div>
|
</div>
|
||||||
<router-link class="px-4 text-sm text-blue-500" to="/help-notifications">
|
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
|
||||||
Troubleshoot your notification setup.
|
Troubleshoot your notification setup.
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
||||||
|
<!-- label -->
|
||||||
|
<div class="mb-2 font-bold">Location</div>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'search-area' }"
|
||||||
|
v-if="activeDid"
|
||||||
|
class="block w-full text-center text-m bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6"
|
||||||
|
>
|
||||||
|
Set Search Area…
|
||||||
|
<!-- If already set, change button label to "Change Search Area" -->
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="activeDid"
|
v-if="activeDid"
|
||||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||||
>
|
>
|
||||||
<div class="mb-2">Usage Limits</div>
|
<div class="mb-2 font-bold">Usage Limits</div>
|
||||||
<!-- show spinner if loading limits -->
|
<!-- show spinner if loading limits -->
|
||||||
<div v-if="loadingLimits" class="text-center">
|
<div v-if="loadingLimits" class="text-center">
|
||||||
Checking… <fa icon="spinner" class="fa-spin"></fa>
|
Checking… <fa icon="spinner" class="fa-spin"></fa>
|
||||||
@@ -157,31 +236,42 @@
|
|||||||
<div>
|
<div>
|
||||||
{{ limitsMessage }}
|
{{ limitsMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!!limits?.nextWeekBeginDateTime">
|
<div v-if="!!endorserLimits?.nextWeekBeginDateTime">
|
||||||
<p class="mb-3 text-sm">
|
|
||||||
You have done <b>{{ limits.doneClaimsThisWeek }}</b> claims out of
|
|
||||||
<b>{{ limits.maxClaimsPerWeek }}</b> for this week. Your claims
|
|
||||||
counter resets at
|
|
||||||
<b class="whitespace-nowrap">{{
|
|
||||||
readableTime(limits.nextWeekBeginDateTime)
|
|
||||||
}}</b>
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
You have done
|
You have done
|
||||||
<b>{{ limits.doneRegistrationsThisMonth }}</b> registrations out of
|
<b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of
|
||||||
<b>{{ limits.maxRegistrationsPerMonth }}</b> for this month.
|
<b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your
|
||||||
|
claims counter resets at
|
||||||
|
<b class="whitespace-nowrap">{{
|
||||||
|
readableDate(endorserLimits.nextWeekBeginDateTime)
|
||||||
|
}}</b>
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-sm">
|
||||||
|
You have done
|
||||||
|
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
|
||||||
|
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
|
||||||
|
month.
|
||||||
<i
|
<i
|
||||||
>(You can register nobody on your first day, and after that only one
|
>(You can register nobody on your first day, and after that only one
|
||||||
a day in your first month.)</i
|
a day in your first month.)</i
|
||||||
>
|
>
|
||||||
Your registration counter resets at
|
Your registration counter resets at
|
||||||
<b class="whitespace-nowrap">
|
<b class="whitespace-nowrap">
|
||||||
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
{{ readableDate(endorserLimits.nextMonthBeginDateTime) }}
|
||||||
</b>
|
</b>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-3 text-sm" v-if="!!imageLimits">
|
||||||
|
You have uploaded
|
||||||
|
<b>{{ imageLimits?.doneImagesThisWeek }} images</b> out of
|
||||||
|
<b>{{ imageLimits?.maxImagesPerWeek }}</b> for this week. Your image
|
||||||
|
counter resets at
|
||||||
|
<b class="whitespace-nowrap">{{
|
||||||
|
readableDate(imageLimits?.nextWeekBeginDateTime)
|
||||||
|
}}</b>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="block float-right w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
class="block float-right w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
||||||
@click="checkLimits()"
|
@click="checkLimits()"
|
||||||
>
|
>
|
||||||
Recheck Limits
|
Recheck Limits
|
||||||
@@ -189,18 +279,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
||||||
<div>Data Export</div>
|
<div class="mb-2 font-bold">Data Export</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'seed-backup' }"
|
:to="{ name: 'seed-backup' }"
|
||||||
v-if="activeDid"
|
v-if="activeDid"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
||||||
>
|
>
|
||||||
Backup Identifier Seed
|
Backup Identifier Seed
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-bind:class="computedStartDownloadLinkClassNames()"
|
v-bind:class="computedStartDownloadLinkClassNames()"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||||
@click="exportDatabase()"
|
@click="exportDatabase()"
|
||||||
>
|
>
|
||||||
Download Settings & Contacts
|
Download Settings & Contacts
|
||||||
@@ -210,7 +300,7 @@
|
|||||||
<a
|
<a
|
||||||
ref="downloadLink"
|
ref="downloadLink"
|
||||||
v-bind:class="computedDownloadLinkClassNames()"
|
v-bind:class="computedDownloadLinkClassNames()"
|
||||||
class="block w-full text-center text-md uppercase bg-green-600 text-white px-1.5 py-2 rounded-md"
|
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
|
||||||
>
|
>
|
||||||
If no download happened yet, click again here to download now.
|
If no download happened yet, click again here to download now.
|
||||||
</a>
|
</a>
|
||||||
@@ -291,7 +381,7 @@
|
|||||||
<router-link
|
<router-link
|
||||||
id="switch-identity-link"
|
id="switch-identity-link"
|
||||||
:to="{ name: 'identity-switcher' }"
|
:to="{ name: 'identity-switcher' }"
|
||||||
class="block w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
class="block w-fit 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-4 py-2 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
Switch Identifier
|
Switch Identifier
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -303,7 +393,7 @@
|
|||||||
>
|
>
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
<span class="text-slate-500 text-sm font-bold">Contacts Display</span>
|
<span class="text-slate-500 text-sm font-bold">Contacts Display</span>
|
||||||
<span class="ml-2">Show amounts given</span>
|
<span class="ml-2">Show hours given & received</span>
|
||||||
<!-- toggle -->
|
<!-- toggle -->
|
||||||
<div class="relative ml-2">
|
<div class="relative ml-2">
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
@@ -439,25 +529,57 @@
|
|||||||
{{ DEFAULT_PUSH_SERVER }}
|
{{ DEFAULT_PUSH_SERVER }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
|
||||||
|
|
||||||
|
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
for="toggleHideRegisterPromptOnNewContact"
|
||||||
|
class="flex items-center justify-between cursor-pointer mt-4"
|
||||||
|
@click="toggleHideRegisterPromptOnNewContact()"
|
||||||
|
>
|
||||||
|
<!-- label -->
|
||||||
|
<span class="text-slate-500 text-sm font-bold">
|
||||||
|
Hide Register Prompt on New Contact
|
||||||
|
</span>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div class="relative ml-2">
|
||||||
|
<!-- input -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="hideRegisterPromptOnNewContact"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
for="toggleShowShortcutBvc"
|
for="toggleShowShortcutBvc"
|
||||||
class="flex items-center justify-between cursor-pointer my-4"
|
class="flex items-center justify-between cursor-pointer mt-4"
|
||||||
@click="toggleShowShortcutBvc"
|
@click="toggleShowShortcutBvc"
|
||||||
>
|
>
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
<span class="text-slate-500 text-sm font-bold"
|
<span class="text-slate-500 text-sm font-bold">
|
||||||
>Show BVC Shortcut on Home Page</span
|
Show BVC Shortcut on Home Page
|
||||||
>
|
</span>
|
||||||
<!-- toggle -->
|
<!-- toggle -->
|
||||||
<div class="relative ml-2">
|
<div class="relative ml-2">
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
<input type="checkbox" v-model="showShortcutBvc" class="sr-only" />
|
<input type="checkbox" v-model="showShortcutBvc" class="sr-only" />
|
||||||
<!-- line -->
|
<!-- line -->
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||||||
<!-- dot -->
|
<!-- dot -->
|
||||||
<div
|
<div
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -468,11 +590,11 @@
|
|||||||
|
|
||||||
<div class="ml-4 mt-2">
|
<div class="ml-4 mt-2">
|
||||||
Import
|
Import
|
||||||
<input type="file" @change="uploadFile" class="ml-2" />
|
<input type="file" @change="uploadImportFile" class="ml-2" />
|
||||||
<div v-if="showContactImport()">
|
<div v-if="showContactImport()">
|
||||||
<button
|
<button
|
||||||
class="block text-center text-md uppercase bg-blue-500 text-white px-1.5 py-2 rounded-md mb-6"
|
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
|
||||||
@click="submitFile()"
|
@click="confirmSubmitImportFile()"
|
||||||
>
|
>
|
||||||
Import Settings & Contacts
|
Import Settings & Contacts
|
||||||
<br />
|
<br />
|
||||||
@@ -486,7 +608,7 @@
|
|||||||
<button>
|
<button>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'statistics' }"
|
:to="{ name: 'statistics' }"
|
||||||
class="block w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
See Global Animated History of Giving
|
See Global Animated History of Giving
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -497,7 +619,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import Dexie from "dexie";
|
import Dexie from "dexie";
|
||||||
import "dexie-export-import";
|
import "dexie-export-import";
|
||||||
import { ImportProgress } from "dexie-export-import/dist/import";
|
import { ImportProgress } from "dexie-export-import/dist/import";
|
||||||
@@ -505,21 +627,29 @@ import { ref } from "vue";
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
|
||||||
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 {
|
||||||
AppString,
|
AppString,
|
||||||
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
DEFAULT_PUSH_SERVER,
|
DEFAULT_PUSH_SERVER,
|
||||||
|
IMAGE_TYPE_PROFILE,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
} from "@/constants/app";
|
} from "@/constants/app";
|
||||||
import { db, accountsDB } from "@/db/index";
|
import { db, accountsDB } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { ErrorResponse, RateLimits } from "@/libs/endorserServer";
|
import {
|
||||||
|
ErrorResponse,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
EndorserRateLimits,
|
||||||
const Buffer = require("buffer/").Buffer;
|
ImageRateLimits,
|
||||||
|
fetchEndorserRateLimits,
|
||||||
|
fetchImageRateLimits,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { Buffer } from "buffer/";
|
||||||
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
|
|
||||||
interface IAccount {
|
interface IAccount {
|
||||||
did: string;
|
did: string;
|
||||||
@@ -528,29 +658,37 @@ interface IAccount {
|
|||||||
derivationPath: string;
|
derivationPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputFileNameRef = ref<Blob>();
|
const inputImportFileNameRef = ref<Blob>();
|
||||||
|
|
||||||
@Component({ components: { QuickNav, TopMessage } })
|
@Component({
|
||||||
|
components: { EntityIcon, ImageMethodDialog, QuickNav, TopMessage },
|
||||||
|
})
|
||||||
export default class AccountViewView extends Vue {
|
export default class AccountViewView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
AppConstants = AppString;
|
AppConstants = AppString;
|
||||||
DEFAULT_PUSH_SERVER = DEFAULT_PUSH_SERVER;
|
DEFAULT_PUSH_SERVER = DEFAULT_PUSH_SERVER;
|
||||||
|
DEFAULT_IMAGE_API_SERVER = DEFAULT_IMAGE_API_SERVER;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
apiServerInput = "";
|
apiServerInput = "";
|
||||||
derivationPath = "";
|
derivationPath = "";
|
||||||
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
||||||
|
endorserLimits: EndorserRateLimits | null = null;
|
||||||
givenName = "";
|
givenName = "";
|
||||||
|
imageLimits: ImageRateLimits | null = null;
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
isSubscribed = false;
|
isSubscribed = false;
|
||||||
notificationMaybeChanged = false;
|
notificationMaybeChanged = false;
|
||||||
|
profileImageUrl?: string;
|
||||||
publicHex = "";
|
publicHex = "";
|
||||||
publicBase64 = "";
|
publicBase64 = "";
|
||||||
|
showLargeIdenticonId?: string;
|
||||||
|
showLargeIdenticonUrl?: string;
|
||||||
webPushServer = "";
|
webPushServer = "";
|
||||||
webPushServerInput = "";
|
webPushServerInput = "";
|
||||||
limits: RateLimits | null = null;
|
|
||||||
limitsMessage = "";
|
limitsMessage = "";
|
||||||
loadingLimits = false;
|
loadingLimits = false;
|
||||||
showContactGives = false;
|
showContactGives = false;
|
||||||
@@ -559,19 +697,20 @@ export default class AccountViewView extends Vue {
|
|||||||
showB64Copy = false;
|
showB64Copy = false;
|
||||||
showPubCopy = false;
|
showPubCopy = false;
|
||||||
showAdvanced = false;
|
showAdvanced = false;
|
||||||
|
hideRegisterPromptOnNewContact = false;
|
||||||
showShortcutBvc = false;
|
showShortcutBvc = false;
|
||||||
subscription: PushSubscription | null = null;
|
subscription: PushSubscription | null = null;
|
||||||
warnIfProdServer = false;
|
warnIfProdServer = false;
|
||||||
warnIfTestServer = false;
|
warnIfTestServer = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async function executed when the component is created.
|
* Async function executed when the component is mounted.
|
||||||
* Initializes the component's state with values from the database,
|
* Initializes the component's state with values from the database,
|
||||||
* handles identity-related tasks, and checks limitations.
|
* handles identity-related tasks, and checks limitations.
|
||||||
*
|
*
|
||||||
* @throws Will display specific messages to the user based on different errors.
|
* @throws Will display specific messages to the user based on different errors.
|
||||||
*/
|
*/
|
||||||
async created() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
await db.open();
|
await db.open();
|
||||||
|
|
||||||
@@ -585,18 +724,13 @@ export default class AccountViewView extends Vue {
|
|||||||
if (identity) {
|
if (identity) {
|
||||||
this.processIdentity(identity);
|
this.processIdentity(identity);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
|
||||||
this.handleError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
this.subscription = await registration.pushManager.getSubscription();
|
this.subscription = await registration.pushManager.getSubscription();
|
||||||
this.isSubscribed = !!this.subscription;
|
this.isSubscribed = !!this.subscription;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Mount error:", error);
|
console.error("Mount error:", error);
|
||||||
|
this.handleError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,7 +752,10 @@ export default class AccountViewView extends Vue {
|
|||||||
(settings?.firstName || "") +
|
(settings?.firstName || "") +
|
||||||
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
|
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
this.profileImageUrl = settings?.profileImageUrl as string;
|
||||||
this.showContactGives = !!settings?.showContactGivesInline;
|
this.showContactGives = !!settings?.showContactGivesInline;
|
||||||
|
this.hideRegisterPromptOnNewContact =
|
||||||
|
!!settings?.hideRegisterPromptOnNewContact;
|
||||||
this.showShortcutBvc = !!settings?.showShortcutBvc;
|
this.showShortcutBvc = !!settings?.showShortcutBvc;
|
||||||
this.warnIfProdServer = !!settings?.warnIfProdServer;
|
this.warnIfProdServer = !!settings?.warnIfProdServer;
|
||||||
this.warnIfTestServer = !!settings?.warnIfTestServer;
|
this.warnIfTestServer = !!settings?.warnIfTestServer;
|
||||||
@@ -697,7 +834,7 @@ export default class AccountViewView extends Vue {
|
|||||||
this.updateShowShortcutBvc(this.showShortcutBvc);
|
this.updateShowShortcutBvc(this.showShortcutBvc);
|
||||||
}
|
}
|
||||||
|
|
||||||
readableTime(timeStr: string) {
|
readableDate(timeStr: string) {
|
||||||
return timeStr.substring(0, timeStr.indexOf("T"));
|
return timeStr.substring(0, timeStr.indexOf("T"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +981,28 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async toggleHideRegisterPromptOnNewContact() {
|
||||||
|
const newSetting = !this.hideRegisterPromptOnNewContact;
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
hideRegisterPromptOnNewContact: newSetting,
|
||||||
|
});
|
||||||
|
this.hideRegisterPromptOnNewContact = newSetting;
|
||||||
|
} catch (err) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Updating Setting",
|
||||||
|
text: "The setting may not have saved. Try again, maybe after restarting the app.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
console.error("Telling user to try again because:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async updateShowShortcutBvc(newSetting: boolean) {
|
public async updateShowShortcutBvc(newSetting: boolean) {
|
||||||
try {
|
try {
|
||||||
await db.open();
|
await db.open();
|
||||||
@@ -883,11 +1042,11 @@ export default class AccountViewView extends Vue {
|
|||||||
// Trigger the download
|
// Trigger the download
|
||||||
this.downloadDatabaseBackup(this.downloadUrl);
|
this.downloadDatabaseBackup(this.downloadUrl);
|
||||||
|
|
||||||
// Revoke the temporary URL -- not yet because of DuckDuckGo download failure
|
|
||||||
//URL.revokeObjectURL(this.downloadUrl);
|
|
||||||
|
|
||||||
// Notify the user that the download has started
|
// Notify the user that the download has started
|
||||||
this.notifyDownloadStarted();
|
this.notifyDownloadStarted();
|
||||||
|
|
||||||
|
// Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
|
||||||
|
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleExportError(error);
|
this.handleExportError(error);
|
||||||
}
|
}
|
||||||
@@ -926,13 +1085,13 @@ export default class AccountViewView extends Vue {
|
|||||||
|
|
||||||
public computedStartDownloadLinkClassNames() {
|
public computedStartDownloadLinkClassNames() {
|
||||||
return {
|
return {
|
||||||
invisible: this.downloadUrl,
|
hidden: this.downloadUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public computedDownloadLinkClassNames() {
|
public computedDownloadLinkClassNames() {
|
||||||
return {
|
return {
|
||||||
invisible: !this.downloadUrl,
|
hidden: !this.downloadUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,13 +1128,29 @@ export default class AccountViewView extends Vue {
|
|||||||
console.error("Export Error:", error);
|
console.error("Export Error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
async uploadImportFile(event: Event) {
|
||||||
async uploadFile(event: any) {
|
inputImportFileNameRef.value = event.target.files[0];
|
||||||
inputFileNameRef.value = event.target.files[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showContactImport() {
|
showContactImport() {
|
||||||
return !!inputFileNameRef.value;
|
return !!inputImportFileNameRef.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmitImportFile() {
|
||||||
|
if (inputImportFileNameRef.value != null) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Replace All",
|
||||||
|
text:
|
||||||
|
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
|
||||||
|
" Are you sure you want to import and replace all contacts and settings?",
|
||||||
|
onYes: this.submitImportFile,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -983,19 +1158,12 @@ export default class AccountViewView extends Vue {
|
|||||||
*
|
*
|
||||||
* @throws Will notify the user if there is an export error.
|
* @throws Will notify the user if there is an export error.
|
||||||
*/
|
*/
|
||||||
async submitFile() {
|
async submitImportFile() {
|
||||||
if (inputFileNameRef.value != null) {
|
if (inputImportFileNameRef.value != null) {
|
||||||
if (
|
await db.delete();
|
||||||
confirm(
|
await Dexie.import(inputImportFileNameRef.value as Blob, {
|
||||||
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
|
progressCallback: this.progressCallback,
|
||||||
" Are you sure you want to import and replace all contacts and settings?",
|
});
|
||||||
)
|
|
||||||
) {
|
|
||||||
await db.delete();
|
|
||||||
await Dexie.import(inputFileNameRef.value, {
|
|
||||||
progressCallback: this.progressCallback,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,11 +1206,15 @@ export default class AccountViewView extends Vue {
|
|||||||
this.limitsMessage = "";
|
this.limitsMessage = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await this.fetchRateLimits(identity);
|
const resp = await fetchEndorserRateLimits(
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
this.limits = resp.data;
|
this.endorserLimits = resp.data;
|
||||||
if (!this.isRegistered) {
|
if (!this.isRegistered) {
|
||||||
// the user is not known to be registered, but they are so let's record it
|
// the user was not known to be registered, but now they are (because we got no error) so let's record it
|
||||||
try {
|
try {
|
||||||
await db.open();
|
await db.open();
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
@@ -1054,7 +1226,7 @@ export default class AccountViewView extends Vue {
|
|||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "warning",
|
type: "danger",
|
||||||
title: "Update Error",
|
title: "Update Error",
|
||||||
text: "Unable to update your settings. Check claim limits again.",
|
text: "Unable to update your settings. Check claim limits again.",
|
||||||
},
|
},
|
||||||
@@ -1062,6 +1234,14 @@ export default class AccountViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const imageResp = await fetchImageRateLimits(
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
if (imageResp.status === 200) {
|
||||||
|
this.imageLimits = imageResp.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleRateLimitsError(error);
|
this.handleRateLimitsError(error);
|
||||||
@@ -1081,18 +1261,6 @@ export default class AccountViewView extends Vue {
|
|||||||
this.loadingLimits = false;
|
this.loadingLimits = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches rate limits from the server.
|
|
||||||
*
|
|
||||||
* @param {IIdentifier} identity - The identity object to check rate limits for.
|
|
||||||
* @returns {Promise<AxiosResponse>} The Axios response object.
|
|
||||||
*/
|
|
||||||
private async fetchRateLimits(identity: IIdentifier) {
|
|
||||||
const url = `${this.apiServer}/api/report/rateLimits`;
|
|
||||||
const headers = await this.getHeaders(identity);
|
|
||||||
return await this.axios.get(url, { headers } as AxiosRequestConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors that occur while fetching rate limits.
|
* Handles errors that occur while fetching rate limits.
|
||||||
*
|
*
|
||||||
@@ -1104,9 +1272,9 @@ export default class AccountViewView extends Vue {
|
|||||||
this.limitsMessage =
|
this.limitsMessage =
|
||||||
(data?.error?.message as string) || "Bad server response.";
|
(data?.error?.message as string) || "Bad server response.";
|
||||||
console.error(
|
console.error(
|
||||||
"Got bad response retrieving limits, which usually means user isn't registered:",
|
"Got bad response retrieving limits, which usually means user isn't registered.",
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
|
//console.error(error);
|
||||||
} else {
|
} else {
|
||||||
this.limitsMessage = "Got an error retrieving limits.";
|
this.limitsMessage = "Got an error retrieving limits.";
|
||||||
console.error("Got some error retrieving limits:", error);
|
console.error("Got some error retrieving limits:", error);
|
||||||
@@ -1205,5 +1373,105 @@ export default class AccountViewView extends Vue {
|
|||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openImageDialog() {
|
||||||
|
(this.$refs.imageMethodDialog as ImageMethodDialog).open(
|
||||||
|
async (imgUrl) => {
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
profileImageUrl: imgUrl,
|
||||||
|
});
|
||||||
|
this.profileImageUrl = imgUrl;
|
||||||
|
//console.log("Got image URL:", imgUrl);
|
||||||
|
},
|
||||||
|
IMAGE_TYPE_PROFILE,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDeleteImage() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title:
|
||||||
|
"Note that anyone with you already as a contact will no longer see a picture, and you will have to reshare your data with them if you save a new picture. Are you sure you want to delete your profile picture?",
|
||||||
|
text: "",
|
||||||
|
onYes: this.deleteImage,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage() {
|
||||||
|
if (!this.profileImageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
if (!identity) {
|
||||||
|
throw Error("No identity found.");
|
||||||
|
}
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
const response = await this.axios.delete(
|
||||||
|
DEFAULT_IMAGE_API_SERVER +
|
||||||
|
"/image/" +
|
||||||
|
encodeURIComponent(this.profileImageUrl),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 204) {
|
||||||
|
// don't bother with a notification
|
||||||
|
// (either they'll simply continue or they're canceling and going back)
|
||||||
|
} else {
|
||||||
|
console.error("Non-success deleting image:", response);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem deleting the image. Contact support if you want it removed from the servers.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
// keep the imageUrl in localStorage so the user can try again if they want
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
profileImageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.profileImageUrl = undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting image:", error);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((error as any).response.status === 404) {
|
||||||
|
console.error("The image was already deleted:", error);
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
profileImageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.profileImageUrl = undefined;
|
||||||
|
|
||||||
|
// it already doesn't exist so we won't say anything to the user
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error deleting the image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
@click="$router.go(-1)"
|
@click="$router.go(-1)"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
<fa icon="chevron-left" class="fa-fw" />
|
||||||
</button>
|
</button>
|
||||||
Verifiable Claim Details
|
Verifiable Claim Details
|
||||||
</h1>
|
</h1>
|
||||||
@@ -35,16 +35,16 @@
|
|||||||
"
|
"
|
||||||
class="ml-2 mr-2"
|
class="ml-2 mr-2"
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||||
</button>
|
</button>
|
||||||
<span v-show="showIdCopy">Copied ID</span>
|
<span v-show="showIdCopy">Copied ID</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<fa icon="message" class="fa-fw text-slate-400"></fa>
|
<fa icon="message" class="fa-fw text-slate-400" />
|
||||||
{{ veriClaim.claim?.description }}
|
{{ veriClaim.claim?.description }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
<fa icon="user" class="fa-fw text-slate-400" />
|
||||||
{{ veriClaim.issuer }}
|
{{ veriClaim.issuer }}
|
||||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
|
||||||
<button
|
<button
|
||||||
@@ -56,13 +56,13 @@
|
|||||||
"
|
"
|
||||||
class="ml-2 mr-2"
|
class="ml-2 mr-2"
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||||
</button>
|
</button>
|
||||||
<span v-show="showDidCopy">Copied DID</span>
|
<span v-show="showDidCopy">Copied DID</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||||
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,9 +121,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns-3">
|
<div class="flex columns-3">
|
||||||
<button
|
<button
|
||||||
class="col-span-1 bg-blue-600 text-white px-4 py-2 rounded-md"
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
v-if="
|
v-if="
|
||||||
libsUtil.isGiveRecordTheUserCanConfirm(
|
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||||
veriClaim,
|
veriClaim,
|
||||||
@@ -131,24 +131,34 @@
|
|||||||
confirmerIdList,
|
confirmerIdList,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="confirmClaim()"
|
@click="confirmConfirmClaim()"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span class="px-4 py-2">
|
||||||
|
<router-link
|
||||||
|
v-if="libsUtil.isGiveAction(veriClaim)"
|
||||||
|
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
|
||||||
|
class="col-span-1 text-blue-500"
|
||||||
|
>
|
||||||
|
Confirmation Details...
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="libsUtil.canFulfillOffer(veriClaim)"
|
v-if="libsUtil.canFulfillOffer(veriClaim)"
|
||||||
@click="openFulfillGiftDialog()"
|
@click="openFulfillGiftDialog()"
|
||||||
class="col-span-1 block w-fit text-center text-md bg-blue-600 text-white px-1.5 py-2 rounded-md"
|
class="col-span-1 block w-fit text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Affirm Delivery
|
Affirm Delivery
|
||||||
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
|
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<GiftedDialog ref="customGiveDialog" message="Offer fulfilled by" />
|
<GiftedDialog ref="customGiveDialog" />
|
||||||
|
|
||||||
<div v-if="libsUtil.giveIsConfirmable(veriClaim)">
|
<div v-if="libsUtil.isGiveAction(veriClaim)">
|
||||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
||||||
|
|
||||||
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
||||||
@@ -319,7 +329,7 @@
|
|||||||
class="list-disc p-4"
|
class="list-disc p-4"
|
||||||
>
|
>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<fa icon="minus" class="fa-fw"></fa>
|
<fa icon="minus" class="fa-fw" />
|
||||||
The {{ visibleDidPath }} is visible to:
|
The {{ visibleDidPath }} is visible to:
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-12 p-1">
|
<div class="ml-12 p-1">
|
||||||
@@ -341,8 +351,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||||
>, found at
|
>, found at
|
||||||
<fa icon="globe" class="fa-fw text-slate-400"></fa
|
<fa icon="globe" class="fa-fw text-slate-400" /> <a
|
||||||
> <a
|
|
||||||
:href="veriClaim.publicUrls?.[visDid]"
|
:href="veriClaim.publicUrls?.[visDid]"
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>{{
|
>{{
|
||||||
@@ -377,7 +386,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
|
class="block w-full text-center text-md 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-1.5 py-2 rounded-md mb-2"
|
||||||
@click="showFullClaim(veriClaim.id as string)"
|
@click="showFullClaim(veriClaim.id as string)"
|
||||||
>
|
>
|
||||||
Load Full Claim Details
|
Load Full Claim Details
|
||||||
@@ -390,7 +399,7 @@
|
|||||||
<a
|
<a
|
||||||
:href="apiServer + '/api/claim/' + veriClaim.id"
|
:href="apiServer + '/api/claim/' + veriClaim.id"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
|
class="block w-full text-center text-md 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-1.5 py-2 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
View on the Public Server
|
View on the Public Server
|
||||||
</a>
|
</a>
|
||||||
@@ -406,7 +415,6 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
import OfferDialog from "@/components/OfferDialog.vue";
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { accountsDB, db } from "@/db/index";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
@@ -415,12 +423,11 @@ import { accessToken } from "@/libs/crypto";
|
|||||||
import * as serverUtil from "@/libs/endorserServer";
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon.vue";
|
|
||||||
import { Account } from "@/db/tables/accounts";
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
components: { GiftedDialog, QuickNav },
|
||||||
})
|
})
|
||||||
export default class ClaimView extends Vue {
|
export default class ClaimView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
@@ -476,10 +483,10 @@ export default class ClaimView extends Vue {
|
|||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = accountsDB.accounts;
|
const accounts = accountsDB.accounts;
|
||||||
const accountsArr = await accounts?.toArray();
|
const accountsArr: Array<Account> = await accounts?.toArray();
|
||||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
this.accountIdentityStr = account?.identity || "null";
|
this.accountIdentityStr = (account?.identity as string) || "null";
|
||||||
const identity = JSON.parse(this.accountIdentityStr);
|
const identity = JSON.parse(this.accountIdentityStr);
|
||||||
|
|
||||||
const pathParam = window.location.pathname.substring("/claim/".length);
|
const pathParam = window.location.pathname.substring("/claim/".length);
|
||||||
@@ -668,7 +675,7 @@ export default class ClaimView extends Vue {
|
|||||||
const accounts = accountsDB.accounts;
|
const accounts = accountsDB.accounts;
|
||||||
const accountsArr: Account[] = await accounts?.toArray();
|
const accountsArr: Account[] = await accounts?.toArray();
|
||||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse((account?.identity as string) || "null");
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||||
@@ -717,52 +724,65 @@ export default class ClaimView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmConfirmClaim() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Confirm",
|
||||||
|
text: "Do you personally confirm that this is true?",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.confirmClaim();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// similar code is found in ProjectViewView
|
// similar code is found in ProjectViewView
|
||||||
async confirmClaim() {
|
async confirmClaim() {
|
||||||
if (confirm("Do you personally confirm that this is true?")) {
|
// similar logic is found in endorser-mobile
|
||||||
// similar logic is found in endorser-mobile
|
const goodClaim = serverUtil.removeSchemaContext(
|
||||||
const goodClaim = serverUtil.removeSchemaContext(
|
serverUtil.removeVisibleToDids(
|
||||||
serverUtil.removeVisibleToDids(
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
this.veriClaim.claim,
|
||||||
this.veriClaim.claim,
|
this.veriClaim.id,
|
||||||
this.veriClaim.id,
|
this.veriClaim.handleId,
|
||||||
this.veriClaim.handleId,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "AgreeAction",
|
||||||
|
object: goodClaim,
|
||||||
|
};
|
||||||
|
const result = await serverUtil.createAndSubmitClaim(
|
||||||
|
confirmationClaim,
|
||||||
|
await this.getIdentity(this.activeDid),
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
);
|
||||||
|
if (result.type === "success") {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "Confirmation submitted.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
);
|
);
|
||||||
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
} else {
|
||||||
"@context": "https://schema.org",
|
console.error("Got error submitting the confirmation:", result);
|
||||||
"@type": "AgreeAction",
|
this.$notify(
|
||||||
object: goodClaim,
|
{
|
||||||
};
|
group: "alert",
|
||||||
const result = await serverUtil.createAndSubmitClaim(
|
type: "danger",
|
||||||
confirmationClaim,
|
title: "Error",
|
||||||
await this.getIdentity(this.activeDid),
|
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||||
this.apiServer,
|
},
|
||||||
this.axios,
|
-1,
|
||||||
);
|
);
|
||||||
if (result.type === "success") {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "Confirmation submitted.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("Got error submitting the confirmation:", result);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,12 +797,14 @@ export default class ClaimView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openFulfillGiftDialog() {
|
openFulfillGiftDialog() {
|
||||||
const giver: GiverInputInfo = {
|
const giver: GiverReceiverInputInfo = {
|
||||||
did: libsUtil.offerGiverDid(this.veriClaim),
|
did: libsUtil.offerGiverDid(this.veriClaim),
|
||||||
};
|
};
|
||||||
(this.$refs.customGiveDialog as GiftedDialog).open(
|
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||||
giver,
|
giver,
|
||||||
|
undefined,
|
||||||
this.veriClaim.handleId,
|
this.veriClaim.handleId,
|
||||||
|
"Offer fulfilled by " + (giver?.name || "someone not named"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,17 +30,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<input
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
type="submit"
|
<input
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
type="submit"
|
||||||
value="Add Contact"
|
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"
|
||||||
/>
|
value="Add Contact"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
type="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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
878
src/views/ConfirmGiftView.vue
Normal file
878
src/views/ConfirmGiftView.vue
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
|
<!-- Back -->
|
||||||
|
<button
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-left" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||||
|
veriClaim,
|
||||||
|
activeDid,
|
||||||
|
confirmerIdList,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Do you agree?
|
||||||
|
</span>
|
||||||
|
<span v-else> Details </span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="giveDetails">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
|
v-if="
|
||||||
|
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||||
|
veriClaim,
|
||||||
|
activeDid,
|
||||||
|
confirmerIdList,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="confirmConfirmClaim()"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="notifyWhyCannotConfirm()"
|
||||||
|
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
|
||||||
|
:href="urlForNewGive"
|
||||||
|
>
|
||||||
|
Record a Similar One
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
|
||||||
|
<div class="block flex gap-4 overflow-hidden">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div>
|
||||||
|
<fa icon="arrow-down" class="fa-fw text-slate-400" />
|
||||||
|
{{ giverName }}
|
||||||
|
</div>
|
||||||
|
<div class="ml-6">gave</div>
|
||||||
|
<div v-if="giveDetails.amount">
|
||||||
|
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
|
||||||
|
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="giveDetails.description">
|
||||||
|
<fa icon="message" class="fa-fw text-slate-400" />
|
||||||
|
{{ giveDetails.amount ? "and:" : "" }}
|
||||||
|
{{ giveDetails.description }}
|
||||||
|
</div>
|
||||||
|
<div class="ml-6">to</div>
|
||||||
|
<div>
|
||||||
|
<fa icon="arrow-up" class="fa-fw text-slate-400" />
|
||||||
|
{{ recipientName }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||||
|
on
|
||||||
|
{{ giveDetails.issuedAt.substring(0, 10) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullfills Links -->
|
||||||
|
|
||||||
|
<!-- fullfills links for a give -->
|
||||||
|
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
|
||||||
|
<router-link
|
||||||
|
:to="
|
||||||
|
'/project/' +
|
||||||
|
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
|
||||||
|
"
|
||||||
|
class="text-blue-500 mt-2 cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
This fulfills a bigger plan
|
||||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<!-- if there's another, it's probably fulfilling an offer, too -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
giveDetails?.fulfillsType &&
|
||||||
|
giveDetails?.fulfillsType !== 'PlanAction' &&
|
||||||
|
giveDetails?.fulfillsHandleId
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- router-link to /claim/ only changes URL path -->
|
||||||
|
<router-link
|
||||||
|
:to="
|
||||||
|
'/claim/' +
|
||||||
|
encodeURIComponent(giveDetails?.fulfillsHandleId)
|
||||||
|
"
|
||||||
|
class="text-blue-500 mt-2 cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
This fulfills
|
||||||
|
{{
|
||||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
|
||||||
|
giveDetails.fulfillsType,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<fa icon="comment" class="text-slate-400" />
|
||||||
|
{{ issuerName }} posted that.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
|
||||||
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
||||||
|
|
||||||
|
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
||||||
|
<span v-else-if="totalConfirmers() === 1">
|
||||||
|
One person has confirmed this.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ totalConfirmers() }} people have confirmed this.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div v-if="totalConfirmers() > 0">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Nobody that you know confirmed this claim, nor do they have any
|
||||||
|
confirmers in their network.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Only show if this person has links to confirmers (below). -->
|
||||||
|
Nobody that you know has issued or confirmed this claim.
|
||||||
|
</div>
|
||||||
|
<div v-if="confirmerIdList.length > 0">
|
||||||
|
The following people have issued or confirmed this claim.
|
||||||
|
<ul class="ml-4">
|
||||||
|
<li
|
||||||
|
v-for="confirmerId in confirmerIdList"
|
||||||
|
:key="confirmerId"
|
||||||
|
class="list-disc ml-4"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="grow overflow-hidden">
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ didInfo(confirmerId) }}
|
||||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
copyToClipboard(
|
||||||
|
'The DID of ' + confirmerId,
|
||||||
|
confirmerId,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Never need to show this message:
|
||||||
|
"Nobody that you know can see someone who has confirmed this claim."
|
||||||
|
|
||||||
|
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
|
||||||
|
If there is somebody in the confirmerIdList then that's all they need to show.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Now show anyone linked to confirmers. -->
|
||||||
|
<div v-if="confsVisibleToIdList.length > 0">
|
||||||
|
The following people can connect you with people who have issued or
|
||||||
|
confirmed this claim.
|
||||||
|
<ul class="ml-4">
|
||||||
|
<li
|
||||||
|
v-for="confsVisibleTo in confsVisibleToIdList"
|
||||||
|
:key="confsVisibleTo"
|
||||||
|
class="list-disc ml-4"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="grow overflow-hidden">
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ didInfo(confsVisibleTo) }}
|
||||||
|
<span
|
||||||
|
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
copyToClipboard(
|
||||||
|
'The DID of ' + confsVisibleTo,
|
||||||
|
confsVisibleTo,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- explain if user cannot confirm -->
|
||||||
|
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
|
||||||
|
<div v-if="confirmerIdList.includes(activeDid)">
|
||||||
|
You have confirmed this claim.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="giveDetails.agentDid == activeDid">
|
||||||
|
You cannot confirm this because you issued this claim, so you already
|
||||||
|
count as confirming it.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
|
||||||
|
You cannot confirm this because it contains hidden identifiers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
|
||||||
|
@click="showDetails = !showDetails"
|
||||||
|
>
|
||||||
|
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
|
||||||
|
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
|
||||||
|
<span v-else><fa icon="chevron-up" /></span>
|
||||||
|
</h2>
|
||||||
|
<div v-if="showDetails">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
serverUtil.containsHiddenDid(veriClaim) &&
|
||||||
|
R.isEmpty(veriClaimDidsVisible)
|
||||||
|
"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
Some of the details are not visible to you; they show as "HIDDEN".
|
||||||
|
They are not visible to any of your direct contacts, either.
|
||||||
|
<span v-if="canShare">
|
||||||
|
If you'd like to ask any of your contacts to take a look and see if
|
||||||
|
their contacts can see more details,
|
||||||
|
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||||
|
>click to send them this info</a
|
||||||
|
>
|
||||||
|
and see if they are willing to make an introduction.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
If you'd like to ask any of your contacts to take a look and see if
|
||||||
|
their contacts can see more details,
|
||||||
|
<a
|
||||||
|
@click="copyToClipboard('Location', windowLocation.href)"
|
||||||
|
class="text-blue-500"
|
||||||
|
>share this page with them</a
|
||||||
|
>
|
||||||
|
and see if they are willing to make an introduction.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
|
||||||
|
Some of the details are not visible to you but they are visible to
|
||||||
|
some of your contacts.
|
||||||
|
<span v-if="canShare">
|
||||||
|
If you'd like an introduction,
|
||||||
|
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||||
|
>click to share the information with them and ask if they'll tell
|
||||||
|
you more about the participants.</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
If you'd like an introduction,
|
||||||
|
<a
|
||||||
|
@click="copyToClipboard('Location', windowLocation.href)"
|
||||||
|
class="text-blue-500"
|
||||||
|
>share this page with them and ask if they'll tell you more about
|
||||||
|
about the participants.</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
|
||||||
|
:key="index"
|
||||||
|
class="list-disc p-4"
|
||||||
|
>
|
||||||
|
<div class="text-sm">
|
||||||
|
<fa icon="minus" class="fa-fw" />
|
||||||
|
The {{ visibleDidPath }} is visible to:
|
||||||
|
</div>
|
||||||
|
<div class="ml-12 p-1">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
|
||||||
|
:key="idx2"
|
||||||
|
class="list-disc"
|
||||||
|
>
|
||||||
|
<div class="text-sm mt-2">
|
||||||
|
<span>
|
||||||
|
{{ didInfo(visDid) }}
|
||||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
copyToClipboard('The DID of ' + visDid, visDid)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||||
|
>, found at
|
||||||
|
<fa icon="globe" class="fa-fw text-slate-400" />
|
||||||
|
<a
|
||||||
|
:href="veriClaim.publicUrls?.[visDid]"
|
||||||
|
class="text-blue-500"
|
||||||
|
>{{
|
||||||
|
veriClaim.publicUrls[visDid].substring(
|
||||||
|
veriClaim.publicUrls[visDid].indexOf("//") + 2,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||||
|
<pre
|
||||||
|
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||||
|
>{{ veriClaimDump }}</pre
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>This does not have details to confirm.</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
@click="showClaimPage(veriClaim.id)"
|
||||||
|
class="text-blue-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<fa icon="file-lines" class="pl-2" />
|
||||||
|
All Generic Info
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import * as R from "ramda";
|
||||||
|
import { IIdentifier } from "@veramo/core";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
|
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
import { isGiveAction } from "@/libs/util";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
methods: { displayAmount },
|
||||||
|
components: { GiftedDialog, QuickNav },
|
||||||
|
})
|
||||||
|
export default class ClaimView extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
accountIdentityStr: string = "null";
|
||||||
|
activeDid = "";
|
||||||
|
allMyDids: Array<string> = [];
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
|
canShare = false;
|
||||||
|
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
|
||||||
|
confsVisibleErrorMessage = "";
|
||||||
|
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
|
||||||
|
giveDetails = null;
|
||||||
|
giverName = "";
|
||||||
|
issuerName = "";
|
||||||
|
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||||
|
recipientName = "";
|
||||||
|
showDetails = false;
|
||||||
|
urlForNewGive = "";
|
||||||
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
|
veriClaimDump = "";
|
||||||
|
veriClaimDidsVisible = {};
|
||||||
|
windowLocation = window.location;
|
||||||
|
|
||||||
|
R = R;
|
||||||
|
yaml = yaml;
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
serverUtil = serverUtil;
|
||||||
|
|
||||||
|
resetThisValues() {
|
||||||
|
this.confirmerIdList = [];
|
||||||
|
this.confsVisibleErrorMessage = "";
|
||||||
|
this.confsVisibleToIdList = [];
|
||||||
|
this.giveDetails = null;
|
||||||
|
this.numConfsNotVisible = 0;
|
||||||
|
this.urlForNewGive = "";
|
||||||
|
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
|
this.veriClaimDump = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.allContacts = await db.contacts.toArray();
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
const accounts = accountsDB.accounts;
|
||||||
|
const accountsArr: Array<Account> = await accounts?.toArray();
|
||||||
|
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||||
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
|
this.accountIdentityStr = (account?.identity as string) || "null";
|
||||||
|
const identity = JSON.parse(this.accountIdentityStr);
|
||||||
|
|
||||||
|
const pathParam = window.location.pathname.substring(
|
||||||
|
"/confirm-gift/".length,
|
||||||
|
);
|
||||||
|
let claimId;
|
||||||
|
if (pathParam) {
|
||||||
|
claimId = decodeURIComponent(pathParam);
|
||||||
|
await this.loadClaim(claimId, identity);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "No claim ID was provided.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||||
|
// then use this truer check: navigator.canShare && navigator.canShare()
|
||||||
|
this.canShare = !!navigator.share;
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert a space before any capital letters except the initial letter
|
||||||
|
// (and capitalize initial letter, just in case)
|
||||||
|
capitalizeAndInsertSpacesBeforeCaps(text: string) {
|
||||||
|
return !text
|
||||||
|
? ""
|
||||||
|
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
|
||||||
|
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
|
||||||
|
if (word) {
|
||||||
|
// if the word starts with a vowel, use "an" instead of "a"
|
||||||
|
const firstLetter = word[0].toLowerCase();
|
||||||
|
const vowels = ["a", "e", "i", "o", "u"];
|
||||||
|
const particle = vowels.includes(firstLetter) ? "an" : "a";
|
||||||
|
return particle + " " + word;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalConfirmers() {
|
||||||
|
return (
|
||||||
|
this.numConfsNotVisible +
|
||||||
|
this.confirmerIdList.length +
|
||||||
|
this.confsVisibleToIdList.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||||
|
await accountsDB.open();
|
||||||
|
const account = (await accountsDB.accounts
|
||||||
|
.where("did")
|
||||||
|
.equals(activeDid)
|
||||||
|
.first()) as Account;
|
||||||
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error(
|
||||||
|
"Attempted to load project records with no identifier available.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHeaders(identity: IIdentifier) {
|
||||||
|
const headers: RawAxiosRequestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isn't there a better way to make this available to the template?
|
||||||
|
didInfo(did: string | undefined) {
|
||||||
|
return serverUtil.didInfo(
|
||||||
|
did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadClaim(claimId: string, identity: IIdentifier) {
|
||||||
|
const urlPath = libsUtil.isGlobalUri(claimId)
|
||||||
|
? "/api/claim/byHandle/"
|
||||||
|
: "/api/claim/";
|
||||||
|
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = await this.getHeaders(identity);
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
// resp.data is:
|
||||||
|
// - a Jwt from https://api.endorser.ch/api-docs/
|
||||||
|
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
|
||||||
|
if (resp.status === 200) {
|
||||||
|
this.veriClaim = resp.data;
|
||||||
|
this.veriClaimDump = yaml.dump(this.veriClaim);
|
||||||
|
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
|
||||||
|
this.veriClaim,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// actually, axios typically throws an error so we never get here
|
||||||
|
console.error("Error getting claim:", resp);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem retrieving that claim.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve more details on Give, Offer, or Plan
|
||||||
|
if (this.veriClaim.claimType !== "GiveAction") {
|
||||||
|
// no need to go further... this page is for gifts
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.issuerName = this.didInfo(this.veriClaim.issuer);
|
||||||
|
|
||||||
|
// use give record when possible since it may include edits
|
||||||
|
const giveUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/gives?handleId=" +
|
||||||
|
encodeURIComponent(this.veriClaim.handleId as string);
|
||||||
|
const giveHeaders = await this.getHeaders(identity);
|
||||||
|
const giveResp = await this.axios.get(giveUrl, {
|
||||||
|
headers: giveHeaders,
|
||||||
|
});
|
||||||
|
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
|
||||||
|
if (giveResp.status === 200) {
|
||||||
|
this.giveDetails = giveResp.data.data[0];
|
||||||
|
} else {
|
||||||
|
console.error("Error getting detailed give info:", giveResp);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong retrieving gift data.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.urlForNewGive = "/gifted-details?";
|
||||||
|
if (this.giveDetails.amount) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
|
||||||
|
}
|
||||||
|
if (this.giveDetails.unit) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
|
||||||
|
}
|
||||||
|
if (this.giveDetails.description) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&description=" + encodeURIComponent(this.giveDetails.description);
|
||||||
|
}
|
||||||
|
this.giverName = this.didInfo(this.giveDetails.agentDid);
|
||||||
|
if (this.giveDetails.agentDid) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&giverDid=" +
|
||||||
|
encodeURIComponent(this.giveDetails.agentDid) +
|
||||||
|
"&giverName=" +
|
||||||
|
encodeURIComponent(this.giverName);
|
||||||
|
}
|
||||||
|
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
|
||||||
|
if (this.giveDetails.recipientDid) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&recipientDid=" +
|
||||||
|
encodeURIComponent(this.giveDetails.recipientDid) +
|
||||||
|
"&recipientName=" +
|
||||||
|
encodeURIComponent(this.recipientName);
|
||||||
|
}
|
||||||
|
if (this.giveDetails.fullClaim.image) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.giveDetails.type == "Offer" &&
|
||||||
|
this.giveDetails.fulfillsHandleId
|
||||||
|
) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId);
|
||||||
|
}
|
||||||
|
if (this.giveDetails.fulfillsPlanHandleId) {
|
||||||
|
this.urlForNewGive +=
|
||||||
|
"&projectId=" +
|
||||||
|
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve the list of confirmers
|
||||||
|
const confirmUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
||||||
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
||||||
|
const confirmHeaders = await this.getHeaders(identity);
|
||||||
|
const response = await this.axios.get(confirmUrl, {
|
||||||
|
headers: confirmHeaders,
|
||||||
|
});
|
||||||
|
if (response.status === 200) {
|
||||||
|
const resultList1 = response.data.result || [];
|
||||||
|
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
||||||
|
const resultList3 = R.reject(
|
||||||
|
(did: string) => did === this.giveDetails.agentDid,
|
||||||
|
resultList2,
|
||||||
|
);
|
||||||
|
this.confirmerIdList = resultList3;
|
||||||
|
this.numConfsNotVisible = resultList1.length - resultList2.length;
|
||||||
|
if (resultList3.length === resultList2.length) {
|
||||||
|
// the issuer was not in the "visible" list so they must be hidden
|
||||||
|
// so subtract them from the non-visible confirmers count
|
||||||
|
this.numConfsNotVisible = this.numConfsNotVisible - 1;
|
||||||
|
}
|
||||||
|
this.confsVisibleToIdList =
|
||||||
|
response.data.result.resultVisibleToDids || [];
|
||||||
|
} else {
|
||||||
|
this.confsVisibleErrorMessage =
|
||||||
|
"Had problems retrieving confirmations.";
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const serverError = error as AxiosError;
|
||||||
|
console.error("Error retrieving claim:", serverError);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong retrieving claim data.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmConfirmClaim() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Confirm",
|
||||||
|
text: "Do you personally confirm that this is true?",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.confirmClaim();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// similar code is found in ProjectViewView
|
||||||
|
async confirmClaim() {
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
const goodClaim = serverUtil.removeSchemaContext(
|
||||||
|
serverUtil.removeVisibleToDids(
|
||||||
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||||
|
this.veriClaim.claim,
|
||||||
|
this.veriClaim.id,
|
||||||
|
this.veriClaim.handleId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "AgreeAction",
|
||||||
|
object: goodClaim,
|
||||||
|
};
|
||||||
|
const result = await serverUtil.createAndSubmitClaim(
|
||||||
|
confirmationClaim,
|
||||||
|
await this.getIdentity(this.activeDid),
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
);
|
||||||
|
if (result.type === "success") {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "Confirmation submitted.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("Got error submitting the confirmation:", result);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showClaimPage(claimId: string) {
|
||||||
|
const route = {
|
||||||
|
path: "/claim/" + encodeURIComponent(claimId),
|
||||||
|
};
|
||||||
|
this.$router.push(route).then(async () => {
|
||||||
|
this.resetThisValues();
|
||||||
|
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openFulfillGiftDialog() {
|
||||||
|
const giver: GiverReceiverInputInfo = {
|
||||||
|
did: libsUtil.offerGiverDid(this.veriClaim),
|
||||||
|
};
|
||||||
|
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||||
|
giver,
|
||||||
|
undefined,
|
||||||
|
this.giveDetails.handleId,
|
||||||
|
"Offer fulfilled by " + (giver?.name || "someone not named"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard(name: string, text: string) {
|
||||||
|
useClipboard()
|
||||||
|
.copy(text)
|
||||||
|
.then(() => {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
title: "Copied",
|
||||||
|
text: (name || "That") + " was copied to the clipboard.",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyWhyCannotConfirm() {
|
||||||
|
if (!isGiveAction(this.veriClaim)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Not A Give",
|
||||||
|
text: "This is not a giving action to confirm.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else if (this.confirmerIdList.includes(this.activeDid)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Already Confirmed",
|
||||||
|
text: "You have already confirmed this claim.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else if (this.giveDetails.agentDid == this.activeDid) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Cannot Confirm",
|
||||||
|
text: "You cannot confirm this because you issued this claim.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Cannot Confirm",
|
||||||
|
text: "You cannot confirm this because it contains hidden identifiers.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Cannot Confirm",
|
||||||
|
text: "You cannot confirm this claim.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickShareClaim() {
|
||||||
|
window.navigator.share({
|
||||||
|
title: "Help Connect Me",
|
||||||
|
text: "I'm trying to find the full details of this claim. Can you help me?",
|
||||||
|
url: this.windowLocation.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<td class="p-1">
|
<td class="p-1">
|
||||||
<span v-if="record.agentDid == contact.did">
|
<span v-if="record.agentDid == contact.did">
|
||||||
<div class="font-bold">
|
<div class="font-bold">
|
||||||
{{ record.amount }} {{ record.unit }}
|
{{ displayAmount(record.unit, record.amount) }}
|
||||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||||
</span>
|
</span>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<td class="p-1">
|
<td class="p-1">
|
||||||
<span v-if="record.agentDid != contact.did">
|
<span v-if="record.agentDid != contact.did">
|
||||||
<div class="font-bold">
|
<div class="font-bold">
|
||||||
{{ record.amount }} {{ record.unit }}
|
{{ displayAmount(record.unit, record.amount) }}
|
||||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||||
</span>
|
</span>
|
||||||
@@ -119,7 +119,8 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|||||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||||
import {
|
import {
|
||||||
AgreeVerifiableCredential,
|
AgreeVerifiableCredential,
|
||||||
GiveServerRecord,
|
displayAmount,
|
||||||
|
GiveSummaryRecord,
|
||||||
GiveVerifiableCredential,
|
GiveVerifiableCredential,
|
||||||
SCHEMA_ORG_CONTEXT,
|
SCHEMA_ORG_CONTEXT,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
@@ -131,9 +132,11 @@ export default class ContactAmountssView extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
contact: Contact | null = null;
|
contact: Contact | null = null;
|
||||||
giveRecords: Array<GiveServerRecord> = [];
|
giveRecords: Array<GiveSummaryRecord> = [];
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
|
||||||
|
displayAmount = displayAmount;
|
||||||
|
|
||||||
async beforeCreate() {
|
async beforeCreate() {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
@@ -197,7 +200,7 @@ export default class ContactAmountssView extends Vue {
|
|||||||
async loadGives(activeDid: string, contact: Contact) {
|
async loadGives(activeDid: string, contact: Contact) {
|
||||||
try {
|
try {
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
let result: Array<GiveServerRecord> = [];
|
let result: Array<GiveSummaryRecord> = [];
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/gives?agentDid=" +
|
"/api/v2/report/gives?agentDid=" +
|
||||||
@@ -252,7 +255,7 @@ export default class ContactAmountssView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedResult: Array<GiveServerRecord> = R.sort(
|
const sortedResult: Array<GiveSummaryRecord> = R.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
||||||
result,
|
result,
|
||||||
@@ -271,7 +274,7 @@ export default class ContactAmountssView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm(record: GiveServerRecord) {
|
async confirm(record: GiveSummaryRecord) {
|
||||||
// Make claim
|
// Make claim
|
||||||
// I use clone here because otherwise it gets a Proxy object.
|
// I use clone here because otherwise it gets a Proxy object.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
width="32"
|
width="32"
|
||||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
/>
|
/>
|
||||||
Anonymous/Unnamed
|
Unnamed/Unknown
|
||||||
</span>
|
</span>
|
||||||
<span class="text-right">
|
<span class="text-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDialog()"
|
@click="openDialog()"
|
||||||
class="block w-full text-center text-sm uppercase bg-blue-600 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"
|
||||||
>
|
>
|
||||||
<fa icon="gift" class="fa-fw"></fa>
|
<fa icon="gift" class="fa-fw"></fa>
|
||||||
</button>
|
</button>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<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 font-semibold">
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entityId="contact.did"
|
:contact="contact"
|
||||||
:iconSize="32"
|
:iconSize="32"
|
||||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
/>
|
/>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDialog(contact)"
|
@click="openDialog(contact)"
|
||||||
class="block w-full text-center text-sm uppercase bg-blue-600 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"
|
||||||
>
|
>
|
||||||
<fa icon="gift" class="fa-fw"></fa>
|
<fa icon="gift" class="fa-fw"></fa>
|
||||||
</button>
|
</button>
|
||||||
@@ -66,11 +66,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<GiftedDialog
|
<GiftedDialog ref="customDialog" :projectId="projectId" />
|
||||||
ref="customDialog"
|
|
||||||
message="Received from"
|
|
||||||
:projectId="projectId"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,7 +83,7 @@ import { Account, AccountsSchema } from "@/db/tables/accounts";
|
|||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||||
@@ -99,12 +95,10 @@ export default class ContactGiftingView extends Vue {
|
|||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
accounts: typeof AccountsSchema;
|
accounts: typeof AccountsSchema;
|
||||||
numAccounts = 0;
|
|
||||||
projectId = localStorage.getItem("projectId") || "";
|
projectId = localStorage.getItem("projectId") || "";
|
||||||
|
|
||||||
async beforeCreate() {
|
async beforeCreate() {
|
||||||
accountsDB.open();
|
accountsDB.open();
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
@@ -113,7 +107,13 @@ export default class ContactGiftingView extends Vue {
|
|||||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = settings?.activeDid || "";
|
||||||
this.allContacts = await db.contacts.orderBy("name").toArray();
|
|
||||||
|
// .orderBy("name") wouldn't retrieve any entries with a blank name
|
||||||
|
// .toCollection.sortBy("name") didn't sort in an order I understood
|
||||||
|
const baseContacts = await db.contacts.toArray();
|
||||||
|
this.allContacts = baseContacts.sort((a, b) =>
|
||||||
|
(a.name || "").localeCompare(b.name || ""),
|
||||||
|
);
|
||||||
|
|
||||||
localStorage.removeItem("projectId");
|
localStorage.removeItem("projectId");
|
||||||
|
|
||||||
@@ -159,8 +159,16 @@ export default class ContactGiftingView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(giver: GiverInputInfo) {
|
openDialog(giver: GiverReceiverInputInfo) {
|
||||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
const recipient = this.projectId
|
||||||
|
? undefined
|
||||||
|
: { did: this.activeDid, name: "you" };
|
||||||
|
(this.$refs.customDialog as GiftedDialog).open(
|
||||||
|
giver,
|
||||||
|
recipient,
|
||||||
|
undefined,
|
||||||
|
"Given by " + (giver?.name || "someone not named"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
You aren't sharing your name, so quickly
|
You aren't sharing your name, so quickly
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'new-edit-account' }"
|
:to="{ name: 'new-edit-account' }"
|
||||||
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
|
class="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-1 rounded-md"
|
||||||
>
|
>
|
||||||
click here to set it for them.
|
click here to set it for them.
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
:dotsOptions="{ type: 'square' }"
|
:dotsOptions="{ type: 'square' }"
|
||||||
class="flex justify-center"
|
class="flex justify-center"
|
||||||
/>
|
/>
|
||||||
<span> Click QR to copy your contact URL to your clipboard. </span>
|
<span> Click that QR to copy your contact URL to your clipboard. </span>
|
||||||
|
<div>Not scanning? Show it in pieces.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center" v-else>
|
<div class="text-center" v-else>
|
||||||
You have no identitifiers yet, so
|
You have no identitifiers yet, so
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { AxiosError } from "axios";
|
||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
import { sha256 } from "ethereum-cryptography/sha256.js";
|
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||||
@@ -78,19 +80,27 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import { QrcodeStream } from "vue-qrcode-reader";
|
import { QrcodeStream } from "vue-qrcode-reader";
|
||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { accountsDB, db } from "@/db/index";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
|
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
|
||||||
import { Account } from "@/db/tables/accounts";
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import {
|
||||||
|
deriveAddress,
|
||||||
|
getContactPayloadFromJwtUrl,
|
||||||
|
nextDerivationPath,
|
||||||
|
SimpleSigner,
|
||||||
|
} from "@/libs/crypto";
|
||||||
import {
|
import {
|
||||||
CONTACT_URL_PREFIX,
|
CONTACT_URL_PREFIX,
|
||||||
ENDORSER_JWT_URL_LOCATION,
|
ENDORSER_JWT_URL_LOCATION,
|
||||||
|
isDid,
|
||||||
|
register,
|
||||||
|
setVisibilityUtil,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
import { Buffer } from "buffer/";
|
||||||
const Buffer = require("buffer/").Buffer;
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@@ -105,31 +115,19 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
givenName = "";
|
givenName = "";
|
||||||
|
hideRegisterPromptOnNewContact = false;
|
||||||
|
isRegistered = false;
|
||||||
qrValue = "";
|
qrValue = "";
|
||||||
|
|
||||||
public async getIdentity(activeDid: string) {
|
|
||||||
await accountsDB.open();
|
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
|
||||||
const account: Account | undefined = R.find(
|
|
||||||
(acc) => acc.did === activeDid,
|
|
||||||
accounts,
|
|
||||||
);
|
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
|
||||||
|
|
||||||
if (!identity) {
|
|
||||||
throw new Error(
|
|
||||||
"Attempted to show contact info with no identifier available.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
await db.open();
|
await db.open();
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = (settings?.activeDid as string) || "";
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = (settings?.apiServer as string) || "";
|
||||||
this.givenName = settings?.firstName || "";
|
this.givenName = (settings?.firstName as string) || "";
|
||||||
|
this.hideRegisterPromptOnNewContact =
|
||||||
|
!!settings?.hideRegisterPromptOnNewContact;
|
||||||
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
@@ -155,6 +153,8 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
||||||
publicEncKey,
|
publicEncKey,
|
||||||
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
|
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
|
||||||
|
profileImageUrl: settings?.profileImageUrl,
|
||||||
|
registered: settings?.isRegistered,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,25 +172,249 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
danger(message: string, title: string = "Error", timeout = 5000) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: title,
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string) {
|
||||||
|
await accountsDB.open();
|
||||||
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
|
const account: Account | undefined = R.find(
|
||||||
|
(acc) => acc.did === activeDid,
|
||||||
|
accounts,
|
||||||
|
);
|
||||||
|
const identity = JSON.parse((account?.identity as string) || "null");
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error(
|
||||||
|
"Attempted to show contact info with no identifier available.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param content is the result of a QR scan, an array with one item with a rawValue property
|
* @param content is the result of a QR scan, an array with one item with a rawValue property
|
||||||
*/
|
*/
|
||||||
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
|
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onScanDetect(content: any) {
|
async onScanDetect(content: any) {
|
||||||
if (content[0]?.rawValue) {
|
const url = content[0]?.rawValue;
|
||||||
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
|
if (url) {
|
||||||
this.$router.push({ name: "contacts" });
|
let newContact: Contact;
|
||||||
|
try {
|
||||||
|
const payload = getContactPayloadFromJwtUrl(url);
|
||||||
|
if (!payload) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "No Contact Info",
|
||||||
|
text: "The contact info could not be parsed.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newContact = {
|
||||||
|
did: payload.iss as string,
|
||||||
|
name: payload.own.name,
|
||||||
|
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
|
||||||
|
profileImageUrl: payload.own.profileImageUrl,
|
||||||
|
publicKeyBase64: payload.own.publicEncKey,
|
||||||
|
registered: payload.own.registered,
|
||||||
|
};
|
||||||
|
if (!newContact.did) {
|
||||||
|
this.danger("There is no DID.", "Incomplete Contact");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isDid(newContact.did)) {
|
||||||
|
this.danger("The DID must begin with 'did:'", "Invalid DID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing QR info:", e);
|
||||||
|
this.danger("Could not parse the QR info.", "Read Error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
await db.contacts.add(newContact);
|
||||||
|
|
||||||
|
let addedMessage;
|
||||||
|
if (this.activeDid) {
|
||||||
|
await this.setVisibility(newContact, true);
|
||||||
|
newContact.seesMe = true; // didn't work inside setVisibility
|
||||||
|
addedMessage =
|
||||||
|
"They were added, and your activity is visible to them.";
|
||||||
|
} else {
|
||||||
|
addedMessage = "They were added.";
|
||||||
|
}
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Contact Added",
|
||||||
|
text: addedMessage,
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.isRegistered) {
|
||||||
|
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Register",
|
||||||
|
text: "Do you want to register them?",
|
||||||
|
onCancel: async (stopAsking: boolean) => {
|
||||||
|
if (stopAsking) {
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
hideRegisterPromptOnNewContact: stopAsking,
|
||||||
|
});
|
||||||
|
this.hideRegisterPromptOnNewContact = stopAsking;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNo: async (stopAsking: boolean) => {
|
||||||
|
if (stopAsking) {
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
hideRegisterPromptOnNewContact: stopAsking,
|
||||||
|
});
|
||||||
|
this.hideRegisterPromptOnNewContact = stopAsking;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onYes: async () => {
|
||||||
|
await this.register(newContact);
|
||||||
|
},
|
||||||
|
promptToStopAsking: true,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error saving contact info:", e);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Contact Error",
|
||||||
|
text: "Could not save contact info. Check if it already exists.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "warning",
|
type: "danger",
|
||||||
title: "Invalid Contact QR Code",
|
title: "Invalid Contact QR Code",
|
||||||
text: "No QR code detected with contact information.",
|
text: "No QR code detected with contact information.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVisibility(contact: Contact, visibility: boolean) {
|
||||||
|
const result = await setVisibilityUtil(
|
||||||
|
this.activeDid,
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
db,
|
||||||
|
contact,
|
||||||
|
visibility,
|
||||||
|
);
|
||||||
|
if (result.error) {
|
||||||
|
this.danger(result.error as string, "Error Setting Visibility");
|
||||||
|
} else if (!result.success) {
|
||||||
|
console.error("Got strange result from setting visibility:", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(contact: Contact) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
text: "",
|
||||||
|
title: "Registration submitted...",
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regResult = await register(
|
||||||
|
this.activeDid,
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
contact,
|
||||||
|
);
|
||||||
|
if (regResult.success) {
|
||||||
|
contact.registered = true;
|
||||||
|
db.contacts.update(contact.did, { registered: true });
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Registration Success",
|
||||||
|
text:
|
||||||
|
(contact.name || "That unnamed person") + " has been registered.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Registration Error",
|
||||||
|
text:
|
||||||
|
(regResult.error as string) ||
|
||||||
|
"Something went wrong during registration.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when registering:", error);
|
||||||
|
let userMessage = "There was an error. See logs for more info.";
|
||||||
|
const serverError = error as AxiosError;
|
||||||
|
if (serverError) {
|
||||||
|
if (serverError.response?.data?.error?.message) {
|
||||||
|
userMessage = serverError.response.data.error.message;
|
||||||
|
} else if (serverError.message) {
|
||||||
|
userMessage = serverError.message; // Info for the user
|
||||||
|
} else {
|
||||||
|
userMessage = JSON.stringify(serverError.toJSON());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userMessage = error as string;
|
||||||
|
}
|
||||||
|
// Now set that error for the user to see.
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Registration Error",
|
||||||
|
text: userMessage,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,15 +425,16 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "warning",
|
type: "danger",
|
||||||
title: "Invalid Scan",
|
title: "Invalid Scan",
|
||||||
text: "The scan was invalid.",
|
text: "The scan was invalid.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onCopyToClipboard() {
|
onCopyToClipboard() {
|
||||||
|
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||||
useClipboard()
|
useClipboard()
|
||||||
.copy(this.qrValue)
|
.copy(this.qrValue)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -65,17 +65,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<input
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
type="submit"
|
<input
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
type="submit"
|
||||||
value="Look Up Contact"
|
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"
|
||||||
/>
|
value="Look Up Contact"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
type="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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
337
src/views/DIDView.vue
Normal file
337
src/views/DIDView.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav selected="Contacts" />
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
|
<!-- Back -->
|
||||||
|
<button
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||||
|
</button>
|
||||||
|
Identifier Details
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identity Details -->
|
||||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
{{
|
||||||
|
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
|
||||||
|
.displayName
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<span class="mt-2 text-xl font-semibold break-words">
|
||||||
|
{{ viewingDid }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<span v-if="contact?.profileImageUrl" class="flex justify-between">
|
||||||
|
<EntityIcon
|
||||||
|
:icon-size="96"
|
||||||
|
:profileImageUrl="contact?.profileImageUrl"
|
||||||
|
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||||
|
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex justify-center">Auto-Generated Icon:</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<EntityIcon
|
||||||
|
:entityId="viewingDid"
|
||||||
|
:iconSize="64"
|
||||||
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
|
@click="showLargeIdenticonId = viewingDid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
|
||||||
|
class="fixed z-[100] top-0 inset-x-0 w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
:entityId="showLargeIdenticonId"
|
||||||
|
:iconSize="512"
|
||||||
|
:profileImageUrl="showLargeIdenticonUrl"
|
||||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||||
|
@click="
|
||||||
|
showLargeIdenticonId = undefined;
|
||||||
|
showLargeIdenticonUrl = undefined;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Animation -->
|
||||||
|
<div
|
||||||
|
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||||
|
v-if="isLoading"
|
||||||
|
>
|
||||||
|
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||||
|
</div>
|
||||||
|
<!-- Results List -->
|
||||||
|
<div v-if="claims.length > 0" class="mt-4">
|
||||||
|
<div class="text-l font-bold text-center">Claims That Involve Them</div>
|
||||||
|
</div>
|
||||||
|
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
class="border-b border-slate-300"
|
||||||
|
v-for="claim in claims"
|
||||||
|
:key="claim.handleId"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-12 gap-4">
|
||||||
|
<span class="col-span-2">
|
||||||
|
{{ claim.issuedAt.substring(0, 10) }}
|
||||||
|
</span>
|
||||||
|
<span class="col-span-2">
|
||||||
|
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
|
||||||
|
</span>
|
||||||
|
<span class="col-span-2">
|
||||||
|
{{ claimAmount(claim) }}
|
||||||
|
</span>
|
||||||
|
<span class="col-span-5">
|
||||||
|
{{ claimDescription(claim) }}
|
||||||
|
</span>
|
||||||
|
<span class="col-span-1">
|
||||||
|
<a
|
||||||
|
@click="onClickLoadClaim(claim.handleId)"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfiniteScroll>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && claims.length === 0"
|
||||||
|
class="flex justify-center mt-4"
|
||||||
|
>
|
||||||
|
<span>They Are in No Claims Visible to You</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
import {
|
||||||
|
capitalizeAndInsertSpacesBeforeCaps,
|
||||||
|
didInfoForContact,
|
||||||
|
displayAmount,
|
||||||
|
GenericCredWrapper,
|
||||||
|
GenericVerifiableCredential,
|
||||||
|
GiveVerifiableCredential,
|
||||||
|
OfferVerifiableCredential,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
InfiniteScroll,
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class DIDView extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
allMyDids: Array<string> = [];
|
||||||
|
apiServer = "";
|
||||||
|
claims: Array<GenericCredWrapper> = [];
|
||||||
|
contact?: Contact;
|
||||||
|
hitEnd = false;
|
||||||
|
isLoading = false;
|
||||||
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||||
|
showLargeIdenticonId?: string;
|
||||||
|
showLargeIdenticonUrl?: string;
|
||||||
|
viewingDid?: string;
|
||||||
|
|
||||||
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||||
|
didInfoForContact = didInfoForContact;
|
||||||
|
displayAmount = displayAmount;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
this.activeDid = (settings?.activeDid as string) || "";
|
||||||
|
this.apiServer = (settings?.apiServer as string) || "";
|
||||||
|
|
||||||
|
const pathParam = window.location.pathname.substring("/did/".length);
|
||||||
|
if (pathParam) {
|
||||||
|
this.viewingDid = decodeURIComponent(pathParam);
|
||||||
|
this.contact = await db.contacts.get(this.viewingDid);
|
||||||
|
await this.loadClaimsAbout();
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "No claim ID was provided.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
|
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async buildHeaders(): Promise<HeadersInit> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.activeDid) {
|
||||||
|
await accountsDB.open();
|
||||||
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
|
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||||
|
const identity = JSON.parse((account?.identity as string) || "null");
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error(
|
||||||
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||||
|
} else {
|
||||||
|
// it's OK without auth... we just won't get any identifiers
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data loader used by infinite scroller
|
||||||
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||||
|
**/
|
||||||
|
async loadMoreData(payload: boolean) {
|
||||||
|
if (this.claims.length > 0 && !this.hitEnd && payload) {
|
||||||
|
this.loadClaimsAbout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadClaimsAbout() {
|
||||||
|
if (!this.viewingDid) {
|
||||||
|
console.error("This should never be called without a DID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
|
||||||
|
let postfix = "";
|
||||||
|
if (this.claims.length > 0) {
|
||||||
|
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isLoading = true;
|
||||||
|
const response = await fetch(
|
||||||
|
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: await this.buildHeaders(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const details = await response.text();
|
||||||
|
console.error("Problem with full search:", details);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: `There was a problem accessing the server. Try again later.`,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
this.claims = this.claims.concat(results.data);
|
||||||
|
this.hitEnd = !results.hitLimit;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Error with feed load:", e);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: e.userMessage || "There was a problem retrieving claims.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickLoadClaim(jwtId: string) {
|
||||||
|
const route = {
|
||||||
|
path: "/claim/" + encodeURIComponent(jwtId),
|
||||||
|
};
|
||||||
|
this.$router.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
public claimAmount(claim: GenericVerifiableCredential) {
|
||||||
|
if (claim.claimType === "GiveAction") {
|
||||||
|
const giveClaim = claim.claim as GiveVerifiableCredential;
|
||||||
|
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
|
||||||
|
return displayAmount(
|
||||||
|
giveClaim.object.unitCode,
|
||||||
|
giveClaim.object.amountOfThisGood,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else if (claim.claimType === "Offer") {
|
||||||
|
const offerClaim = claim.claim as OfferVerifiableCredential;
|
||||||
|
if (
|
||||||
|
offerClaim.includesObject?.unitCode &&
|
||||||
|
offerClaim.includesObject?.amountOfThisGood
|
||||||
|
) {
|
||||||
|
return displayAmount(
|
||||||
|
offerClaim.includesObject.unitCode,
|
||||||
|
offerClaim.includesObject.amountOfThisGood,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
claimDescription(claim: GenericVerifiableCredential) {
|
||||||
|
return claim.claim.name || claim.claim.description || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<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">
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
Discover
|
Discover Projects
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Quick Search -->
|
<!-- Quick Search -->
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<div v-if="isLocalActive">
|
<div v-if="isLocalActive">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
@click="$router.push({ name: 'search-area' })"
|
@click="$router.push({ name: 'search-area' })"
|
||||||
>
|
>
|
||||||
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||||
@@ -100,14 +100,15 @@
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@click="onClickLoadProject(project.handleId)"
|
@click="onClickLoadProject(project.handleId)"
|
||||||
class="block py-4 flex gap-4"
|
class="block py-4 flex gap-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="w-12">
|
<div>
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entityId="project.handleId"
|
:entityId="project.handleId"
|
||||||
:iconSize="48"
|
:iconSize="48"
|
||||||
class="block border border-slate-300 rounded-md"
|
:imageUrl="project.image"
|
||||||
></ProjectIcon>
|
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
@@ -131,7 +132,6 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
|
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon.vue";
|
|
||||||
import ProjectIcon from "@/components/ProjectIcon.vue";
|
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||||
import TopMessage from "@/components/TopMessage.vue";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
@@ -143,7 +143,6 @@ import { didInfo, PlanData } from "@/libs/endorserServer";
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
EntityIcon,
|
|
||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
@@ -273,8 +272,15 @@ export default class DiscoverView extends Vue {
|
|||||||
const plans: PlanData[] = results.data;
|
const plans: PlanData[] = results.data;
|
||||||
if (plans) {
|
if (plans) {
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
const { name, description, handleId, image, issuerDid, rowid } = plan;
|
||||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
this.projects.push({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
handleId,
|
||||||
|
image,
|
||||||
|
issuerDid,
|
||||||
|
rowid,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.remoteCount = this.projects.length;
|
this.remoteCount = this.projects.length;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
601
src/views/GiftedDetails.vue
Normal file
601
src/views/GiftedDetails.vue
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Back -->
|
||||||
|
<div
|
||||||
|
v-if="!hideBackButton"
|
||||||
|
class="text-lg text-center font-light relative px-7"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||||
|
@click="cancelBack()"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||||
|
</h1>
|
||||||
|
</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 {{ giverName || "somebody not named" }}</span>
|
||||||
|
<span> to {{ recipientName || "somebody not named" }}</span>
|
||||||
|
</h1>
|
||||||
|
<textarea
|
||||||
|
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||||
|
placeholder="What was received"
|
||||||
|
v-model="description"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-row justify-center">
|
||||||
|
<span
|
||||||
|
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()"
|
||||||
|
>
|
||||||
|
{{ 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()"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-left" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||||
|
v-model="amountInput"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||||
|
@click="increment()"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<span v-if="imageUrl" class="flex justify-between">
|
||||||
|
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
|
||||||
|
<img :src="imageUrl" class="h-24 rounded-xl" />
|
||||||
|
</a>
|
||||||
|
<fa
|
||||||
|
icon="trash-can"
|
||||||
|
@click="confirmDeleteImage"
|
||||||
|
class="text-red-500 fa-fw ml-8 mt-10"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<fa
|
||||||
|
icon="camera"
|
||||||
|
class="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-2 rounded-md"
|
||||||
|
@click="openImageDialog"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ImageMethodDialog ref="imageDialog" />
|
||||||
|
|
||||||
|
<div class="h-7 mt-4 flex">
|
||||||
|
<input
|
||||||
|
v-if="projectId && !givenToUser"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-6 w-6 mr-2"
|
||||||
|
v-model="givenToProject"
|
||||||
|
/>
|
||||||
|
<fa
|
||||||
|
v-else
|
||||||
|
icon="square"
|
||||||
|
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||||
|
@click="notifyUserOfProject()"
|
||||||
|
/>
|
||||||
|
<label class="text-sm mt-1">
|
||||||
|
{{
|
||||||
|
projectId
|
||||||
|
? "This was given to " + projectName
|
||||||
|
: "No project was chosen"
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-7 mt-4 flex">
|
||||||
|
<input
|
||||||
|
v-if="!givenToProject"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-6 w-6 mr-2"
|
||||||
|
v-model="givenToUser"
|
||||||
|
/>
|
||||||
|
<fa
|
||||||
|
v-else
|
||||||
|
icon="square"
|
||||||
|
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||||
|
@click="
|
||||||
|
notifyUser('You cannot assign this both a project and also to you.')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<label class="text-sm mt-1">This was given to you</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex">
|
||||||
|
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
|
||||||
|
<label class="text-sm mt-1">This was a trade (not a gift)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center mb-2 mt-6 italic">
|
||||||
|
Sign & Send to publish to the world
|
||||||
|
<fa
|
||||||
|
icon="circle-info"
|
||||||
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
|
@click="explainData()"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<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"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
Sign & 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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
import { createAndSubmitGive, getPlanFromCache } from "@/libs/endorserServer";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
ImageMethodDialog,
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class GiftedDetails extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
|
amountInput = "0";
|
||||||
|
description = "";
|
||||||
|
destinationNameAfter = "";
|
||||||
|
givenToProject = false;
|
||||||
|
givenToUser = false;
|
||||||
|
giverDid: string | undefined;
|
||||||
|
giverName = "";
|
||||||
|
hideBackButton = false;
|
||||||
|
imageUrl = "";
|
||||||
|
isTrade = false;
|
||||||
|
message = "";
|
||||||
|
offerId = "";
|
||||||
|
projectId = "";
|
||||||
|
projectName = "a project";
|
||||||
|
recipientDid = "";
|
||||||
|
recipientName = "";
|
||||||
|
showGivenToUser = false;
|
||||||
|
unitCode = "HUR";
|
||||||
|
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
this.amountInput =
|
||||||
|
(this.$route.query.amountInput as string) || this.amountInput;
|
||||||
|
this.description = (this.$route.query.description as string) || "";
|
||||||
|
this.destinationNameAfter = this.$route.query
|
||||||
|
.destinationNameAfter as string;
|
||||||
|
this.giverDid = this.$route.query.giverDid as string;
|
||||||
|
this.giverName = (this.$route.query.giverName as string) || "";
|
||||||
|
this.hideBackButton = this.$route.query.hideBackButton === "true";
|
||||||
|
this.message = (this.$route.query.message as string) || "";
|
||||||
|
this.offerId = this.$route.query.offerId as string;
|
||||||
|
this.projectId = this.$route.query.projectId as string;
|
||||||
|
this.recipientDid = this.$route.query.recipientDid as string;
|
||||||
|
this.recipientName = (this.$route.query.recipientName as string) || "";
|
||||||
|
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
|
||||||
|
|
||||||
|
this.imageUrl =
|
||||||
|
(this.$route.query.imageUrl as string) ||
|
||||||
|
localStorage.getItem("imageUrl") ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
// this is an endpoint for sharing project info to highlight something given
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
|
||||||
|
if (this.$route.query.shareTitle) {
|
||||||
|
this.description = this.$route.query.shareTitle as string;
|
||||||
|
}
|
||||||
|
if (this.$route.query.shareText) {
|
||||||
|
this.description =
|
||||||
|
(this.description ? this.description + " " : "") +
|
||||||
|
(this.$route.query.shareText as string);
|
||||||
|
}
|
||||||
|
if (this.$route.query.shareUrl) {
|
||||||
|
this.imageUrl = this.$route.query.shareUrl as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
|
||||||
|
if (this.giverDid && !this.giverName) {
|
||||||
|
this.giverName =
|
||||||
|
this.giverDid === this.activeDid ? "you" : "someone not named";
|
||||||
|
}
|
||||||
|
this.givenToUser = this.recipientDid === this.activeDid;
|
||||||
|
if (this.recipientDid && !this.recipientName) {
|
||||||
|
this.recipientName =
|
||||||
|
this.recipientDid === this.activeDid ? "you" : "someone not named";
|
||||||
|
}
|
||||||
|
this.givenToProject = !!this.projectId;
|
||||||
|
this.givenToUser =
|
||||||
|
!this.projectId && this.recipientDid === this.activeDid;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error retrieving settings from database:", err);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: err.message || "There was an error retrieving your settings.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.projectId) {
|
||||||
|
// console.log("Getting project name from cache", this.projectId);
|
||||||
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
|
const project = await getPlanFromCache(
|
||||||
|
this.projectId,
|
||||||
|
identity,
|
||||||
|
this.axios,
|
||||||
|
this.apiServer,
|
||||||
|
);
|
||||||
|
console.log("Got project name from cache", project);
|
||||||
|
this.projectName = project?.name
|
||||||
|
? "the project: " + project.name
|
||||||
|
: "a project";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changeUnitCode() {
|
||||||
|
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||||
|
const index = units.indexOf(this.unitCode);
|
||||||
|
this.unitCode = units[(index + 1) % units.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
decrement() {
|
||||||
|
this.amountInput = `${Math.max(
|
||||||
|
0,
|
||||||
|
(parseFloat(this.amountInput) || 1) - 1,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||||
|
if (this.destinationNameAfter) {
|
||||||
|
this.$router.push({ name: this.destinationNameAfter });
|
||||||
|
} else {
|
||||||
|
this.$router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBack() {
|
||||||
|
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||||
|
this.$router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
openImageDialog() {
|
||||||
|
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
|
||||||
|
this.imageUrl = imgUrl;
|
||||||
|
}, "GiveAction");
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDeleteImage() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Are you sure you want to delete the image?",
|
||||||
|
text: "",
|
||||||
|
onYes: this.deleteImage,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage() {
|
||||||
|
if (!this.imageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
const response = await this.axios.delete(
|
||||||
|
DEFAULT_IMAGE_API_SERVER +
|
||||||
|
"/image/" +
|
||||||
|
encodeURIComponent(this.imageUrl),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 204) {
|
||||||
|
// don't bother with a notification
|
||||||
|
// (either they'll simply continue or they're canceling and going back)
|
||||||
|
} else {
|
||||||
|
console.error("Problem deleting image:", response);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem deleting the image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
// keep the imageUrl in localStorage so the user can try again if they want
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem("imageUrl");
|
||||||
|
this.imageUrl = "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting image:", error);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((error as any).response.status === 404) {
|
||||||
|
console.log("The image was already deleted:", error);
|
||||||
|
|
||||||
|
localStorage.removeItem("imageUrl");
|
||||||
|
this.imageUrl = "";
|
||||||
|
|
||||||
|
// it already doesn't exist so we won't say anything to the user
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error deleting the image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm() {
|
||||||
|
if (!this.activeDid) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "You must select an identifier before you can record a give.",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parseFloat(this.amountInput) < 0) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
text: "You may not send a negative number.",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.description && !parseFloat(this.amountInput)) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: `You must enter a description or some number of ${
|
||||||
|
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||||
|
}.`,
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
text: "Recording the give...",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// this is asynchronous, but we don't need to wait for it to complete
|
||||||
|
await this.recordGive();
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyUser(message: string) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Error",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyUserOfProject() {
|
||||||
|
if (!this.projectId) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Error",
|
||||||
|
text: "To assign to a project, you must open this dialog through a project.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// must be because givenToUser is true
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Error",
|
||||||
|
text: "You cannot assign both to a project and to yourself.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param giverDid may be null
|
||||||
|
* @param description may be an empty string
|
||||||
|
* @param amountInput may be 0
|
||||||
|
* @param unitCode may be omitted, defaults to "HUR"
|
||||||
|
*/
|
||||||
|
public async recordGive() {
|
||||||
|
try {
|
||||||
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
|
const recipientDid =
|
||||||
|
this.recipientDid === this.activeDid
|
||||||
|
? this.givenToUser
|
||||||
|
? this.activeDid
|
||||||
|
: undefined
|
||||||
|
: this.recipientDid;
|
||||||
|
const projectId = this.givenToProject ? this.projectId : undefined;
|
||||||
|
const result = await createAndSubmitGive(
|
||||||
|
this.axios,
|
||||||
|
this.apiServer,
|
||||||
|
identity,
|
||||||
|
this.giverDid,
|
||||||
|
recipientDid,
|
||||||
|
this.description,
|
||||||
|
parseFloat(this.amountInput),
|
||||||
|
this.unitCode,
|
||||||
|
projectId,
|
||||||
|
this.offerId,
|
||||||
|
this.isTrade,
|
||||||
|
this.imageUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.type === "error" ||
|
||||||
|
this.isGiveCreationError(result.response)
|
||||||
|
) {
|
||||||
|
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||||
|
console.error("Error with give creation result:", result);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: errorMessage || "There was an error creating the give.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
localStorage.removeItem("imageUrl");
|
||||||
|
if (this.destinationNameAfter) {
|
||||||
|
this.$router.push({ name: this.destinationNameAfter });
|
||||||
|
} else {
|
||||||
|
this.$router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error with give recordation caught:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error.userMessage ||
|
||||||
|
error.response?.data?.error?.message ||
|
||||||
|
"There was an error recording the give.";
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: errorMessage,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for readability
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param result response "data" from the server
|
||||||
|
* @returns true if the result indicates an error
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
isGiveCreationError(result: any) {
|
||||||
|
return result.status !== 201 || result.data?.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||||
|
* @returns best guess at an error message
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
getGiveCreationErrorMessage(result: any) {
|
||||||
|
return (
|
||||||
|
result.error?.userMessage ||
|
||||||
|
result.error?.error ||
|
||||||
|
result.response?.data?.error?.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
explainData() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Data Sharing",
|
||||||
|
text: libsUtil.PRIVACY_MESSAGE,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
If this works then you're all set.
|
If this works then you're all set.
|
||||||
<button
|
<button
|
||||||
@click="sendTestWebPushMessage(true)"
|
@click="sendTestWebPushMessage(true)"
|
||||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
Send Yourself a Test Web Push Message (Through Push Server but
|
Send Yourself a Test Web Push Message (Through Push Server but
|
||||||
Skipping Client Filter)
|
Skipping Client Filter)
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
<h2 class="text-xl font-semibold mt-4">Tests</h2>
|
<h2 class="text-xl font-semibold mt-4">Tests</h2>
|
||||||
<button
|
<button
|
||||||
@click="showTestNotification()"
|
@click="showTestNotification()"
|
||||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||||
>
|
>
|
||||||
Send Test Notification Directly to Device (Not Through Push Server)
|
Send Test Notification Directly to Device (Not Through Push Server)
|
||||||
</button>
|
</button>
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="alertWebPushSubscription()"
|
@click="alertWebPushSubscription()"
|
||||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||||
>
|
>
|
||||||
Show Web Push Subscription Info
|
Show Web Push Subscription Info
|
||||||
</button>
|
</button>
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="sendTestWebPushMessage(true)"
|
@click="sendTestWebPushMessage(true)"
|
||||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||||
>
|
>
|
||||||
Send Yourself a Test Web Push Message (Through Push Server but Skipping
|
Send Yourself a Test Web Push Message (Through Push Server but Skipping
|
||||||
Client Filter)
|
Client Filter)
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="sendTestWebPushMessage()"
|
@click="sendTestWebPushMessage()"
|
||||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||||
>
|
>
|
||||||
Send Yourself a Test Web Push Message (Through Push Server and Client
|
Send Yourself a Test Web Push Message (Through Push Server and Client
|
||||||
Filter)
|
Filter)
|
||||||
@@ -301,12 +301,13 @@ import { sendTestThroughPushServer } from "@/libs/util";
|
|||||||
export default class HelpNotificationsView extends Vue {
|
export default class HelpNotificationsView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
subscription: PushSubscription | null = null;
|
subscriptionJSON?: PushSubscriptionJSON;
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
this.subscription = await registration.pushManager.getSubscription();
|
const fullSub = await registration.pushManager.getSubscription();
|
||||||
|
this.subscriptionJSON = fullSub?.toJSON();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Mount error:", error);
|
console.error("Mount error:", error);
|
||||||
}
|
}
|
||||||
@@ -315,13 +316,13 @@ export default class HelpNotificationsView extends Vue {
|
|||||||
alertWebPushSubscription() {
|
alertWebPushSubscription() {
|
||||||
console.log(
|
console.log(
|
||||||
"Web push subscription:",
|
"Web push subscription:",
|
||||||
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging
|
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
|
||||||
);
|
);
|
||||||
alert(JSON.stringify(this.subscription));
|
alert(JSON.stringify(this.subscriptionJSON));
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTestWebPushMessage(skipFilter: boolean = false) {
|
async sendTestWebPushMessage(skipFilter: boolean = false) {
|
||||||
if (!this.subscription) {
|
if (!this.subscriptionJSON) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -336,7 +337,7 @@ export default class HelpNotificationsView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendTestThroughPushServer(this.subscription, skipFilter);
|
await sendTestThroughPushServer(this.subscriptionJSON, skipFilter);
|
||||||
|
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
|
|||||||
69
src/views/HelpOnboardingView.vue
Normal file
69
src/views/HelpOnboardingView.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Don't include nav buttons since this is shown in a different window. -->
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- Don't include 'back' button since this is shown in a different window. -->
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Time Safari Onboarding Instructions
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable prettier/prettier -->
|
||||||
|
<div class="ml-4">
|
||||||
|
<h1 class="font-bold text-xl">Install</h1>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
1) Have them visit TimeSafari.app in a browser, preferably Chrome or Safari.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
2) Have them "Install" the site to their desktop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-xl">Add Contact & Register</h1>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
3) Have them follow their yellow prompts.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
4) Add them to your contacts <fa icon="users" />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
5) Register them <fa icon="person-circle-question" />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
6) Add yourself to their contacts <fa icon="users" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-xl">Enable Notifications</h1>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
7) Enable notifications from <fa icon="circle-user" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-xl">Discuss Backups</h1>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
8) Exporting backups <fa icon="circle-user" /> are important if they lose their phone --- especially for the Identifier Seed!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- eslint enable -->
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
|
||||||
|
@Component({ components: { QuickNav } })
|
||||||
|
export default class Help extends Vue {}
|
||||||
|
</script>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
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="$router.back()"
|
@click="$router.back()"
|
||||||
>
|
>
|
||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
<fa icon="chevron-left" class="fa-fw" />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,22 +39,22 @@
|
|||||||
and network.
|
and network.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can show giving and also offer help to ideas, based on others'
|
You highlight giving and also offer help to ideas -- which could be
|
||||||
willingness to help out, too. You can record your own ideas and invite
|
conditional on others' willingness to help, too.
|
||||||
others to collaborate.
|
You can record your own ideas and invite others to collaborate.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This app uses the power of cryptography to build a reputation, recording
|
This app uses the power of cryptography to build a reputation, recording
|
||||||
activity that you can share at your discretion. You put some activity
|
activity that you can share at your discretion. You put some activity
|
||||||
public, but your sensitive information is not shared with anyone,
|
public, but these services don't share your ID with others without explicit consent.
|
||||||
including our services. This is in contrast to Meta and Google, who hold
|
This is in contrast to Meta and Google, who hold
|
||||||
your data and allow you use it. Those services are useful, but they have
|
your data and allow you use it while they manage sharing...
|
||||||
the control; this app gives you the control.
|
those services are useful but they have the control, whereas this app gives you the control.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I get started?</h2>
|
<h2 class="text-xl font-semibold">How do I get started?</h2>
|
||||||
<p>
|
<p>
|
||||||
You need someone to register you -- usually the person who told you
|
You need someone to register you, like the person who told you
|
||||||
about this app, on the Contacts
|
about this app, on the Contacts
|
||||||
<fa icon="users" class="fa-fw" /> page. After they register you, you can
|
<fa icon="users" class="fa-fw" /> page. After they register you, you can
|
||||||
select any contact on the home page (or "anonymous") and record your
|
select any contact on the home page (or "anonymous") and record your
|
||||||
@@ -83,25 +83,22 @@
|
|||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
||||||
<p>
|
<p>
|
||||||
<button class="text-blue-500" @click="showOnboardInfo">
|
<a href="/help-onboarding" target="_blank" class="text-blue-500">
|
||||||
Click here to show an alert with the steps.
|
Use these instructions.
|
||||||
</button>
|
</a>
|
||||||
To start scanning, go
|
To start scanning, go
|
||||||
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
|
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If they are not nearby to scan QR codes, tell them to copy their ID from
|
If they are not nearby to scan QR codes, you each can tap on the QR code
|
||||||
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
|
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
|
||||||
typically starts with "did:ethr:...", and send it to you. Go to the
|
|
||||||
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
|
|
||||||
top form. To add a name, put a comma and then their name; to add their
|
|
||||||
public key, put another comma followed by the key.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
|
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
|
||||||
<p>
|
<p>
|
||||||
There are two sets of data to backup: the identifier secrets and the
|
There are three sets of data to backup: the identifier secrets;
|
||||||
other data that isn't quite a secret such as settings, contacts, etc.
|
the non-public textual data that isn't quite a secret such as settings and contacts;
|
||||||
|
the non-public image for yourself; and the data that you have sent to the public.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
@@ -122,7 +119,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
How do I backup my other (non-identifier-secret) data?
|
How do I backup my non-secret, non-public text data?
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc list-outside ml-4">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>
|
<li>
|
||||||
@@ -134,6 +131,27 @@
|
|||||||
won't lose it.
|
won't lose it.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
How do I backup my non-secret, non-public image?
|
||||||
|
</h2>
|
||||||
|
<ul class="list-disc list-outside ml-4">
|
||||||
|
<li>
|
||||||
|
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
|
||||||
|
tap on your image, and save it.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
How do I backup my public data?
|
||||||
|
</h2>
|
||||||
|
<ul class="list-disc list-outside ml-4">
|
||||||
|
<li>
|
||||||
|
This requires use of the API, so investigate the endpoints
|
||||||
|
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
|
||||||
|
(particularly the "claim" endpoints).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
|
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
|
||||||
@@ -178,8 +196,7 @@
|
|||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
|
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
|
||||||
<p>
|
<p>
|
||||||
Before doing this, note the two kinds of data to backup: identity data,
|
Before doing this, you may want to back up your data with the instructions above.
|
||||||
and other data for contacts and settings (see instructions above).
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="list-disc list-outside ml-4">
|
<li class="list-disc list-outside ml-4">
|
||||||
@@ -198,13 +215,11 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li class="list-disc list-outside ml-4">
|
<li class="list-disc list-outside ml-4">
|
||||||
Chrome:
|
Chrome:
|
||||||
<a href="chrome://settings/content/all" class="text-blue-500"
|
Clear at "chrome://settings/content/all" and
|
||||||
>clear here</a
|
|
||||||
>
|
|
||||||
also clear under dev tools Application
|
also clear under dev tools Application
|
||||||
</li>
|
</li>
|
||||||
<li class="list-disc list-outside ml-4">
|
<li class="list-disc list-outside ml-4">
|
||||||
Firefox: <a href="about:preferences">go here</a>, Manage Data,
|
Firefox: Navigate to "about:preferences", Manage Data,
|
||||||
find timesafari.app and select, hit Remove Selected, then Save
|
find timesafari.app and select, hit Remove Selected, then Save
|
||||||
Changes
|
Changes
|
||||||
</li>
|
</li>
|
||||||
@@ -217,6 +232,28 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>To erase your data from our servers, contact us (below).</p>
|
<p>To erase your data from our servers, contact us (below).</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
How do I get higher limits?
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Let's talk. Contact us (below).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
How do I access even more functionality?
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
There is an "Advanced" section at the bottom of the Account
|
||||||
|
<fa icon="circle-user" /> page.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
There is a even more functionality in a mobile app (and more
|
||||||
|
documentation) at
|
||||||
|
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
|
||||||
|
EndorserSearch.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
I know there is a record from someone, so why can't I see that info?
|
I know there is a record from someone, so why can't I see that info?
|
||||||
</h2>
|
</h2>
|
||||||
@@ -240,35 +277,70 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
<router-link class="text-blue-500" to="/help-notifications"
|
<router-link class="text-blue-500" to="/help-notifications"
|
||||||
>Here.</router-link
|
>Here.</router-link
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
How do I get higher limits?
|
My app is misbehaving, like showing me a blank screen or failing to show a feed.
|
||||||
|
What can I do?
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Let's talk. Contact us (below).
|
First, note that clearing the cache will clear all your identity and contact info,
|
||||||
|
so we recommend doing other things first (unless you know you have your backups ready).
|
||||||
</p>
|
</p>
|
||||||
|
<ul class="list-disc list-outside ml-4">
|
||||||
<h2 class="text-xl font-semibold">
|
<li>
|
||||||
How do I access even more functionality?
|
Drag down on the screen to refresh it; do that multiple times, because
|
||||||
</h2>
|
it sometimes takes multiple tries for the app to refresh to the current version.
|
||||||
|
You can see the version information at the bottom of this page; the best
|
||||||
|
way to determine the current version is to open this page in an incognito
|
||||||
|
browser window and look at the version there.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Close all tabs that have Time Safari open; it can be difficult to find them all,
|
||||||
|
and you may have to close all your tabs. In addition, it may be running as an
|
||||||
|
installed app, so look for any Time Safari app that may be running outside a browser.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
There may be a problem with your identity. Go to the Identity
|
||||||
|
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
|
||||||
|
and you may see helpful info there. If it shows a problem, try adding your identifier again.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
It can help to reregister the service worker:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
In Chrome, open a tab to
|
||||||
|
"chrome://serviceworker-internals",
|
||||||
|
find "timesafari.app", and click "Unregister".</li>
|
||||||
|
<li>
|
||||||
|
In Firefox,
|
||||||
|
open a tab to "about:serviceworkers",
|
||||||
|
find "timesafari.app", and click "Unregister".
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
|
||||||
|
for instructions for other browsers.</li>
|
||||||
|
</ul>
|
||||||
|
Then reload Time Safari.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Restart your device.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
There is an "Advanced" section at the bottom of the Account
|
If you still have problems, you can clear the cache (see "erase my data" above)
|
||||||
<fa icon="circle-user" /> page.
|
and even uninstall and reinstall the app
|
||||||
</p>
|
-- just be sure to have your backups ready or be
|
||||||
<p>
|
prepared to restart with a new identity and recreate your network.
|
||||||
There is a even more functionality in a mobile app (and more
|
Nobody else has access to your identity or contact information because
|
||||||
documentation) at
|
this app is designed to give you full control over your data.
|
||||||
<a href="https://endorser.ch" class="text-blue-500">
|
|
||||||
EndorserSearch.com
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||||
<p style="display:inline; align-items: center">
|
<p style="display:inline; align-items: center">
|
||||||
This work is marked with
|
This work is public domain, governed by
|
||||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
||||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||||
<img
|
<img
|
||||||
@@ -289,7 +361,7 @@
|
|||||||
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
|
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
|
||||||
<br />
|
<br />
|
||||||
For all other claim data,
|
For all other claim data,
|
||||||
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
|
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
|
||||||
the Endorser Service has this Privacy Policy.
|
the Endorser Service has this Privacy Policy.
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -297,7 +369,7 @@
|
|||||||
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
||||||
<p>
|
<p>
|
||||||
This is part of the
|
This is part of the
|
||||||
<a href="https://livesofgiving.org" class="text-blue-500">
|
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
|
||||||
Lives of Giving
|
Lives of Giving
|
||||||
</a>
|
</a>
|
||||||
initiative.
|
initiative.
|
||||||
@@ -307,7 +379,7 @@
|
|||||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
For any other questions, including removing your data:
|
For any other questions, including removing all your data from the public ledger:
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Contact us at
|
Contact us at
|
||||||
@@ -326,25 +398,12 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import * as Package from "../../package.json";
|
import * as Package from "../../package.json";
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { ONBOARD_MESSAGE } from "@/libs/util";
|
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class Help extends Vue {
|
export default class Help extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
package = Package;
|
package = Package;
|
||||||
commitHash = process.env.VUE_APP_GIT_HASH;
|
commitHash = import.meta.env.VITE_GIT_HASH;
|
||||||
|
|
||||||
showOnboardInfo() {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "info",
|
|
||||||
title: "Onboard Someone",
|
|
||||||
text: ONBOARD_MESSAGE,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<div v-if="showShortcutBvc" class="mb-4">
|
<div v-if="showShortcutBvc" class="mb-4">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'quick-action-bvc' }"
|
:to="{ name: 'quick-action-bvc' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Bountiful Voluntaryist Community Actions</router-link
|
Bountiful Voluntaryist Community Actions</router-link
|
||||||
>
|
>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div v-if="isCreatingIdentifier">
|
<div v-if="isCreatingIdentifier">
|
||||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
<fa icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'start' }"
|
:to="{ name: 'start' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Create An Identifier</router-link
|
Create An Identifier</router-link
|
||||||
>
|
>
|
||||||
@@ -95,21 +95,13 @@
|
|||||||
v-else-if="!isRegistered"
|
v-else-if="!isRegistered"
|
||||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||||
>
|
>
|
||||||
Someone must register your identifier before you can record anyone's
|
Someone must register you before you can give or offer.
|
||||||
giving.
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'contact-qr' }"
|
:to="{ name: 'contact-qr' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||||
>
|
|
||||||
Show Them Your Identifier Info</router-link
|
|
||||||
>
|
|
||||||
<br />
|
|
||||||
To double-check that you're registered,
|
|
||||||
<br />
|
|
||||||
<router-link :to="{ name: 'account' }" class="text-blue-500">
|
|
||||||
see your Usage Limits on the Account
|
|
||||||
<fa icon="circle-user" /> page.</router-link
|
|
||||||
>
|
>
|
||||||
|
Show Them Your Identifier Info
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -118,7 +110,9 @@
|
|||||||
<h2 class="text-xl font-bold">Record Something Given By:</h2>
|
<h2 class="text-xl font-bold">Record Something Given By:</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
<ul
|
||||||
|
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
|
||||||
|
>
|
||||||
<li @click="openDialog()">
|
<li @click="openDialog()">
|
||||||
<img
|
<img
|
||||||
src="../assets/blank-square.svg"
|
src="../assets/blank-square.svg"
|
||||||
@@ -127,7 +121,7 @@
|
|||||||
<h3
|
<h3
|
||||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
>
|
>
|
||||||
Anonymous/Unnamed
|
Unnamed/Unknown
|
||||||
</h3>
|
</h3>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -136,9 +130,9 @@
|
|||||||
@click="openDialog(contact)"
|
@click="openDialog(contact)"
|
||||||
>
|
>
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entityId="contact.did"
|
:contact="contact"
|
||||||
:iconSize="64"
|
:iconSize="64"
|
||||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
@@ -151,14 +145,14 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="allContacts.length >= 7"
|
v-if="allContacts.length >= 7"
|
||||||
:to="{ name: 'contact-gives' }"
|
:to="{ name: 'contact-gift' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
class="block text-center text-md font-bold 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-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Choose From All Contacts
|
Choose From All Contacts
|
||||||
</router-link>
|
</router-link>
|
||||||
<button
|
<button
|
||||||
@click="openGiftedPrompts()"
|
@click="openGiftedPrompts()"
|
||||||
class="block text-center text-md font-bold bg-slate-500 text-white px-2 py-3 rounded-md"
|
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Ideas...
|
Ideas...
|
||||||
</button>
|
</button>
|
||||||
@@ -166,16 +160,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GiftedDialog
|
<GiftedDialog ref="customDialog" />
|
||||||
ref="customDialog"
|
|
||||||
message="Received from"
|
|
||||||
showGivenToUser="true"
|
|
||||||
/>
|
|
||||||
<GiftedPrompts ref="giftedPrompts" />
|
<GiftedPrompts ref="giftedPrompts" />
|
||||||
|
<FeedFilters ref="feedFilters" />
|
||||||
|
|
||||||
<!-- Results List -->
|
<!-- Results List -->
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||||
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
<div class="flex items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Latest Activity</h2>
|
||||||
|
<button @click="openFeedFilters()" class="block text-center ml-auto">
|
||||||
|
<span class="text-sm text-white">
|
||||||
|
<span
|
||||||
|
v-if="resultsAreFiltered()"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Filtered
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Unfiltered
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||||
<ul class="border-t border-slate-300">
|
<ul class="border-t border-slate-300">
|
||||||
<li
|
<li
|
||||||
@@ -184,53 +193,95 @@
|
|||||||
:key="record.jwtId"
|
:key="record.jwtId"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
|
||||||
v-if="record.jwtId == feedLastViewedClaimId"
|
v-if="record.jwtId == feedLastViewedClaimId"
|
||||||
>
|
>
|
||||||
You've already seen all the following
|
You've already seen all the following
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-12">
|
<div class="grid grid-cols-12">
|
||||||
<span class="col-span-11 justify-self-start">
|
<span class="col-span-1 justify-self-start">
|
||||||
<span>
|
<span>
|
||||||
<fa
|
<fa
|
||||||
v-if="record.giver.known || record.receiver.known"
|
v-if="record.giver.known || record.receiver.known"
|
||||||
icon="circle-user"
|
icon="circle-user"
|
||||||
class="col-span-1 pt-1 pl-0 pr-3 text-slate-500"
|
class="pt-1 text-slate-500"
|
||||||
|
/>
|
||||||
|
<fa v-else icon="gift" class="pt-1 pl-3 text-slate-500" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="col-span-10 justify-self-stretch">
|
||||||
|
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
record.giver.profileImageUrl ||
|
||||||
|
record.receiver.profileImageUrl
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
v-if="record.agentDid !== activeDid"
|
||||||
|
:icon-size="32"
|
||||||
|
:profile-image-url="record.giver.profileImageUrl"
|
||||||
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
/>
|
/>
|
||||||
<fa
|
<fa
|
||||||
v-else
|
v-if="
|
||||||
icon="gift"
|
record.agentDid !== activeDid &&
|
||||||
class="col-span-1 pt-1 pl-3 pr-0 text-slate-500"
|
record.recipientDid !== activeDid &&
|
||||||
|
!record.fulfillsPlanHandleId
|
||||||
|
"
|
||||||
|
icon="ellipsis"
|
||||||
|
class="text-slate"
|
||||||
|
/>
|
||||||
|
<EntityIcon
|
||||||
|
v-if="
|
||||||
|
record.recipientDid !== activeDid &&
|
||||||
|
!record.fulfillsPlanHandleId
|
||||||
|
"
|
||||||
|
:iconSize="32"
|
||||||
|
:profile-image-url="record.receiver.profileImageUrl"
|
||||||
|
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{{ giveDescription(record) }}
|
-->
|
||||||
|
<span class="pl-2">
|
||||||
|
{{ giveDescription(record) }}
|
||||||
|
</span>
|
||||||
<a @click="onClickLoadClaim(record.jwtId)">
|
<a @click="onClickLoadClaim(record.jwtId)">
|
||||||
<fa
|
<fa
|
||||||
icon="circle-info"
|
icon="file-lines"
|
||||||
class="pl-2 text-blue-500 cursor-pointer"
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
></fa>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="col-span-1 justify-self-end shrink">
|
<span class="col-span-1 justify-self-end">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="record.fulfillsPlanHandleId"
|
v-if="record.fulfillsPlanHandleId"
|
||||||
:to="
|
:to="
|
||||||
'/project/' +
|
'/project/' +
|
||||||
encodeURIComponent(record.fulfillsPlanHandleId)
|
encodeURIComponent(record.fulfillsPlanHandleId)
|
||||||
"
|
"
|
||||||
class="justify-end"
|
|
||||||
>
|
>
|
||||||
<fa icon="hammer" class="ml-4 pl-2 text-blue-500"></fa>
|
<fa icon="hammer" class="text-blue-500" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="record.image" class="flex justify-center">
|
||||||
|
<a :href="record.image" target="_blank">
|
||||||
|
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
<div v-if="isFeedLoading">
|
<div v-if="isFeedLoading">
|
||||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
<fa icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isFeedLoading && feedData.length === 0">
|
||||||
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||||
|
No claims match your filters.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,10 +292,12 @@
|
|||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import EntityIcon from "@/components/EntityIcon.vue";
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
import GiftedPrompts from "@/components/GiftedPrompts.vue";
|
import GiftedPrompts from "@/components/GiftedPrompts.vue";
|
||||||
|
import FeedFilters from "@/components/FeedFilters.vue";
|
||||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import TopMessage from "@/components/TopMessage.vue";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
@@ -252,24 +305,36 @@ import { NotificationIface } from "@/constants/app";
|
|||||||
import { db, accountsDB } from "@/db/index";
|
import { db, accountsDB } from "@/db/index";
|
||||||
import { Account } from "@/db/tables/accounts";
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
import {
|
||||||
|
BoundingBox,
|
||||||
|
isAnyFeedFilterOn,
|
||||||
|
MASTER_SETTINGS_KEY,
|
||||||
|
Settings,
|
||||||
|
} from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import {
|
import {
|
||||||
contactForDid,
|
contactForDid,
|
||||||
|
containsNonHiddenDid,
|
||||||
didInfoForContact,
|
didInfoForContact,
|
||||||
GiverInputInfo,
|
fetchEndorserRateLimits,
|
||||||
GiveServerRecord,
|
getPlanFromCache,
|
||||||
|
GiverReceiverInputInfo,
|
||||||
|
GiveSummaryRecord,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
||||||
|
|
||||||
interface GiveRecordWithContactInfo extends GiveServerRecord {
|
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
||||||
giver: {
|
giver: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
known: boolean;
|
known: boolean;
|
||||||
|
profileImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
image?: string;
|
||||||
|
recipientProjectName?: string;
|
||||||
receiver: {
|
receiver: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
known: boolean;
|
known: boolean;
|
||||||
|
profileImageUrl?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +342,7 @@ interface GiveRecordWithContactInfo extends GiveServerRecord {
|
|||||||
components: {
|
components: {
|
||||||
GiftedDialog,
|
GiftedDialog,
|
||||||
GiftedPrompts,
|
GiftedPrompts,
|
||||||
|
FeedFilters,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
EntityIcon,
|
EntityIcon,
|
||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
@@ -293,13 +359,20 @@ export default class HomeView extends Vue {
|
|||||||
feedData: GiveRecordWithContactInfo[] = [];
|
feedData: GiveRecordWithContactInfo[] = [];
|
||||||
feedPreviousOldestId?: string;
|
feedPreviousOldestId?: string;
|
||||||
feedLastViewedClaimId?: string;
|
feedLastViewedClaimId?: string;
|
||||||
|
isAnyFeedFilterOn: boolean;
|
||||||
isCreatingIdentifier = false;
|
isCreatingIdentifier = false;
|
||||||
|
isFeedFilteredByVisible = false;
|
||||||
|
isFeedFilteredByNearby = false;
|
||||||
isFeedLoading = true;
|
isFeedLoading = true;
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
|
searchBoxes: Array<{
|
||||||
|
name: string;
|
||||||
|
bbox: BoundingBox;
|
||||||
|
}> = [];
|
||||||
showShortcutBvc = false;
|
showShortcutBvc = false;
|
||||||
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
||||||
|
|
||||||
public async getIdentity(activeDid: string) {
|
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = (await accountsDB.accounts
|
const account = (await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
@@ -318,7 +391,7 @@ export default class HomeView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async created() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const allAccounts = await accountsDB.accounts.toArray();
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
@@ -330,9 +403,14 @@ export default class HomeView extends Vue {
|
|||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = settings?.activeDid || "";
|
||||||
this.allContacts = await db.contacts.toArray();
|
this.allContacts = await db.contacts.toArray();
|
||||||
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
||||||
|
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
|
||||||
|
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
this.searchBoxes = settings?.searchBoxes || [];
|
||||||
this.showShortcutBvc = !!settings?.showShortcutBvc;
|
this.showShortcutBvc = !!settings?.showShortcutBvc;
|
||||||
|
|
||||||
|
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
|
||||||
|
|
||||||
if (this.allMyDids.length === 0) {
|
if (this.allMyDids.length === 0) {
|
||||||
this.isCreatingIdentifier = true;
|
this.isCreatingIdentifier = true;
|
||||||
this.activeDid = await generateSaveAndActivateIdentity();
|
this.activeDid = await generateSaveAndActivateIdentity();
|
||||||
@@ -340,8 +418,29 @@ export default class HomeView extends Vue {
|
|||||||
this.isCreatingIdentifier = false;
|
this.isCreatingIdentifier = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this returns a Promise but we don't need to wait for it
|
// someone may have have registered after sharing contact info
|
||||||
|
if (!this.isRegistered && this.activeDid) {
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
try {
|
||||||
|
const resp = await fetchEndorserRateLimits(
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
identity as IIdentifier,
|
||||||
|
);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
// we just needed to know that they're registered
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
isRegistered: true,
|
||||||
|
});
|
||||||
|
this.isRegistered = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore the error... just keep us unregistered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this returns a Promise but we don't need to wait for it
|
||||||
await this.updateAllFeed();
|
await this.updateAllFeed();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -361,6 +460,10 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsAreFiltered() {
|
||||||
|
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
||||||
|
}
|
||||||
|
|
||||||
notificationsSupported() {
|
notificationsSupported() {
|
||||||
return "Notification" in window;
|
return "Notification" in window;
|
||||||
}
|
}
|
||||||
@@ -370,73 +473,129 @@ export default class HomeView extends Vue {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
if (this.activeDid) {
|
if (this.activeDid) {
|
||||||
await accountsDB.open();
|
if (identity) {
|
||||||
const allAccounts = await accountsDB.accounts.toArray();
|
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||||
const account = allAccounts.find(
|
} else {
|
||||||
(acc) => acc.did === this.activeDid,
|
|
||||||
) as Account;
|
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
|
||||||
|
|
||||||
if (!identity) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
|
||||||
} else {
|
} else {
|
||||||
// it's OK without auth... we just won't get any identifiers
|
// it's OK without auth... we just won't get any identifiers
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only called when a setting was changed
|
||||||
|
async reloadFeedOnChange() {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
|
||||||
|
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
|
||||||
|
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
|
||||||
|
|
||||||
|
this.feedData = [];
|
||||||
|
this.feedPreviousOldestId = undefined;
|
||||||
|
this.updateAllFeed();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data loader used by infinite scroller
|
* Data loader used by infinite scroller
|
||||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||||
**/
|
**/
|
||||||
public async loadMoreGives(payload: boolean) {
|
public async loadMoreGives(payload: boolean) {
|
||||||
if (payload) {
|
// Since feed now loads projects along the way, it takes longer
|
||||||
|
// and the InfiniteScroll component triggers a load before finished.
|
||||||
|
// One alternative is to totally separate the project link loading.
|
||||||
|
if (payload && !this.isFeedLoading) {
|
||||||
this.updateAllFeed();
|
this.updateAllFeed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latLongInAnySearchBox(lat: number, long: number) {
|
||||||
|
for (const boxInfo of this.searchBoxes) {
|
||||||
|
if (
|
||||||
|
boxInfo.bbox.westLong <= long &&
|
||||||
|
long <= boxInfo.bbox.eastLong &&
|
||||||
|
boxInfo.bbox.minLat <= lat &&
|
||||||
|
lat <= boxInfo.bbox.maxLat
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async updateAllFeed() {
|
public async updateAllFeed() {
|
||||||
this.isFeedLoading = true;
|
this.isFeedLoading = true;
|
||||||
|
let endOfResults = true;
|
||||||
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
||||||
.then(async (results) => {
|
.then(async (results) => {
|
||||||
if (results.data.length > 0) {
|
if (results.data.length > 0) {
|
||||||
|
endOfResults = false;
|
||||||
// include the descriptions of the giver and receiver
|
// include the descriptions of the giver and receiver
|
||||||
const newFeedData: GiveRecordWithContactInfo = results.data.map(
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
(record: GiveServerRecord) => {
|
for (const record: GiveSummaryRecord of results.data) {
|
||||||
// similar code is in endorser-mobile utility.ts
|
// similar code is in endorser-mobile utility.ts
|
||||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
||||||
// agent.did is for legacy data, before March 2023
|
// agent.did is for legacy data, before March 2023
|
||||||
const giverDid =
|
const giverDid =
|
||||||
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
// recipient.did is for legacy data, before March 2023
|
// recipient.did is for legacy data, before March 2023
|
||||||
const recipientDid =
|
const recipientDid =
|
||||||
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
return {
|
|
||||||
...record,
|
// This has indeed proven problematic. See loadMoreGives
|
||||||
giver: didInfoForContact(
|
// We should display it immediately and then get the plan later.
|
||||||
giverDid,
|
const plan = await getPlanFromCache(
|
||||||
this.activeDid,
|
record.fulfillsPlanHandleId,
|
||||||
contactForDid(giverDid, this.allContacts),
|
identity,
|
||||||
this.allMyDids,
|
this.axios,
|
||||||
),
|
this.apiServer,
|
||||||
receiver: didInfoForContact(
|
);
|
||||||
recipientDid,
|
|
||||||
this.activeDid,
|
// check if the record should be filtered out
|
||||||
contactForDid(recipientDid, this.allContacts),
|
let anyMatch = false;
|
||||||
this.allMyDids,
|
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||||
),
|
// has a visible DID so it's a keeper
|
||||||
};
|
anyMatch = true;
|
||||||
},
|
}
|
||||||
);
|
if (!anyMatch && this.isFeedFilteredByNearby) {
|
||||||
this.feedData = this.feedData.concat(newFeedData);
|
// check if the associated project has a location inside user's search box
|
||||||
|
if (record.fulfillsPlanHandleId) {
|
||||||
|
if (plan?.locLat && plan?.locLon) {
|
||||||
|
if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
|
||||||
|
anyMatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.isAnyFeedFilterOn && !anyMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecord: GiveRecordWithContactInfo = {
|
||||||
|
...record,
|
||||||
|
giver: didInfoForContact(
|
||||||
|
giverDid,
|
||||||
|
this.activeDid,
|
||||||
|
contactForDid(giverDid, this.allContacts),
|
||||||
|
this.allMyDids,
|
||||||
|
),
|
||||||
|
image: claim.image,
|
||||||
|
recipientProjectName: plan?.name as string,
|
||||||
|
receiver: didInfoForContact(
|
||||||
|
recipientDid,
|
||||||
|
this.activeDid,
|
||||||
|
contactForDid(recipientDid, this.allContacts),
|
||||||
|
this.allMyDids,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
this.feedData.push(newRecord);
|
||||||
|
}
|
||||||
this.feedPreviousOldestId =
|
this.feedPreviousOldestId =
|
||||||
results.data[results.data.length - 1].jwtId;
|
results.data[results.data.length - 1].jwtId;
|
||||||
// The following update is only done on the first load.
|
// The following update is only done on the first load.
|
||||||
@@ -463,6 +622,10 @@ export default class HomeView extends Vue {
|
|||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if (this.feedData.length === 0 && !endOfResults) {
|
||||||
|
// repeat until there's at least some data
|
||||||
|
this.updateAllFeed();
|
||||||
|
}
|
||||||
this.isFeedLoading = false;
|
this.isFeedLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +639,7 @@ export default class HomeView extends Vue {
|
|||||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
endorserApiServer +
|
endorserApiServer +
|
||||||
"/api/v2/report/gives?giftNotTrade=true&" +
|
"/api/v2/report/gives?giftNotTrade=true" +
|
||||||
beforeQuery,
|
beforeQuery,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -529,12 +692,28 @@ export default class HomeView extends Vue {
|
|||||||
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
|
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
|
||||||
} else if (giverInfo.known) {
|
} else if (giverInfo.known) {
|
||||||
// giver is named but recipient is not
|
// giver is named but recipient is not
|
||||||
|
|
||||||
|
// show the project name if to one
|
||||||
|
if (giveRecord.recipientProjectName) {
|
||||||
|
// retrieve the project name
|
||||||
|
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// it's not to a project
|
||||||
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
|
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
|
||||||
} else if (recipientInfo.known) {
|
} else if (recipientInfo.known) {
|
||||||
// recipient is named but giver is not
|
// recipient is named but giver is not
|
||||||
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
|
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
|
||||||
} else {
|
} else {
|
||||||
// neither giver nor recipient are named
|
// neither giver nor recipient are named
|
||||||
|
|
||||||
|
// show the project name if to one
|
||||||
|
if (giveRecord.recipientProjectName) {
|
||||||
|
// retrieve the project name
|
||||||
|
return `${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// it's not to a project
|
||||||
let peopleInfo;
|
let peopleInfo;
|
||||||
if (giverInfo.displayName === recipientInfo.displayName) {
|
if (giverInfo.displayName === recipientInfo.displayName) {
|
||||||
peopleInfo = `between two who are ${giverInfo.displayName}`;
|
peopleInfo = `between two who are ${giverInfo.displayName}`;
|
||||||
@@ -549,7 +728,7 @@ export default class HomeView extends Vue {
|
|||||||
const route = {
|
const route = {
|
||||||
path: "/claim/" + encodeURIComponent(jwtId),
|
path: "/claim/" + encodeURIComponent(jwtId),
|
||||||
};
|
};
|
||||||
this.$router.push(route);
|
(this.$router as Router).push(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
displayAmount(code: string, amt: number) {
|
displayAmount(code: string, amt: number) {
|
||||||
@@ -560,12 +739,24 @@ export default class HomeView extends Vue {
|
|||||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(giver?: GiverInputInfo) {
|
openDialog(giver?: GiverReceiverInputInfo) {
|
||||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
(this.$refs.customDialog as GiftedDialog).open(
|
||||||
|
giver,
|
||||||
|
{
|
||||||
|
did: this.activeDid,
|
||||||
|
name: "you",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
"Given by " + (giver?.name || "someone not named"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openGiftedPrompts() {
|
openGiftedPrompts() {
|
||||||
(this.$refs.giftedPrompts as GiftedPrompts).open();
|
(this.$refs.giftedPrompts as GiftedPrompts).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openFeedFilters() {
|
||||||
|
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -65,13 +65,13 @@
|
|||||||
<router-link
|
<router-link
|
||||||
id="start-link"
|
id="start-link"
|
||||||
:to="{ name: 'start' }"
|
:to="{ name: 'start' }"
|
||||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
class="block 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 mb-2"
|
||||||
>
|
>
|
||||||
Add Another Identity…
|
Add Another Identity…
|
||||||
</router-link>
|
</router-link>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
|
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 mb-8"
|
||||||
@click="switchAccount('0')"
|
@click="switchAccount('0')"
|
||||||
>
|
>
|
||||||
No Identity
|
No Identity
|
||||||
@@ -112,10 +112,15 @@ export default class IdentitySwitcherView extends Vue {
|
|||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
for (let n = 0; n < accounts.length; n++) {
|
for (let n = 0; n < accounts.length; n++) {
|
||||||
const did = JSON.parse(accounts[n].identity)["did"];
|
try {
|
||||||
this.otherIdentities.push({ did: did });
|
const did = accounts[n]["did"];
|
||||||
if (did && this.activeDid === did) {
|
this.otherIdentities.push({ did: did });
|
||||||
this.activeDidInIdentities = true;
|
if (did && this.activeDid === did) {
|
||||||
|
this.activeDidInIdentities = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error parsing identity:", err);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Enter your seed phrase below to import your identifier on this device.
|
Enter your seed phrase below to import your identifier on this device.
|
||||||
</p>
|
</p>
|
||||||
<!-- id used by puppeteer test script -->
|
<!-- id used by puppeteer test script -->
|
||||||
<input
|
<textarea
|
||||||
id="seed-input"
|
id="seed-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Seed Phrase"
|
placeholder="Seed Phrase"
|
||||||
@@ -56,19 +56,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
@click="fromMnemonic()"
|
<button
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
@click="fromMnemonic()"
|
||||||
>
|
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"
|
||||||
Import
|
>
|
||||||
</button>
|
Import
|
||||||
<button
|
</button>
|
||||||
@click="onCancelClick()"
|
<button
|
||||||
type="button"
|
@click="onCancelClick()"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
type="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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,19 +49,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
@click="incrementDerivation()"
|
<button
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
@click="incrementDerivation()"
|
||||||
>
|
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"
|
||||||
Increment and Import
|
>
|
||||||
</button>
|
Increment and Import
|
||||||
<button
|
</button>
|
||||||
@click="onCancelClick()"
|
<button
|
||||||
type="button"
|
@click="onCancelClick()"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
type="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"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,21 +22,23 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
type="button"
|
<button
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
type="button"
|
||||||
@click="onClickSaveChanges()"
|
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 mb-2"
|
||||||
>
|
@click="onClickSaveChanges()"
|
||||||
Save Changes
|
>
|
||||||
</button>
|
Save Changes
|
||||||
<!-- SHOW ME instead while processing saving changes -->
|
</button>
|
||||||
<button
|
<!-- SHOW ME instead while processing saving changes -->
|
||||||
type="button"
|
<button
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
type="button"
|
||||||
@click="onClickCancel()"
|
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-2 py-3 rounded-md mb-2"
|
||||||
>
|
@click="onClickCancel()"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -29,10 +29,31 @@
|
|||||||
v-model="fullClaim.name"
|
v-model="fullClaim.name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<span v-if="imageUrl" class="flex justify-between">
|
||||||
|
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
|
||||||
|
<img :src="imageUrl" class="h-24 rounded-xl" />
|
||||||
|
</a>
|
||||||
|
<fa
|
||||||
|
icon="trash-can"
|
||||||
|
@click="confirmDeleteImage"
|
||||||
|
class="text-red-500 fa-fw ml-8 mt-10"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<fa
|
||||||
|
icon="camera"
|
||||||
|
class="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-2 rounded-md"
|
||||||
|
@click="openImageDialog"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ImageMethodDialog ref="imageDialog" />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Other Authorized Representative"
|
placeholder="Other Authorized Representative"
|
||||||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
||||||
v-model="agentDid"
|
v-model="agentDid"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -64,6 +85,23 @@
|
|||||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex mb-4 columns-3 w-full">
|
||||||
|
<input
|
||||||
|
v-model="startDateInput"
|
||||||
|
placeholder="Start Date"
|
||||||
|
type="date"
|
||||||
|
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:disabled="!startDateInput"
|
||||||
|
v-model="startTimeInput"
|
||||||
|
placeholder="Start Time"
|
||||||
|
type="time"
|
||||||
|
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -73,16 +111,17 @@
|
|||||||
/>
|
/>
|
||||||
<label for="includeLocation">Include Location</label>
|
<label for="includeLocation">Include Location</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
<div v-if="includeLocation" class="mb-4 aspect-video">
|
||||||
<div class="px-2 py-2">
|
<p class="text-sm mb-2 text-slate-500">
|
||||||
For your security, choose a location nearby but not exactly at the
|
For your security, choose a location nearby but not exactly at the
|
||||||
place.
|
place.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<l-map
|
<l-map
|
||||||
ref="map"
|
ref="map"
|
||||||
v-model:zoom="zoom"
|
v-model:zoom="zoom"
|
||||||
:center="[0, 0]"
|
:center="[0, 0]"
|
||||||
|
class="!z-40 rounded-md"
|
||||||
@click="
|
@click="
|
||||||
(event) => {
|
(event) => {
|
||||||
latitude = event.latlng.lat;
|
latitude = event.latlng.lat;
|
||||||
@@ -98,34 +137,36 @@
|
|||||||
<l-marker
|
<l-marker
|
||||||
v-if="latitude && longitude"
|
v-if="latitude && longitude"
|
||||||
:lat-lng="[latitude, longitude]"
|
:lat-lng="[latitude, longitude]"
|
||||||
@click="maybeEraseLatLong()"
|
@click="confirmEraseLatLong()"
|
||||||
/>
|
/>
|
||||||
</l-map>
|
</l-map>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
:disabled="isHiddenSave"
|
<button
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
:disabled="isHiddenSave"
|
||||||
@click="onSaveProjectClick()"
|
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 mb-2"
|
||||||
>
|
@click="onSaveProjectClick()"
|
||||||
<!-- SHOW if in idle state -->
|
|
||||||
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
|
||||||
|
|
||||||
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
|
||||||
<span :class="{ hidden: isHiddenSpinner }">
|
|
||||||
<!-- icon no worky? -->
|
|
||||||
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
|
||||||
Saving...</span
|
|
||||||
>
|
>
|
||||||
</button>
|
<!-- SHOW if in idle state -->
|
||||||
<button
|
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
||||||
type="button"
|
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
||||||
@click="onCancelClick()"
|
<span :class="{ hidden: isHiddenSpinner }">
|
||||||
>
|
<!-- icon no worky? -->
|
||||||
Cancel
|
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
||||||
</button>
|
Saving...</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="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="onCancelClick()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -134,23 +175,32 @@
|
|||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||||
|
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||||
import { accountsDB, db } from "@/db/index";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
import { useAppStore } from "@/store/app";
|
import { useAppStore } from "@/store/app";
|
||||||
import { IIdentifier } from "@veramo/core";
|
|
||||||
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
||||||
|
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { LMap, LMarker, LTileLayer, QuickNav },
|
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
errNote(message) {
|
||||||
|
this.$notify(
|
||||||
|
{ group: "alert", type: "danger", title: "Error", text: message },
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
@@ -162,6 +212,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
}; // this default is only to avoid errors before plan is loaded
|
}; // this default is only to avoid errors before plan is loaded
|
||||||
|
imageUrl = "";
|
||||||
includeLocation = false;
|
includeLocation = false;
|
||||||
isHiddenSave = false;
|
isHiddenSave = false;
|
||||||
isHiddenSpinner = true;
|
isHiddenSpinner = true;
|
||||||
@@ -171,12 +222,12 @@ export default class NewEditProjectView extends Vue {
|
|||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
projectId = localStorage.getItem("projectId") || "";
|
projectId = localStorage.getItem("projectId") || "";
|
||||||
projectIssuerDid = "";
|
projectIssuerDid = "";
|
||||||
|
startDateInput?: string;
|
||||||
|
startTimeInput?: string;
|
||||||
|
zoneName = DateTime.local().zoneName;
|
||||||
zoom = 2;
|
zoom = 2;
|
||||||
|
|
||||||
async beforeCreate() {
|
libsUtil = libsUtil;
|
||||||
await accountsDB.open();
|
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getIdentity(activeDid: string) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
@@ -203,7 +254,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async created() {
|
async mounted() {
|
||||||
|
await accountsDB.open();
|
||||||
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
|
|
||||||
await db.open();
|
await db.open();
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
this.activeDid = (settings?.activeDid as string) || "";
|
this.activeDid = (settings?.activeDid as string) || "";
|
||||||
@@ -211,7 +265,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
|
|
||||||
if (this.projectId) {
|
if (this.projectId) {
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
console.error("Error: no account was found.");
|
this.errNote("There was a problem loading your account info.");
|
||||||
} else {
|
} else {
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
@@ -240,6 +294,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
this.projectIssuerDid = resp.data.issuer;
|
this.projectIssuerDid = resp.data.issuer;
|
||||||
this.fullClaim = resp.data.claim;
|
this.fullClaim = resp.data.claim;
|
||||||
|
this.imageUrl = resp.data.claim.image || "";
|
||||||
this.lastClaimJwtId = resp.data.id;
|
this.lastClaimJwtId = resp.data.id;
|
||||||
if (this.fullClaim?.location) {
|
if (this.fullClaim?.location) {
|
||||||
this.includeLocation = true;
|
this.includeLocation = true;
|
||||||
@@ -249,9 +304,94 @@ export default class NewEditProjectView extends Vue {
|
|||||||
if (this.fullClaim?.agent?.identifier) {
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
this.agentDid = this.fullClaim.agent.identifier;
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
}
|
}
|
||||||
|
if (this.fullClaim.startTime) {
|
||||||
|
const localDateTime = DateTime.fromISO(
|
||||||
|
this.fullClaim.startTime as string,
|
||||||
|
).toLocal();
|
||||||
|
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
|
||||||
|
this.startTimeInput = localDateTime.toFormat("HH:mm");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Got error retrieving that project", error);
|
console.error("Got error retrieving that project", error);
|
||||||
|
this.errNote("There was an error retrieving that project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openImageDialog() {
|
||||||
|
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
|
||||||
|
this.imageUrl = imgUrl;
|
||||||
|
}, "PlanAction");
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDeleteImage() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Are you sure you want to delete the image?",
|
||||||
|
text: "",
|
||||||
|
onYes: this.deleteImage,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage() {
|
||||||
|
if (!this.imageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
const response = await this.axios.delete(
|
||||||
|
DEFAULT_IMAGE_API_SERVER +
|
||||||
|
"/image/" +
|
||||||
|
encodeURIComponent(this.imageUrl),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 204) {
|
||||||
|
// don't bother with a notification
|
||||||
|
// (either they'll simply continue or they're canceling and going back)
|
||||||
|
} else {
|
||||||
|
console.error("Problem deleting image:", response);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem deleting the image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imageUrl = "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting image:", error);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((error as any).response.status === 404) {
|
||||||
|
console.log("The image was already deleted:", error);
|
||||||
|
|
||||||
|
this.imageUrl = "";
|
||||||
|
|
||||||
|
// it already doesn't exist so we won't say anything to the user
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error deleting the image.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +405,13 @@ export default class NewEditProjectView extends Vue {
|
|||||||
vcClaim.agent = {
|
vcClaim.agent = {
|
||||||
identifier: this.agentDid,
|
identifier: this.agentDid,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
delete vcClaim.agent;
|
||||||
|
}
|
||||||
|
if (this.imageUrl) {
|
||||||
|
vcClaim.image = this.imageUrl;
|
||||||
|
} else {
|
||||||
|
delete vcClaim.image;
|
||||||
}
|
}
|
||||||
if (this.includeLocation) {
|
if (this.includeLocation) {
|
||||||
vcClaim.location = {
|
vcClaim.location = {
|
||||||
@@ -274,6 +421,30 @@ export default class NewEditProjectView extends Vue {
|
|||||||
longitude: this.longitude,
|
longitude: this.longitude,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
delete vcClaim.location;
|
||||||
|
}
|
||||||
|
if (this.startDateInput) {
|
||||||
|
try {
|
||||||
|
const startTimeFull = this.startTimeInput || "00:00:00";
|
||||||
|
const fullTimeString = this.startDateInput + " " + startTimeFull;
|
||||||
|
// throw an error on an invalid date or time string
|
||||||
|
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
|
||||||
|
} catch {
|
||||||
|
// it's not a valid date so erase it and tell the user
|
||||||
|
delete vcClaim.startTime;
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "The date was invalid so it was not set.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete vcClaim.startTime;
|
||||||
}
|
}
|
||||||
// Make a payload for the claim
|
// Make a payload for the claim
|
||||||
const vcPayload = {
|
const vcPayload = {
|
||||||
@@ -394,12 +565,25 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public maybeEraseLatLong() {
|
confirmEraseLatLong() {
|
||||||
if (window.confirm("Are you sure you don't want to mark a location?")) {
|
this.$notify(
|
||||||
this.latitude = 0;
|
{
|
||||||
this.longitude = 0;
|
group: "modal",
|
||||||
this.includeLocation = false;
|
type: "confirm",
|
||||||
}
|
title: "Erase Marker",
|
||||||
|
text: "Are you sure you don't want to mark a location? This will erase the current location.",
|
||||||
|
onYes: async () => {
|
||||||
|
this.eraseLatLong();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public eraseLatLong() {
|
||||||
|
this.latitude = 0;
|
||||||
|
this.longitude = 0;
|
||||||
|
this.includeLocation = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancelClick() {
|
public onCancelClick() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 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">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
<!-- Back -->
|
<!-- Back -->
|
||||||
<button
|
<button
|
||||||
@@ -15,23 +15,25 @@
|
|||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||||
</button>
|
</button>
|
||||||
Idea
|
Idea
|
||||||
|
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project Details -->
|
<!-- Project Details -->
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="block pb-4 flex gap-4">
|
<div class="pb-4 flex gap-4">
|
||||||
<div class="flex-none w-16 pt-1">
|
<div class="pt-1">
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entityId="projectId"
|
:entityId="projectId"
|
||||||
:iconSize="64"
|
:iconSize="64"
|
||||||
class="block border border-slate-300 rounded-md"
|
:imageUrl="imageUrl"
|
||||||
></ProjectIcon>
|
:linkToFull="true"
|
||||||
|
class="block border border-slate-300 rounded-md max-h-16 max-w-16"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
|
||||||
<div class="text-sm mb-3">
|
<div class="text-sm mb-3">
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||||
@@ -53,9 +55,9 @@
|
|||||||
<span v-show="showDidCopy">Copied DID</span>
|
<span v-show="showDidCopy">Copied DID</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="timeSince">
|
<div v-if="startTime">
|
||||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||||
{{ timeSince }}
|
{{ startTime }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="latitude || longitude">
|
<div v-if="latitude || longitude">
|
||||||
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||||
@@ -69,8 +71,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="url">
|
<div v-if="url">
|
||||||
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
||||||
<a :href="addScheme(url)" target="_blank" class="underline"
|
<a :href="addScheme(url)" target="_blank" class="underline">
|
||||||
>{{ domainForWebsite(this.url) }}
|
{{ domainForWebsite(this.url) }}
|
||||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,25 +101,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
|
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
|
||||||
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
|
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="activeDid === issuer || activeDid === agentDid"
|
v-if="activeDid === issuer || activeDid === agentDid"
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 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-md"
|
||||||
@click="onEditClick()"
|
@click="onEditClick()"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeDid" class="mb-4">
|
<div v-if="activeDid" class="mt-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button
|
<button
|
||||||
@click="openOfferDialog()"
|
@click="openOfferDialog()"
|
||||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
class="block w-full text-lg 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-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Offer (maybe with conditions)...
|
Offer (maybe with conditions)...
|
||||||
</button>
|
</button>
|
||||||
@@ -125,16 +128,12 @@
|
|||||||
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
|
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
|
||||||
|
|
||||||
<div v-if="activeDid">
|
<div v-if="activeDid">
|
||||||
<GiftedDialog
|
|
||||||
ref="customGiveDialog"
|
|
||||||
message="Received from"
|
|
||||||
:projectId="this.projectId"
|
|
||||||
>
|
|
||||||
</GiftedDialog>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="mt-2 mb-4 text-center">Record a contribution from:</p>
|
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
<ul
|
||||||
|
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
|
||||||
|
>
|
||||||
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
|
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
|
||||||
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
|
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
|
||||||
<h3
|
<h3
|
||||||
@@ -151,7 +150,7 @@
|
|||||||
<h3
|
<h3
|
||||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
>
|
>
|
||||||
Anonymous/Unnamed
|
Unnamed/Unknown
|
||||||
</h3>
|
</h3>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -160,9 +159,9 @@
|
|||||||
@click="openGiftDialog(contact)"
|
@click="openGiftDialog(contact)"
|
||||||
>
|
>
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entityId="contact.did"
|
:contact="contact"
|
||||||
:iconSize="64"
|
:iconSize="64"
|
||||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
@@ -176,18 +175,18 @@
|
|||||||
<a
|
<a
|
||||||
v-if="allContacts.length >= 7"
|
v-if="allContacts.length >= 7"
|
||||||
@click="onClickAllContactsGifting()"
|
@click="onClickAllContactsGifting()"
|
||||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
class="block text-center text-md font-bold 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-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Show More Contacts…
|
Show More Contacts…
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<GiftedDialog ref="customGiveDialog" :projectId="this.projectId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gifts to & from this -->
|
<!-- Offers & Gifts to & from this -->
|
||||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
|
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
<h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3>
|
||||||
Offered To This Idea
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div v-if="offersToThis.length === 0">
|
<div v-if="offersToThis.length === 0">
|
||||||
(None yet. Wanna
|
(None yet. Wanna
|
||||||
@@ -230,7 +229,7 @@
|
|||||||
@click="onClickLoadClaim(offer.jwtId as string)"
|
@click="onClickLoadClaim(offer.jwtId as string)"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
|
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-if="checkIsFulfillable(offer)"
|
v-if="checkIsFulfillable(offer)"
|
||||||
@@ -244,10 +243,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-if="offersHitLimit" class="text-center text-blue-500">
|
||||||
|
<button @click="loadOffers()">Load More</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||||
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
|
<h3 class="text-sm font-semibold mb-3">Given To This Idea</h3>
|
||||||
|
|
||||||
<div v-if="givesToThis.length === 0">
|
<div v-if="givesToThis.length === 0">
|
||||||
(None yet. If you've seen something, say something by clicking a
|
(None yet. If you've seen something, say something by clicking a
|
||||||
@@ -289,14 +291,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<a @click="onClickLoadClaim(give.jwtId)">
|
<a @click="onClickLoadClaim(give.jwtId)">
|
||||||
<fa icon="circle-info" class="text-blue-500 cursor-pointer" />
|
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
|
||||||
</a>
|
</a>
|
||||||
<a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
|
<a
|
||||||
|
v-if="checkIsConfirmable(give)"
|
||||||
|
@click="confirmConfirmClaim(give)"
|
||||||
|
>
|
||||||
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
|
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-if="givesHitLimit" class="text-center text-blue-500">
|
||||||
|
<button @click="loadGives()">Load More</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid items-start grid-cols-1 gap-4">
|
<div class="grid items-start grid-cols-1 gap-4">
|
||||||
@@ -317,6 +325,7 @@
|
|||||||
{{ plan.name }}
|
{{ plan.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="fulfillersToHitLimit" class="text-center">Load More</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,7 +350,6 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||||
import * as moment from "moment";
|
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
@@ -360,11 +368,11 @@ import { accessToken } from "@/libs/crypto";
|
|||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
import {
|
import {
|
||||||
BLANK_GENERIC_SERVER_RECORD,
|
BLANK_GENERIC_SERVER_RECORD,
|
||||||
GenericServerRecord,
|
GenericCredWrapper,
|
||||||
GiverInputInfo,
|
GiverReceiverInputInfo,
|
||||||
GiveServerRecord,
|
GiveSummaryRecord,
|
||||||
OfferServerRecord,
|
OfferSummaryRecord,
|
||||||
PlanServerRecord,
|
PlanSummaryRecord,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import * as serverUtil from "@/libs/endorserServer";
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
|
|
||||||
@@ -388,17 +396,21 @@ export default class ProjectViewView extends Vue {
|
|||||||
apiServer = "";
|
apiServer = "";
|
||||||
description = "";
|
description = "";
|
||||||
expanded = false;
|
expanded = false;
|
||||||
fulfilledByThis: PlanServerRecord | null = null;
|
fulfilledByThis: PlanSummaryRecord | null = null;
|
||||||
fulfillersToThis: Array<PlanServerRecord> = [];
|
fulfillersToThis: Array<PlanSummaryRecord> = [];
|
||||||
givesToThis: Array<GiveServerRecord> = [];
|
fulfillersToHitLimit = false;
|
||||||
|
givesToThis: Array<GiveSummaryRecord> = [];
|
||||||
|
givesHitLimit = false;
|
||||||
|
imageUrl = "";
|
||||||
issuer = "";
|
issuer = "";
|
||||||
latitude = 0;
|
latitude = 0;
|
||||||
longitude = 0;
|
longitude = 0;
|
||||||
name = "";
|
name = "";
|
||||||
offersToThis: Array<OfferServerRecord> = [];
|
offersToThis: Array<OfferSummaryRecord> = [];
|
||||||
|
offersHitLimit = false;
|
||||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||||
showDidCopy = false;
|
showDidCopy = false;
|
||||||
timeSince = "";
|
startTime = "";
|
||||||
truncatedDesc = "";
|
truncatedDesc = "";
|
||||||
truncateLength = 40;
|
truncateLength = 40;
|
||||||
url = "";
|
url = "";
|
||||||
@@ -418,7 +430,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
const accountsArr: Account[] = await accounts?.toArray();
|
const accountsArr: Account[] = await accounts?.toArray();
|
||||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse((account?.identity as string) || "null");
|
||||||
|
|
||||||
const pathParam = window.location.pathname.substring("/project/".length);
|
const pathParam = window.location.pathname.substring("/project/".length);
|
||||||
if (pathParam) {
|
if (pathParam) {
|
||||||
@@ -437,15 +449,6 @@ export default class ProjectViewView extends Vue {
|
|||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity: IIdentifier) {
|
|
||||||
const token = await accessToken(identity);
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: "Bearer " + token,
|
|
||||||
};
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
onEditClick() {
|
onEditClick() {
|
||||||
localStorage.setItem("projectId", this.projectId as string);
|
localStorage.setItem("projectId", this.projectId as string);
|
||||||
const route = {
|
const route = {
|
||||||
@@ -479,13 +482,16 @@ export default class ProjectViewView extends Vue {
|
|||||||
try {
|
try {
|
||||||
const resp = await this.axios.get(url, { headers });
|
const resp = await this.axios.get(url, { headers });
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
const startTime = resp.data.startTime;
|
const startTime = resp.data.claim?.startTime;
|
||||||
if (startTime != null) {
|
if (startTime != null) {
|
||||||
const eventDate = new Date(startTime);
|
const startDateTime = new Date(startTime);
|
||||||
const now = moment.now();
|
this.startTime =
|
||||||
this.timeSince = moment.utc(now).to(eventDate);
|
startDateTime.toLocaleDateString() +
|
||||||
|
" " +
|
||||||
|
startDateTime.toLocaleTimeString();
|
||||||
}
|
}
|
||||||
this.agentDid = resp.data.claim?.agent?.identifier;
|
this.agentDid = resp.data.claim?.agent?.identifier;
|
||||||
|
this.imageUrl = resp.data.claim?.image;
|
||||||
this.issuer = resp.data.issuer;
|
this.issuer = resp.data.issuer;
|
||||||
this.name = resp.data.claim?.name || "(no name)";
|
this.name = resp.data.claim?.name || "(no name)";
|
||||||
this.description = resp.data.claim?.description || "(no description)";
|
this.description = resp.data.claim?.description || "(no description)";
|
||||||
@@ -503,7 +509,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "There was a problem getting that project. See logs for more info.",
|
text: "There was a problem getting that project. See logs for more info.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -517,7 +523,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "That project does not exist.",
|
text: "That project does not exist.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
@@ -527,83 +533,22 @@ export default class ProjectViewView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Something went wrong retrieving that project. See logs for more info.",
|
text: "Something went wrong retrieving that project. See logs for more info.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const givesInUrl =
|
this.loadGives();
|
||||||
this.apiServer +
|
|
||||||
"/api/v2/report/givesToPlans?planIds=" +
|
|
||||||
encodeURIComponent(JSON.stringify([projectId]));
|
|
||||||
try {
|
|
||||||
const resp = await this.axios.get(givesInUrl, { headers });
|
|
||||||
if (resp.status === 200 && resp.data.data) {
|
|
||||||
this.givesToThis = resp.data.data;
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Failed to retrieve gives to this project.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const serverError = error as AxiosError;
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Something went wrong retrieving gives to this project.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
"Error retrieving gives to this project:",
|
|
||||||
serverError.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offersToUrl =
|
this.loadOffers();
|
||||||
this.apiServer +
|
|
||||||
"/api/v2/report/offersToPlans?planIds=" +
|
|
||||||
encodeURIComponent(JSON.stringify([projectId]));
|
|
||||||
try {
|
|
||||||
const resp = await this.axios.get(offersToUrl, { headers });
|
|
||||||
if (resp.status === 200 && resp.data.data) {
|
|
||||||
this.offersToThis = resp.data.data;
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Failed to retrieve offers to this project.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const serverError = error as AxiosError;
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Something went wrong retrieving offers to this project.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
"Error retrieving offers to this project:",
|
|
||||||
serverError.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.loadFulfillersTo();
|
||||||
|
|
||||||
|
// now load fulfilled-by, a single project
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
const fulfilledByUrl =
|
const fulfilledByUrl =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
||||||
@@ -620,7 +565,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Failed to retrieve plans fulfilled by this project.",
|
text: "Failed to retrieve plans fulfilled by this project.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -632,31 +577,50 @@ export default class ProjectViewView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Something went wrong retrieving plans fulfilled by this project.",
|
text: "Something went wrong retrieving plans fulfilled by this project.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
"Error retrieving plans fulfilled by this project:",
|
"Error retrieving plans fulfilled by this project:",
|
||||||
serverError.message,
|
serverError.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fulfillersToUrl =
|
async loadGives() {
|
||||||
|
const givesUrl =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
"/api/v2/report/givesToPlans?planIds=" +
|
||||||
encodeURIComponent(projectId);
|
encodeURIComponent(JSON.stringify([this.projectId]));
|
||||||
|
let postfix = "";
|
||||||
|
if (this.givesToThis.length > 0) {
|
||||||
|
postfix =
|
||||||
|
"&beforeId=" + this.givesToThis[this.givesToThis.length - 1].jwtId;
|
||||||
|
}
|
||||||
|
const givesInUrl = givesUrl + postfix;
|
||||||
|
|
||||||
|
const headers: RawAxiosRequestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await this.axios.get(fulfillersToUrl, { headers });
|
const resp = await this.axios.get(givesInUrl, { headers });
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200 && resp.data.data) {
|
||||||
this.fulfillersToThis = resp.data.data;
|
this.givesToThis = this.givesToThis.concat(resp.data.data);
|
||||||
|
this.givesHitLimit = resp.data.hitLimit;
|
||||||
} else {
|
} else {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Failed to retrieve plan fulfillers to this project.",
|
text: "Failed to retrieve more gives to this project.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -666,12 +630,123 @@ export default class ProjectViewView extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Something went wrong retrieving plan fulfillers to this project.",
|
text: "Something went wrong retrieving more gives to this project.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
"Error retrieving plan fulfillers to this project:",
|
"Something went wrong retrieving more gives to this project:",
|
||||||
|
serverError.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadOffers() {
|
||||||
|
const offersUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/offersToPlans?planIds=" +
|
||||||
|
encodeURIComponent(JSON.stringify([this.projectId]));
|
||||||
|
let postfix = "";
|
||||||
|
if (this.offersToThis.length > 0) {
|
||||||
|
postfix =
|
||||||
|
"&beforeId=" + this.offersToThis[this.offersToThis.length - 1].jwtId;
|
||||||
|
}
|
||||||
|
const offersInUrl = offersUrl + postfix;
|
||||||
|
|
||||||
|
const headers: RawAxiosRequestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.axios.get(offersInUrl, { headers });
|
||||||
|
if (resp.status === 200 && resp.data.data) {
|
||||||
|
this.offersToThis = this.offersToThis.concat(resp.data.data);
|
||||||
|
this.offersHitLimit = resp.data.hitLimit;
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Failed to retrieve more offers to this project.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const serverError = error as AxiosError;
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong retrieving more offers to this project.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Something went wrong retrieving more offers to this project:",
|
||||||
|
serverError.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFulfillersTo() {
|
||||||
|
const fulfillsUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
||||||
|
encodeURIComponent(this.projectId);
|
||||||
|
let postfix = "";
|
||||||
|
if (this.fulfillersToThis.length > 0) {
|
||||||
|
postfix =
|
||||||
|
"&beforeId=" +
|
||||||
|
this.fulfillersToThis[this.fulfillersToThis.length - 1].jwtId;
|
||||||
|
}
|
||||||
|
const fulfillsInUrl = fulfillsUrl + postfix;
|
||||||
|
|
||||||
|
const headers: RawAxiosRequestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
if (identity) {
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.axios.get(fulfillsInUrl, { headers });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data);
|
||||||
|
this.fulfillersToHitLimit = resp.data.hitLimit;
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Failed to retrieve more plans that fullfill this project.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const serverError = error as AxiosError;
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong retrieving more plans that fulfull this project.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Something went wrong retrieving more plans that fulfill this project:",
|
||||||
serverError.message,
|
serverError.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -704,8 +779,13 @@ export default class ProjectViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openGiftDialog(contact?: GiverInputInfo) {
|
openGiftDialog(contact?: GiverReceiverInputInfo) {
|
||||||
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
|
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||||
|
contact,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"Given by " + (contact?.name || "someone not named"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openOfferDialog() {
|
openOfferDialog() {
|
||||||
@@ -715,7 +795,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
onClickAllContactsGifting() {
|
onClickAllContactsGifting() {
|
||||||
localStorage.setItem("projectId", this.projectId);
|
localStorage.setItem("projectId", this.projectId);
|
||||||
const route = {
|
const route = {
|
||||||
name: "contact-gives",
|
name: "contact-gift",
|
||||||
};
|
};
|
||||||
this.$router.push(route);
|
this.$router.push(route);
|
||||||
}
|
}
|
||||||
@@ -727,8 +807,8 @@ export default class ProjectViewView extends Vue {
|
|||||||
this.$router.push(route);
|
this.$router.push(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkIsFulfillable(offer: OfferServerRecord) {
|
checkIsFulfillable(offer: OfferSummaryRecord) {
|
||||||
const offerRecord: GenericServerRecord = {
|
const offerRecord: GenericCredWrapper = {
|
||||||
...BLANK_GENERIC_SERVER_RECORD,
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
claim: offer.fullClaim,
|
claim: offer.fullClaim,
|
||||||
claimType: "Offer",
|
claimType: "Offer",
|
||||||
@@ -737,16 +817,21 @@ export default class ProjectViewView extends Vue {
|
|||||||
return libsUtil.canFulfillOffer(offerRecord);
|
return libsUtil.canFulfillOffer(offerRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickFulfillGiveToOffer(offer: OfferServerRecord) {
|
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
|
||||||
const offerRecord: GenericServerRecord = {
|
const offerRecord: GenericCredWrapper = {
|
||||||
...BLANK_GENERIC_SERVER_RECORD,
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
claim: offer.fullClaim,
|
claim: offer.fullClaim,
|
||||||
issuer: offer.offeredByDid,
|
issuer: offer.offeredByDid,
|
||||||
};
|
};
|
||||||
const giver: GiverInputInfo = {
|
const giver: GiverReceiverInputInfo = {
|
||||||
did: libsUtil.offerGiverDid(offerRecord),
|
did: libsUtil.offerGiverDid(offerRecord),
|
||||||
};
|
};
|
||||||
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
|
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||||
|
giver,
|
||||||
|
undefined,
|
||||||
|
offer.handleId,
|
||||||
|
"Given by " + (giver?.name || "someone not named"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return an HTTPS URL if it's not a global URL
|
// return an HTTPS URL if it's not a global URL
|
||||||
@@ -777,8 +862,8 @@ export default class ProjectViewView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkIsConfirmable(give: GiveServerRecord) {
|
checkIsConfirmable(give: GiveSummaryRecord) {
|
||||||
const giveDetails: GenericServerRecord = {
|
const giveDetails: GenericCredWrapper = {
|
||||||
...BLANK_GENERIC_SERVER_RECORD,
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
claim: give.fullClaim,
|
claim: give.fullClaim,
|
||||||
claimType: "GiveAction",
|
claimType: "GiveAction",
|
||||||
@@ -787,55 +872,68 @@ export default class ProjectViewView extends Vue {
|
|||||||
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
|
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmConfirmClaim(give: GiveSummaryRecord) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Confirm",
|
||||||
|
text: "Do you personally confirm that this is true?",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.confirmClaim(give);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// similar code is found in ClaimView
|
// similar code is found in ClaimView
|
||||||
async confirmClaim(give: GiveServerRecord) {
|
async confirmClaim(give: GiveSummaryRecord) {
|
||||||
if (confirm("Do you personally confirm that this is true?")) {
|
// similar logic is found in endorser-mobile
|
||||||
// similar logic is found in endorser-mobile
|
const goodClaim = serverUtil.removeSchemaContext(
|
||||||
const goodClaim = serverUtil.removeSchemaContext(
|
serverUtil.removeVisibleToDids(
|
||||||
serverUtil.removeVisibleToDids(
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
give.fullClaim,
|
||||||
give.fullClaim,
|
give.jwtId,
|
||||||
give.jwtId,
|
give.handleId,
|
||||||
give.handleId,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "AgreeAction",
|
||||||
|
object: goodClaim,
|
||||||
|
};
|
||||||
|
const result = await serverUtil.createAndSubmitClaim(
|
||||||
|
confirmationClaim,
|
||||||
|
await this.getIdentity(this.activeDid),
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
);
|
||||||
|
if (result.type === "success") {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "Confirmation submitted.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
);
|
);
|
||||||
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
} else {
|
||||||
"@context": "https://schema.org",
|
console.error("Got error submitting the confirmation:", result);
|
||||||
"@type": "AgreeAction",
|
const message =
|
||||||
object: goodClaim,
|
(result.error?.error as string) ||
|
||||||
};
|
"There was a problem submitting the confirmation. See logs for more info.";
|
||||||
const result = await serverUtil.createAndSubmitClaim(
|
this.$notify(
|
||||||
confirmationClaim,
|
{
|
||||||
await this.getIdentity(this.activeDid),
|
group: "alert",
|
||||||
this.apiServer,
|
type: "danger",
|
||||||
this.axios,
|
title: "Error",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
);
|
);
|
||||||
if (result.type === "success") {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "Confirmation submitted.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("Got error submitting the confirmation:", result);
|
|
||||||
const message =
|
|
||||||
(result.error?.error as string) ||
|
|
||||||
"There was a problem submitting the confirmation. See logs for more info.";
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
|
|
||||||
<!-- New Project -->
|
<!-- New Project -->
|
||||||
<button
|
<button
|
||||||
v-if="showProjects"
|
v-if="isRegistered && showProjects"
|
||||||
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
|
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
|
||||||
@click="onClickNewProject()"
|
@click="onClickNewProject()"
|
||||||
>
|
>
|
||||||
<fa icon="plus" class="fa-fw"></fa>
|
<fa icon="plus" class="fa-fw"></fa>
|
||||||
@@ -79,6 +79,13 @@
|
|||||||
|
|
||||||
<!-- Offer Results List -->
|
<!-- Offer Results List -->
|
||||||
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
|
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
|
||||||
|
<div v-if="offers.length === 0" class="text-center py-4">
|
||||||
|
You have not offered anything.
|
||||||
|
<br />
|
||||||
|
<router-link to="/discover" class="text-blue-600">
|
||||||
|
Look for projects worth some of your time.
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
<ul class="border-t border-slate-300">
|
<ul class="border-t border-slate-300">
|
||||||
<li
|
<li
|
||||||
class="border-b border-slate-300"
|
class="border-b border-slate-300"
|
||||||
@@ -86,19 +93,19 @@
|
|||||||
:key="offer.handleId"
|
:key="offer.handleId"
|
||||||
>
|
>
|
||||||
<div class="block py-4 flex gap-4">
|
<div class="block py-4 flex gap-4">
|
||||||
<div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12">
|
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entityId="offer.fulfillsPlanHandleId"
|
:entityId="offer.fulfillsPlanHandleId"
|
||||||
:iconSize="48"
|
:iconSize="48"
|
||||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||||
></ProjectIcon>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="offer.recipientDid" class="flex-none w-12">
|
<div v-if="offer.recipientDid" class="flex-none w-12">
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entityId="offer.recipientDid"
|
:entityId="offer.recipientDid"
|
||||||
:iconSize="48"
|
:iconSize="48"
|
||||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||||
></EntityIcon>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -167,7 +174,7 @@
|
|||||||
|
|
||||||
<a @click="onClickLoadClaim(offer.jwtId)">
|
<a @click="onClickLoadClaim(offer.jwtId)">
|
||||||
<fa
|
<fa
|
||||||
icon="circle-info"
|
icon="file-lines"
|
||||||
class="pl-2 text-blue-500 cursor-pointer"
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
></fa>
|
></fa>
|
||||||
</a>
|
</a>
|
||||||
@@ -177,8 +184,16 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
|
||||||
<!-- Project Results List -->
|
<!-- Project Results List -->
|
||||||
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
|
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
|
||||||
|
<div v-if="projects.length === 0" class="text-center py-4">
|
||||||
|
You have not announced any projects.
|
||||||
|
<br />
|
||||||
|
Hit the big
|
||||||
|
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" />
|
||||||
|
button. You'll never know until you try.
|
||||||
|
</div>
|
||||||
<ul class="border-t border-slate-300">
|
<ul class="border-t border-slate-300">
|
||||||
<li
|
<li
|
||||||
class="border-b border-slate-300"
|
class="border-b border-slate-300"
|
||||||
@@ -189,12 +204,13 @@
|
|||||||
@click="onClickLoadProject(project.handleId)"
|
@click="onClickLoadProject(project.handleId)"
|
||||||
class="block py-4 flex gap-4"
|
class="block py-4 flex gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex-none w-12">
|
<div class="flex-none">
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entityId="project.handleId"
|
:entityId="project.handleId"
|
||||||
:iconSize="48"
|
:iconSize="48"
|
||||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
:imageUrl="project.image"
|
||||||
></ProjectIcon>
|
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow overflow-hidden">
|
<div class="grow overflow-hidden">
|
||||||
@@ -211,6 +227,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { AxiosRequestConfig } from "axios";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
@@ -223,7 +240,7 @@ import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
|||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import ProjectIcon from "@/components/ProjectIcon.vue";
|
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||||
import TopMessage from "@/components/TopMessage.vue";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
import { OfferServerRecord, PlanData } from "@/libs/endorserServer";
|
import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer";
|
||||||
import EntityIcon from "@/components/EntityIcon.vue";
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -231,56 +248,45 @@ import EntityIcon from "@/components/EntityIcon.vue";
|
|||||||
})
|
})
|
||||||
export default class ProjectsView extends Vue {
|
export default class ProjectsView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
errNote(message) {
|
||||||
|
this.$notify(
|
||||||
|
{ group: "alert", type: "danger", title: "Error", text: message },
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
projects: PlanData[] = [];
|
projects: PlanData[] = [];
|
||||||
currentIid: IIdentifier;
|
currentIid: IIdentifier;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
isRegistered = false;
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
offers: OfferServerRecord[] = [];
|
offers: OfferSummaryRecord[] = [];
|
||||||
showOffers = true;
|
showOffers = true;
|
||||||
showProjects = false;
|
showProjects = false;
|
||||||
|
|
||||||
libsUtil = libsUtil;
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
/**
|
async mounted() {
|
||||||
* 'created' hook runs when the Vue instance is first created
|
|
||||||
**/
|
|
||||||
async created() {
|
|
||||||
try {
|
try {
|
||||||
await db.open();
|
await db.open();
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
const activeDid: string = (settings?.activeDid as string) || "";
|
const activeDid: string = (settings?.activeDid as string) || "";
|
||||||
this.apiServer = (settings?.apiServer as string) || "";
|
this.apiServer = (settings?.apiServer as string) || "";
|
||||||
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
console.error("No accounts found.");
|
console.error("No accounts found.");
|
||||||
this.$notify(
|
this.errNote("You need an identifier to load your projects.");
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You need an identifier to load your projects.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
this.currentIid = await this.getIdentity(activeDid);
|
this.currentIid = await this.getIdentity(activeDid);
|
||||||
await this.loadOffers();
|
await this.loadOffers();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error initializing:", err);
|
console.error("Error initializing:", err);
|
||||||
this.$notify(
|
this.errNote("Something went wrong loading your projects.");
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Something went wrong loading your projects.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,12 +303,19 @@ export default class ProjectsView extends Vue {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const resp = await this.axios.get(url, { headers });
|
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
|
||||||
if (resp.status === 200 || !resp.data.data) {
|
if (resp.status === 200 && resp.data.data) {
|
||||||
const plans: PlanData[] = resp.data.data;
|
const plans: PlanData[] = resp.data.data;
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
const { name, description, handleId, image, issuerDid, rowid } = plan;
|
||||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
this.projects.push({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
handleId,
|
||||||
|
issuerDid,
|
||||||
|
rowid,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -310,28 +323,12 @@ export default class ProjectsView extends Vue {
|
|||||||
resp.status,
|
resp.status,
|
||||||
resp.data,
|
resp.data,
|
||||||
);
|
);
|
||||||
this.$notify(
|
this.errNote("Failed to get projects from the server.");
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Failed to get projects from the server. Try again later.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Got error loading plans:", error.message || error);
|
console.error("Got error loading plans:", error.message || error);
|
||||||
this.$notify(
|
this.errNote("Got an error loading projects.");
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Got an error loading projects.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -422,8 +419,8 @@ export default class ProjectsView extends Vue {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const resp = await this.axios.get(url, { headers });
|
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
|
||||||
if (resp.status === 200 || !resp.data.data) {
|
if (resp.status === 200 && resp.data.data) {
|
||||||
this.offers = this.offers.concat(resp.data.data);
|
this.offers = this.offers.concat(resp.data.data);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -49,14 +49,14 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="record()"
|
@click="record()"
|
||||||
class="block text-center text-md font-bold bg-blue-500 text-white px-2 py-3 rounded-md w-56"
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
|
||||||
>
|
>
|
||||||
Sign & Send
|
Sign & Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center mt-4">
|
<div v-else class="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
class="block text-center text-md font-bold bg-slate-500 text-white px-2 py-3 rounded-md w-56"
|
class="block text-center text-md font-bold 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-2 py-3 rounded-md w-56"
|
||||||
>
|
>
|
||||||
Select Your Actions
|
Select Your Actions
|
||||||
</button>
|
</button>
|
||||||
@@ -97,6 +97,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
todayOrPreviousStartDate = "";
|
todayOrPreviousStartDate = "";
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
// use the time zone for Bountiful
|
||||||
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
|
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
|
||||||
if (currentOrPreviousSat.weekday < 6) {
|
if (currentOrPreviousSat.weekday < 6) {
|
||||||
// it's not Saturday or Sunday,
|
// it's not Saturday or Sunday,
|
||||||
@@ -125,6 +126,8 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
const hoursNum = libsUtil.numberOrZero(this.hoursStr);
|
const hoursNum = libsUtil.numberOrZero(this.hoursStr);
|
||||||
const identity = await libsUtil.getIdentity(activeDid);
|
const identity = await libsUtil.getIdentity(activeDid);
|
||||||
|
|
||||||
|
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||||
|
|
||||||
// first send the claim for time given
|
// first send the claim for time given
|
||||||
let timeSuccess = false;
|
let timeSuccess = false;
|
||||||
if (this.gaveTime && hoursNum > 0) {
|
if (this.gaveTime && hoursNum > 0) {
|
||||||
@@ -152,7 +155,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
timeResult?.error?.userMessage ||
|
timeResult?.error?.userMessage ||
|
||||||
"There was an error sending the time.",
|
"There was an error sending the time.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +182,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
attendResult?.error?.userMessage ||
|
attendResult?.error?.userMessage ||
|
||||||
"There was an error sending the attendance.",
|
"There was an error sending the attendance.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +201,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
title: "Success",
|
title: "Success",
|
||||||
text: actions,
|
text: actions,
|
||||||
},
|
},
|
||||||
-1,
|
3000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,9 +213,9 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: error.userMessage || "There was an error sending claims.",
|
text: error.userMessage || "There was an error sending the claims.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
}}
|
}}
|
||||||
<a @click="onClickLoadClaim(record.id)">
|
<a @click="onClickLoadClaim(record.id)">
|
||||||
<fa
|
<fa
|
||||||
icon="circle-info"
|
icon="file-lines"
|
||||||
class="pl-2 text-blue-500 cursor-pointer"
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
page.
|
page.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
claimCountByUser === 1
|
||||||
|
? "There is 1 other claim by you"
|
||||||
|
: `There are ${claimCountByUser} other claims by you`
|
||||||
|
}}
|
||||||
|
which you don't need to confirm.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl m-2">Anything else?</h2>
|
<h2 class="text-2xl m-2">Anything else?</h2>
|
||||||
@@ -109,14 +119,14 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="record()"
|
@click="record()"
|
||||||
class="block text-center text-md font-bold bg-blue-500 text-white px-2 py-3 rounded-md w-56"
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
|
||||||
>
|
>
|
||||||
Sign & Send
|
Sign & Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center mt-4">
|
<div v-else class="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
class="block text-center text-md font-bold bg-slate-500 text-white px-2 py-3 rounded-md w-56"
|
class="block text-center text-md font-bold 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-2 py-3 rounded-md w-56"
|
||||||
>
|
>
|
||||||
Choose What To Confirm
|
Choose What To Confirm
|
||||||
</button>
|
</button>
|
||||||
@@ -146,7 +156,7 @@ import {
|
|||||||
createAndSubmitConfirmation,
|
createAndSubmitConfirmation,
|
||||||
createAndSubmitGive,
|
createAndSubmitGive,
|
||||||
ErrorResult,
|
ErrorResult,
|
||||||
GenericServerRecord,
|
GenericCredWrapper,
|
||||||
GenericVerifiableCredential,
|
GenericVerifiableCredential,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
@@ -165,8 +175,9 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
|
claimCountByUser = 0;
|
||||||
claimCountWithHidden = 0;
|
claimCountWithHidden = 0;
|
||||||
claimsToConfirm: GenericServerRecord[] = [];
|
claimsToConfirm: GenericCredWrapper[] = [];
|
||||||
claimsToConfirmSelected: string[] = [];
|
claimsToConfirmSelected: string[] = [];
|
||||||
description = "breakfast";
|
description = "breakfast";
|
||||||
loadingConfirms = true;
|
loadingConfirms = true;
|
||||||
@@ -223,12 +234,12 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log("Bad response", response);
|
console.error("Bad response", response);
|
||||||
throw new Error("Bad response when retrieving claims.");
|
throw new Error("Bad response when retrieving claims.");
|
||||||
}
|
}
|
||||||
await response.json().then((data) => {
|
await response.json().then((data) => {
|
||||||
const dataByOthers = R.reject(
|
const dataByOthers = R.reject(
|
||||||
(claim: GenericServerRecord) => claim.issuer === this.activeDid,
|
(claim: GenericCredWrapper) => claim.issuer === this.activeDid,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
const dataByOthersWithoutHidden = R.reject(
|
const dataByOthersWithoutHidden = R.reject(
|
||||||
@@ -236,6 +247,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
dataByOthers,
|
dataByOthers,
|
||||||
);
|
);
|
||||||
this.claimsToConfirm = dataByOthersWithoutHidden;
|
this.claimsToConfirm = dataByOthersWithoutHidden;
|
||||||
|
this.claimCountByUser = data.length - dataByOthers.length;
|
||||||
this.claimCountWithHidden =
|
this.claimCountWithHidden =
|
||||||
dataByOthers.length - dataByOthersWithoutHidden.length;
|
dataByOthers.length - dataByOthersWithoutHidden.length;
|
||||||
});
|
});
|
||||||
@@ -248,7 +260,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: "There was an error retrieving today's claims to confirm.",
|
text: "There was an error retrieving today's claims to confirm.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.loadingConfirms = false;
|
this.loadingConfirms = false;
|
||||||
@@ -265,6 +277,8 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
try {
|
try {
|
||||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||||
|
|
||||||
|
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||||
|
|
||||||
// in parallel, make a confirmation for each selected claim and send them all to the server
|
// in parallel, make a confirmation for each selected claim and send them all to the server
|
||||||
const confirmResults = await Promise.allSettled(
|
const confirmResults = await Promise.allSettled(
|
||||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||||
@@ -300,7 +314,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: `There was an error sending ${howMany} of the confirmations.`,
|
text: `There was an error sending ${howMany} of the confirmations.`,
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +344,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
(giveResult as ErrorResult)?.error?.userMessage ||
|
(giveResult as ErrorResult)?.error?.userMessage ||
|
||||||
"There was an error sending that give.",
|
"There was an error sending that give.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +369,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
title: "Success",
|
title: "Success",
|
||||||
text: actions,
|
text: actions,
|
||||||
},
|
},
|
||||||
-1,
|
3000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +383,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text: error.userMessage || "There was an error sending claims.",
|
text: error.userMessage || "There was an error sending claims.",
|
||||||
},
|
},
|
||||||
-1,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'quick-action-bvc-begin' }"
|
:to="{ name: 'quick-action-bvc-begin' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
class="block text-center text-md 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 mt-2 px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Beginning of Meeting
|
Beginning of Meeting
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'quick-action-bvc-end' }"
|
:to="{ name: 'quick-action-bvc-end' }"
|
||||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
class="block text-center text-md 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 mt-2 px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
End of Meeting
|
End of Meeting
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-2 py-4">
|
<div class="px-2 py-4">
|
||||||
This location is only stored on your device. It is used to show you more
|
This location is only stored on your device. It is sometimes sent from
|
||||||
appropriate projects but is not stored on any servers.
|
your device to run searches but it is not stored on our servers.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -64,10 +64,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height: 600px; width: 800px">
|
<div class="mb-4 aspect-video">
|
||||||
<l-map
|
<l-map
|
||||||
ref="map"
|
ref="map"
|
||||||
:center="[localCenterLat, localCenterLong]"
|
:center="[localCenterLat, localCenterLong]"
|
||||||
|
class="!z-40 rounded-md"
|
||||||
v-model:zoom="localZoom"
|
v-model:zoom="localZoom"
|
||||||
@click="setMapPoint"
|
@click="setMapPoint"
|
||||||
>
|
>
|
||||||
@@ -208,9 +209,9 @@ export default class DiscoverView extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Saved",
|
title: "Saved",
|
||||||
text: "That has been saved in your preferences.",
|
text: "That has been saved in your preferences. You can now filter by it on your home screen feed.",
|
||||||
},
|
},
|
||||||
-1,
|
7000,
|
||||||
);
|
);
|
||||||
this.$router.back();
|
this.$router.back();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -246,6 +247,7 @@ export default class DiscoverView extends Vue {
|
|||||||
await db.open();
|
await db.open();
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
searchBoxes: [],
|
searchBoxes: [],
|
||||||
|
filterFeedByNearby: false,
|
||||||
});
|
});
|
||||||
this.searchBox = null;
|
this.searchBox = null;
|
||||||
this.localCenterLat = 0;
|
this.localCenterLat = 0;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<span>
|
<span>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'help' }"
|
:to="{ name: 'help' }"
|
||||||
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
|
class="text-xs 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-1.5 py-1 rounded-md ml-1"
|
||||||
>
|
>
|
||||||
Help
|
Help
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 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-md"
|
||||||
@click="showSeedPhrase"
|
@click="showSeedPhrase"
|
||||||
>
|
>
|
||||||
Reveal my Seed Phrase
|
Reveal my Seed Phrase
|
||||||
@@ -70,12 +70,9 @@ import * as R from "ramda";
|
|||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { accountsDB, db } from "@/db/index";
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
|
||||||
interface Account {
|
|
||||||
mnemonic: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class SeedBackupView extends Vue {
|
export default class SeedBackupView extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|||||||
209
src/views/SharedPhotoView.vue
Normal file
209
src/views/SharedPhotoView.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Image
|
||||||
|
</h1>
|
||||||
|
<div v-if="imageBlob">
|
||||||
|
<div v-if="uploading" class="text-center mb-4">
|
||||||
|
<fa icon="spinner" class="fa-spin-pulse" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-center mb-4">Choose how to use this image</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
@click="recordGift"
|
||||||
|
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
<fa icon="gift" class="fa-fw" />
|
||||||
|
Record a Gift
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="recordProfile"
|
||||||
|
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
<fa icon="circle-user" class="fa-fw" />
|
||||||
|
Save as Profile Image
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancel"
|
||||||
|
class="text-center text-md font-bold 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-2 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
<fa icon="ban" class="fa-fw" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PhotoDialog ref="photoDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<img
|
||||||
|
:src="URL.createObjectURL(imageBlob)"
|
||||||
|
alt="Shared Image"
|
||||||
|
class="rounded mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center mb-4">
|
||||||
|
<p>No image found.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios from "axios";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import PhotoDialog from "@/components/PhotoDialog.vue";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import {
|
||||||
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
|
IMAGE_TYPE_PROFILE,
|
||||||
|
NotificationIface,
|
||||||
|
} from "@/constants/app";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { getIdentity } from "@/libs/util";
|
||||||
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
|
||||||
|
@Component({ components: { PhotoDialog, QuickNav } })
|
||||||
|
export default class SharedPhotoView extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
activeDid: string | undefined = undefined;
|
||||||
|
imageBlob: Blob | undefined = undefined;
|
||||||
|
imageFileName: string | undefined = undefined;
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
URL = window.URL || window.webkitURL;
|
||||||
|
|
||||||
|
// 'created' hook runs when the Vue instance is first created
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
this.activeDid = settings?.activeDid as string;
|
||||||
|
|
||||||
|
const temp = await db.temp.get("shared-photo");
|
||||||
|
if (temp) {
|
||||||
|
this.imageBlob = temp.blob;
|
||||||
|
|
||||||
|
// clear the temp image
|
||||||
|
db.temp.delete("shared-photo");
|
||||||
|
|
||||||
|
this.imageFileName = this.$route.query.fileName as string;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Got an error loading an identifier:", err);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Got an error loading this data.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordGift() {
|
||||||
|
await this.sendToImageServer("GiveAction").then((url) => {
|
||||||
|
if (url) {
|
||||||
|
this.$router.push({
|
||||||
|
name: "gifted-details",
|
||||||
|
query: {
|
||||||
|
destinationNameAfter: "home",
|
||||||
|
hideBackButton: true,
|
||||||
|
imageUrl: url,
|
||||||
|
recipientDid: this.activeDid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordProfile() {
|
||||||
|
(this.$refs.photoDialog as PhotoDialog).open(
|
||||||
|
async (imgUrl) => {
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
profileImageUrl: imgUrl,
|
||||||
|
});
|
||||||
|
this.$router.push({ name: "account" });
|
||||||
|
},
|
||||||
|
IMAGE_TYPE_PROFILE,
|
||||||
|
true,
|
||||||
|
this.imageBlob,
|
||||||
|
this.imageFileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel() {
|
||||||
|
this.imageBlob = undefined;
|
||||||
|
this.imageFileName = undefined;
|
||||||
|
this.$router.push({ name: "home" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToImageServer(imageType: string) {
|
||||||
|
this.uploading = true;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
// send the image to the server
|
||||||
|
const identifier = await getIdentity(this.activeDid as string);
|
||||||
|
const token = await accessToken(identifier);
|
||||||
|
const headers = {
|
||||||
|
Authorization: "Bearer " + token,
|
||||||
|
};
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(
|
||||||
|
"image",
|
||||||
|
this.imageBlob as Blob,
|
||||||
|
this.imageFileName as string,
|
||||||
|
);
|
||||||
|
formData.append("claimType", imageType);
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||||
|
formData,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
if (response?.data?.url) {
|
||||||
|
this.imageBlob = undefined;
|
||||||
|
this.imageFileName = undefined;
|
||||||
|
result = response.data.url as string;
|
||||||
|
} else {
|
||||||
|
console.error("Problem uploading the image", response.data);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text:
|
||||||
|
"There was a problem saving the picture. " +
|
||||||
|
(response?.data?.message || ""),
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading the image", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was an error saving the picture.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
this.uploading = false;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -23,42 +23,47 @@
|
|||||||
|
|
||||||
<!-- id used by puppeteer test script -->
|
<!-- id used by puppeteer test script -->
|
||||||
<div id="start-question" class="mt-8">
|
<div id="start-question" class="mt-8">
|
||||||
<p class="text-center text-xl font-light">
|
<div class="max-w-3xl mx-auto">
|
||||||
Do you want a new identifier of your own?
|
<p class="text-center text-xl font-light">
|
||||||
</p>
|
Do you want a new identifier of your own?
|
||||||
<p class="text-center font-light">
|
</p>
|
||||||
If you haven't used this before, click "Yes" to generate a new
|
<p class="text-center font-light">
|
||||||
identifier.
|
If you haven't used this before, click "Yes" to generate a new
|
||||||
</p>
|
identifier.
|
||||||
<p class="text-center mb-4 font-light">
|
</p>
|
||||||
Only click "No" if you have a seed of 12 or 24 words generated
|
<p class="text-center mb-4 font-light">
|
||||||
elsewhere.
|
Only click "No" if you have a seed of 12 or 24 words generated
|
||||||
</p>
|
elsewhere.
|
||||||
<a
|
</p>
|
||||||
@click="onClickYes()"
|
<a
|
||||||
class="block w-full text-center text-lg uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
@click="onClickYes()"
|
||||||
>
|
class="block w-full text-center text-lg 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 mb-2"
|
||||||
Yes, generate one
|
>
|
||||||
</a>
|
Yes, generate one
|
||||||
<a
|
</a>
|
||||||
@click="onClickNo()"
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
<a
|
||||||
>
|
@click="onClickNo()"
|
||||||
No, I have a seed
|
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"
|
||||||
</a>
|
>
|
||||||
<a
|
No, I have a seed
|
||||||
v-if="numAccounts > 0"
|
</a>
|
||||||
@click="onClickDerive()"
|
<a
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
v-if="numAccounts > 0"
|
||||||
>
|
@click="onClickDerive()"
|
||||||
Derive new address from existing seed
|
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"
|
||||||
</a>
|
>
|
||||||
|
Derive new address from existing seed
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
import { accountsDB } from "@/db/index";
|
import { accountsDB } from "@/db/index";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div>
|
||||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
@@ -153,13 +153,269 @@
|
|||||||
Notif OFF
|
Notif OFF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
|
||||||
|
Populates the "shared-photo" view as if they used "share_target".
|
||||||
|
<input type="file" @change="uploadFile" />
|
||||||
|
<router-link
|
||||||
|
v-if="showFileNextStep()"
|
||||||
|
:to="{
|
||||||
|
name: 'shared-photo',
|
||||||
|
query: { fileName },
|
||||||
|
}"
|
||||||
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
||||||
|
>
|
||||||
|
Go to Shared Page
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
|
||||||
|
See console for results.
|
||||||
|
<br/>
|
||||||
|
Active DID: {{ activeDid }}
|
||||||
|
{{ credIdHex ? "has passkey ID" : "has no passkey ID" }}
|
||||||
|
<div>
|
||||||
|
Register
|
||||||
|
<button
|
||||||
|
@click="register()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Simplewebauthn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Create
|
||||||
|
<button
|
||||||
|
@click="createJwtSimplewebauthn()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Simplewebauthn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="createJwtNavigator()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Navigator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="jwt">
|
||||||
|
Verify
|
||||||
|
<button
|
||||||
|
@click="verifySimplewebauthn()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Simplewebauthn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="verifyWebCrypto()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
WebCrypto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="verifyP256()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
p256 - broken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="verifyMyJwt()"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Verify Mine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Buffer } from "buffer/";
|
||||||
|
import { Base64URLString } from "@simplewebauthn/types";
|
||||||
|
import { ref } from "vue";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import {
|
||||||
|
createPeerDid,
|
||||||
|
PeerSetup,
|
||||||
|
registerCredential,
|
||||||
|
verifyJwtP256,
|
||||||
|
verifyJwtSimplewebauthn,
|
||||||
|
verifyJwtWebCrypto,
|
||||||
|
} from "@/libs/didPeer";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
|
||||||
|
const inputFileNameRef = ref<Blob>();
|
||||||
|
|
||||||
|
const TEST_PAYLOAD = {
|
||||||
|
vc: {
|
||||||
|
credentialSubject: {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "GiveAction",
|
||||||
|
description: "pizza",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class Help extends Vue {}
|
export default class Help extends Vue {
|
||||||
|
// for file import
|
||||||
|
fileName?: string;
|
||||||
|
|
||||||
|
// for passkeys
|
||||||
|
credIdHex?: string;
|
||||||
|
activeDid?: string;
|
||||||
|
jwt?: string;
|
||||||
|
peerSetup?: PeerSetup;
|
||||||
|
userName?: string;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
this.activeDid = (settings?.activeDid as string) || "";
|
||||||
|
this.userName = settings?.firstName as string;
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
const account: { identity?: string } | undefined = await accountsDB.accounts
|
||||||
|
.where("did")
|
||||||
|
.equals(this.activeDid)
|
||||||
|
.first();
|
||||||
|
if (this.activeDid) {
|
||||||
|
if (account) {
|
||||||
|
this.credIdHex = account.passkeyCredIdHex as string;
|
||||||
|
} else {
|
||||||
|
alert("No account found for DID " + this.activeDid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(event: Event) {
|
||||||
|
inputFileNameRef.value = event.target.files[0];
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||||
|
// ... plus it has a `type` property from my testing
|
||||||
|
const file = inputFileNameRef.value;
|
||||||
|
if (file != null) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const data = e.target?.result as ArrayBuffer;
|
||||||
|
if (data) {
|
||||||
|
const blob = new Blob([new Uint8Array(data)], {
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
this.fileName = file.name as string;
|
||||||
|
const temp = await db.temp.get("shared-photo");
|
||||||
|
if (temp) {
|
||||||
|
await db.temp.update("shared-photo", { blob });
|
||||||
|
} else {
|
||||||
|
await db.temp.add({ id: "shared-photo", blob });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file as Blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showFileNextStep() {
|
||||||
|
return !!inputFileNameRef.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async register() {
|
||||||
|
const cred = await registerCredential(this.userName);
|
||||||
|
const publicKeyBytes = cred.publicKeyBytes;
|
||||||
|
this.activeDid = createPeerDid(publicKeyBytes as Uint8Array);
|
||||||
|
this.credIdHex = cred.credIdHex as string;
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
await accountsDB.accounts.add({
|
||||||
|
dateCreated: new Date().toISOString(),
|
||||||
|
did: this.activeDid,
|
||||||
|
passkeyCredIdHex: this.credIdHex,
|
||||||
|
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
|
||||||
|
});``
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createJwtSimplewebauthn() {
|
||||||
|
this.peerSetup = new PeerSetup();
|
||||||
|
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
|
||||||
|
this.activeDid as string,
|
||||||
|
TEST_PAYLOAD,
|
||||||
|
this.credIdHex as string,
|
||||||
|
);
|
||||||
|
console.log("simple jwt4url", this.jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createJwtNavigator() {
|
||||||
|
this.peerSetup = new PeerSetup();
|
||||||
|
this.jwt = await this.peerSetup.createJwtNavigator(
|
||||||
|
this.activeDid as string,
|
||||||
|
TEST_PAYLOAD,
|
||||||
|
this.credIdHex as string,
|
||||||
|
);
|
||||||
|
console.log("lower jwt4url", this.jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyP256() {
|
||||||
|
const decoded = await verifyJwtP256(
|
||||||
|
this.credIdHex as Base64URLString,
|
||||||
|
this.activeDid as string,
|
||||||
|
this.peerSetup.authenticatorData as ArrayBuffer,
|
||||||
|
this.peerSetup.challenge as Uint8Array,
|
||||||
|
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
|
||||||
|
this.peerSetup.signature as Base64URLString,
|
||||||
|
);
|
||||||
|
console.log("decoded", decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifySimplewebauthn() {
|
||||||
|
const decoded = await verifyJwtSimplewebauthn(
|
||||||
|
this.credIdHex as Base64URLString,
|
||||||
|
this.activeDid as string,
|
||||||
|
this.peerSetup.authenticatorData as ArrayBuffer,
|
||||||
|
this.peerSetup.challenge as Uint8Array,
|
||||||
|
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
|
||||||
|
this.peerSetup.signature as Base64URLString,
|
||||||
|
);
|
||||||
|
console.log("decoded", decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyWebCrypto() {
|
||||||
|
const decoded = await verifyJwtWebCrypto(
|
||||||
|
this.credIdHex as Base64URLString,
|
||||||
|
this.activeDid as string,
|
||||||
|
this.peerSetup.authenticatorData as ArrayBuffer,
|
||||||
|
this.peerSetup.challenge as Uint8Array,
|
||||||
|
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
|
||||||
|
this.peerSetup.signature as Base64URLString,
|
||||||
|
);
|
||||||
|
console.log("decoded", decoded);
|
||||||
|
}
|
||||||
|
public async verifyMyJwt() {
|
||||||
|
const jwt =
|
||||||
|
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
|
||||||
|
const pieces = jwt.split(".");
|
||||||
|
console.log("pieces", typeof pieces[1], pieces);
|
||||||
|
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
|
||||||
|
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
|
||||||
|
const clientJSON = Buffer.from(
|
||||||
|
payload["ClientDataJSONB64URL"],
|
||||||
|
"base64",
|
||||||
|
).toString();
|
||||||
|
const clientData = JSON.parse(clientJSON);
|
||||||
|
const challenge = clientData.challenge;
|
||||||
|
const signatureB64URL = pieces[2];
|
||||||
|
const decoded = await verifyJwtWebCrypto(
|
||||||
|
this.credIdHex as Base64URLString,
|
||||||
|
this.activeDid as string,
|
||||||
|
authData,
|
||||||
|
challenge,
|
||||||
|
payload["ClientDataJSONB64URL"],
|
||||||
|
signatureB64URL,
|
||||||
|
);
|
||||||
|
console.log("decoded", decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ self.addEventListener("push", function (event) {
|
|||||||
} else {
|
} else {
|
||||||
title = payload.title || "Update";
|
title = payload.title || "Update";
|
||||||
}
|
}
|
||||||
// getNotificationCount is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
|
// getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
message = await getNotificationCount();
|
message = await getNotificationCount();
|
||||||
}
|
}
|
||||||
@@ -112,8 +112,51 @@ self.addEventListener("message", (event) => {
|
|||||||
logConsoleAndDb("Service worker posted a message.");
|
logConsoleAndDb("Service worker posted a message.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
logConsoleAndDb("Notification got clicked.", event);
|
||||||
|
event.notification.close();
|
||||||
|
// from https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event
|
||||||
|
// ... though I don't see any benefit over just "clients.openWindow"
|
||||||
|
event.waitUntil(
|
||||||
|
clients
|
||||||
|
.matchAll({
|
||||||
|
type: "window",
|
||||||
|
})
|
||||||
|
.then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url === "/" && "focus" in client) return client.focus();
|
||||||
|
}
|
||||||
|
if (clients.openWindow) return clients.openWindow("/");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
logConsoleAndDb("Service worker got fetch event.", event);
|
logConsoleAndDb("Service worker got fetch event.", event);
|
||||||
|
|
||||||
|
// Bypass any regular requests not related to Web Share Target
|
||||||
|
// and also requests that are not exactly to the timesafari.app
|
||||||
|
// (note that Chrome will send subdomain requests like image-api.timesafari.app through this service worker).
|
||||||
|
if (
|
||||||
|
event.request.method !== "POST" ||
|
||||||
|
!event.request.url.endsWith("/share-target")
|
||||||
|
) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests related to Web Share share-target Target.
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const photo = formData.get("photo");
|
||||||
|
// savePhoto is injected from safari-notifications.js at build time by the sw_combine.js script
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
await savePhoto(photo);
|
||||||
|
return Response.redirect("/shared-photo", 303);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("error", (event) => {
|
self.addEventListener("error", (event) => {
|
||||||
|
|||||||
@@ -566,6 +566,20 @@ async function getNotificationCount() {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the image blob and go immediate to a page to upload it.
|
||||||
|
// @param photo - image Blob to store for later retrieval after redirect
|
||||||
|
async function savePhoto(photo) {
|
||||||
|
try {
|
||||||
|
const db = await openIndexedDB("TimeSafari");
|
||||||
|
const transaction = db.transaction("temp", "readwrite");
|
||||||
|
const store = transaction.objectStore("temp");
|
||||||
|
await updateRecord(store, { id: "shared-photo", blob: photo });
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("safari-notifications logMessage IndexedDB error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.appendDailyLog = appendDailyLog;
|
self.appendDailyLog = appendDailyLog;
|
||||||
self.getNotificationCount = getNotificationCount;
|
self.getNotificationCount = getNotificationCount;
|
||||||
self.decodeBase64 = decodeBase64;
|
self.decodeBase64 = decodeBase64;
|
||||||
|
|||||||
@@ -1,47 +1,35 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers
|
||||||
"resolveJsonModule": true,
|
"module": "ESNext", // Use ES modules
|
||||||
"target": "esnext",
|
"strict": true, // Enable all strict type checking options
|
||||||
"module": "esnext",
|
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler
|
||||||
"strict": true,
|
"moduleResolution": "node", // Use Node.js style module resolution
|
||||||
"strictPropertyInitialization": false,
|
"experimentalDecorators": true,
|
||||||
"jsx": "preserve",
|
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports
|
||||||
"moduleResolution": "node",
|
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export
|
||||||
"experimentalDecorators": true,
|
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file
|
||||||
"skipLibCheck": true,
|
"useDefineForClassFields": true,
|
||||||
"esModuleInterop": true,
|
"sourceMap": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"baseUrl": "./src", // Base directory to resolve non-relative module names
|
||||||
"forceConsistentCasingInFileNames": true,
|
"paths": {
|
||||||
"useDefineForClassFields": true,
|
"@/components/*": ["components/*"],
|
||||||
"sourceMap": true,
|
"@/views/*": ["views/*"],
|
||||||
"baseUrl": "./src",
|
"@/db/*": ["db/*"],
|
||||||
"types": [
|
"@/libs/*": ["libs/*"],
|
||||||
"webpack-env"
|
"@/constants/*": ["constants/*"],
|
||||||
],
|
"@/store/*": ["store/*"]
|
||||||
"paths": {
|
},
|
||||||
"@/components/*": ["components/*"],
|
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs
|
||||||
"@/views/*": ["views/*"],
|
|
||||||
"@/db/*": ["db/*"],
|
|
||||||
"@/libs/*": ["libs/*"],
|
|
||||||
"@/constants/*": ["constants/*"],
|
|
||||||
"@/store/*": ["store/*"],
|
|
||||||
},
|
},
|
||||||
"lib": [
|
"include": [
|
||||||
"esnext",
|
"src/**/*.ts",
|
||||||
"dom",
|
"src/**/*.tsx",
|
||||||
"dom.iterable",
|
"src/**/*.vue",
|
||||||
"scripthost"
|
"tests/**/*.ts",
|
||||||
|
"tests/**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.tsx",
|
|
||||||
"src/**/*.vue",
|
|
||||||
"tests/**/*.ts",
|
|
||||||
"tests/**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
51
vite.config.mjs
Normal file
51
vite.config.mjs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 8080
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: '.',
|
||||||
|
filename: 'sw_scripts-combined.js',
|
||||||
|
manifest: {
|
||||||
|
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
|
||||||
|
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
|
||||||
|
// 192x192 and 512x512 are important for Chrome to show that it's installable
|
||||||
|
"icons":[
|
||||||
|
{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
|
||||||
|
{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},
|
||||||
|
{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},
|
||||||
|
{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}
|
||||||
|
],
|
||||||
|
share_target: {
|
||||||
|
action: '/share-target',
|
||||||
|
method: 'POST',
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
params: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'photo',
|
||||||
|
accept: ['image/*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
buffer: path.resolve(__dirname, 'node_modules', 'buffer'),
|
||||||
|
'dexie-export-import/dist/import': 'dexie-export-import/dist/import/index.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
const { defineConfig } = require("@vue/cli-service");
|
|
||||||
const { gitDescribeSync } = require("git-describe");
|
|
||||||
const { exec } = require("child_process");
|
|
||||||
|
|
||||||
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash;
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
transpileDependencies: true,
|
|
||||||
configureWebpack: {
|
|
||||||
devtool: "source-map",
|
|
||||||
experiments: {
|
|
||||||
topLevelAwait: true,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
// Still don't know why this runs three times.
|
|
||||||
apply: (compiler) => {
|
|
||||||
compiler.hooks.beforeCompile.tap("BeforeCompile", () => {
|
|
||||||
// Execute combine-sw.js script
|
|
||||||
exec("node sw_combine.js", (error, stdout, stderr) => {
|
|
||||||
if (error || stderr) {
|
|
||||||
console.error("Service worker files error:", error || stderr);
|
|
||||||
} else {
|
|
||||||
console.log("Finished combining service worker files.", stdout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pwa: {
|
|
||||||
iconPaths: {
|
|
||||||
faviconSVG: "img/icons/safari-pinned-tab.svg",
|
|
||||||
},
|
|
||||||
workboxPluginMode: "InjectManifest",
|
|
||||||
workboxOptions: {
|
|
||||||
// this script will be checked for linting (sw_scripts/* files generate about 1000 linting errors)
|
|
||||||
swSrc: "./sw_scripts-combined.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user