Compare commits
360 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e642b99ff5 | |||
| 26f1e88f5a | |||
| 2e164dfeff | |||
| d7530ff56b | |||
| 2db52cb72e | |||
| c8eb3bfbc0 | |||
| 71b210d541 | |||
| 66289ec206 | |||
| 639dc7b4e5 | |||
| 4fe072f19e | |||
| f253f0af0f | |||
| 2d95a35905 | |||
| 88f869d600 | |||
| a0911bb0fd | |||
| 1053b78ab8 | |||
| dcfa8d9451 | |||
| dd38f76ee1 | |||
| 667e1e8890 | |||
| 1731f2443b | |||
| e1cffcda2d | |||
| a5b1b97012 | |||
| 563b5793a9 | |||
| 660436c8fa | |||
| 31a7752168 | |||
| 3ebe7bc156 | |||
| 0eb16d5661 | |||
| edb09da10f | |||
| be6ec6745a | |||
| b79c5fcf91 | |||
| 9dea4066c9 | |||
| 9b586566f0 | |||
| e5e702f8a5 | |||
| 32c9076c39 | |||
| 6ab4c40fd0 | |||
| d7ef07c2e2 | |||
| 9f595040d8 | |||
| 40a8794649 | |||
| fa72d38d18 | |||
| 31aacb286f | |||
| 2511f18fa7 | |||
| febfa8b098 | |||
| e0fcb1f67b | |||
| 9183092325 | |||
| a87179d127 | |||
| 14e203dd74 | |||
| acaaf8776d | |||
| cb1f38c182 | |||
| cfa7466b94 | |||
| f998364c72 | |||
| 7b4f084b4b | |||
| 115329e26c | |||
| 61bef57563 | |||
| a5368d0f82 | |||
| 48cb45d230 | |||
| 8a7ce0fe65 | |||
| 525d3fc15a | |||
| 68f3b79983 | |||
| 5353fe770a | |||
| 60fec5763d | |||
| aeb1d6a6a5 | |||
| ec6175a550 | |||
| c1361e088f | |||
| a2c986951e | |||
| dce7b8e3d9 | |||
| 211e0487fe | |||
| cc931dcb04 | |||
| bfe14cc9c2 | |||
| 275dba4468 | |||
| 1f05e81b05 | |||
| e9ad68f2a5 | |||
| 934664b9c9 | |||
| 780be59c76 | |||
| 4a0bedb628 | |||
| 5689f95230 | |||
| 3083bb084a | |||
| 821d27a58a | |||
|
|
998a1d312f | ||
| 1f13bf772c | |||
| def744b3df | |||
| 0fb37acb24 | |||
| 20bb723f0b | |||
| d821a7bd59 | |||
| 9f3b7314e8 | |||
| 4df7bb58a4 | |||
| 15ccd2394f | |||
| 920c7bb612 | |||
| 6eb26ea90c | |||
| 28b6d9bbf9 | |||
| 7a099183ae | |||
| 11070755d6 | |||
| c79dfac1fe | |||
| 2b06c64664 | |||
| 769a928b3d | |||
| d26d1d3601 | |||
| 1e6159869f | |||
| 75d15ddeb9 | |||
| 051a0a97d8 | |||
| f8d3fe2ee1 | |||
| 4f0a046723 | |||
| c4a0458c08 | |||
| 25b1598fcb | |||
| ddbb700c34 | |||
| fd8877900b | |||
| 05c6ddda02 | |||
| 853eb3c623 | |||
| 44cfe0d88e | |||
| 7fe256dc9e | |||
| e739d0be7c | |||
| 8d873b51bd | |||
| d7f4acb702 | |||
| f8002c4550 | |||
| d6b1386741 | |||
| 50fdd95c60 | |||
| 91c6c7c11c | |||
| 4e28dc8de6 | |||
| fb425f0d51 | |||
| a19aebcb37 | |||
| d0697c1ef4 | |||
| 1dd2333624 | |||
|
|
b4b78f6a2c | ||
|
|
3c0f6ce0de | ||
| 5534f8fa50 | |||
| a5004d475e | |||
| b445b1234f | |||
| 17c96dd01a | |||
| 6ad17101b2 | |||
| b4085ffaa7 | |||
| 4f2cb55753 | |||
| ebf9164ecc | |||
| 540cc21839 | |||
| c182068901 | |||
| aaa1f31945 | |||
| 17c632eb16 | |||
| 41c4cbe61a | |||
| c8402797ad | |||
| 4a09b9b9b1 | |||
| 5db3423301 | |||
| 2b00b243e8 | |||
| f2e5d8168d | |||
| 1d262b8da9 | |||
| 8ed74b71f2 | |||
| 8fb21c3d89 | |||
| 8dbfcd38d3 | |||
| 04df0d4eff | |||
|
|
ab523639a5 | ||
|
|
0484dfb253 | ||
|
|
c2839e8a99 | ||
|
|
e533cd3d34 | ||
|
|
18e00b95c7 | ||
|
|
e97cd1b1fa | ||
| ccca93b9f1 | |||
| 1be6c04699 | |||
| 2c33febb0e | |||
| e6f73dc81c | |||
| 0d55a722c5 | |||
| 97ef78f5dd | |||
| 672abac9a9 | |||
| 0607fad3e5 | |||
| 6aa89a1d1d | |||
| 2556d5feb9 | |||
| 3c1654764c | |||
| 4c1e229d62 | |||
| 17444d75de | |||
| f2fb432d2e | |||
| e45689daed | |||
| 041308ebc9 | |||
| 9c36bb509a | |||
| 2c300614ef | |||
| 8849e8806a | |||
| f75094283a | |||
| 0fabccd410 | |||
|
|
8ddf7d9532 | ||
|
|
4078853558 | ||
|
|
f4df5ffa9a | ||
| fa856f7594 | |||
|
|
a60beb483c | ||
| a0db6433a6 | |||
| 59d0772881 | |||
| b18e554886 | |||
| 098ef3c644 | |||
| 6045975b79 | |||
| a6bb036ceb | |||
| 1e2ad85547 | |||
|
|
3e2723b744 | ||
| 4daffe8f40 | |||
| efb1922826 | |||
| c6e10bfdad | |||
| bb122be319 | |||
| 3f436476a2 | |||
| a77d20b572 | |||
| 393d1583ae | |||
| 69a25ddd6c | |||
| a12d7fcc1b | |||
| 69c60e5426 | |||
| 4806acc30e | |||
| 1127d7079b | |||
| 0bbadfec6d | |||
| 276d8b2f19 | |||
| a7fbbbd4cd | |||
| a8d362c14d | |||
| ce5933f645 | |||
| 5cbf917ada | |||
| 7335412145 | |||
| feea1a1d3b | |||
| 7f4d31a79c | |||
| 4041a7d08e | |||
|
|
9846cf3e4c | ||
| 681d949098 | |||
| 3bf8fd0c22 | |||
| fa41fb3415 | |||
| 6dbfc5f77d | |||
| 1b9ae96006 | |||
|
|
4dd5664462 | ||
|
|
7d6a45061d | ||
|
|
3b32c2b156 | ||
|
|
1ee6203f4c | ||
|
|
d93299c352 | ||
|
|
9aea7a576d | ||
| 714bb169fa | |||
| 606d9ec734 | |||
| 7a3bd069b8 | |||
| b1ac9e71cb | |||
| c1176fa24d | |||
| 1cf6660e6c | |||
| 6957678474 | |||
| 889b6d5737 | |||
| 1be10b1511 | |||
| 85405317ee | |||
| 072497a553 | |||
| 8a33ccfdcf | |||
| 7311d36726 | |||
| 7e819ea4de | |||
| 5670f23bf3 | |||
| 08d9ca3a25 | |||
| 607666a2f9 | |||
| 0a618cc4ff | |||
| e387794db3 | |||
| ab1a725c1b | |||
| 46d76013e8 | |||
| faf8f4f6a9 | |||
| 154fcd98a5 | |||
| c391385500 | |||
| b64f35869e | |||
| 45fbf7ade5 | |||
| 92fcffdfc5 | |||
| 5f5562f5e3 | |||
| 74ed025377 | |||
| f36ecfd8db | |||
| ee6a344daf | |||
| 65a5edf26b | |||
| fc70a11bd8 | |||
| 73f890beac | |||
| 67dce9e678 | |||
| 2b66ddfb83 | |||
| 56fc2893a2 | |||
|
|
552ad5a267 | ||
|
|
910f57ec7d | ||
|
|
e813315dad | ||
| aea9626c06 | |||
|
|
7f0f1b7fc8 | ||
|
|
cfc4d0a947 | ||
|
|
8684488def | ||
|
|
a820a7b131 | ||
| 30d45c0acf | |||
| 221bb2a27c | |||
| 2961e29831 | |||
| 5ae5e110c2 | |||
| 20c2954be1 | |||
| a848e1fa81 | |||
| 85bd807bcc | |||
| eeece8a1b4 | |||
| bbfc1e1007 | |||
| 433d0c023e | |||
| ac6376243b | |||
| a12f033b72 | |||
| 42cd7d00de | |||
| c388cc8cfe | |||
| 6d4d4e40c3 | |||
| 3b39faf173 | |||
| f43ecc98aa | |||
| 5b7ccf9ef0 | |||
| 9bacd4da87 | |||
|
|
ee28b18b14 | ||
| 7450d8d1c3 | |||
| 7490cfc557 | |||
| 95287e4dd0 | |||
| 679d1a70e8 | |||
| 047fb263dd | |||
| b76cf28bc2 | |||
| 58c091cdaa | |||
| 0df5a975f3 | |||
| 94051e6ba9 | |||
| 8e60f53f0b | |||
| afc48a5434 | |||
| 6eb3381a98 | |||
| 2bec218cc5 | |||
| 327c655fb3 | |||
| 866aad069f | |||
| 7f6c938029 | |||
| 6d2df4a50c | |||
| 7305606546 | |||
| 2a9ff8aa77 | |||
| 829994491c | |||
| ce06e8f0fa | |||
| 1ee751eea8 | |||
|
|
2d38183dce | ||
|
|
082a6eae1f | ||
|
|
d07fb47721 | ||
|
|
ccb6160bca | ||
| 116b239616 | |||
|
|
2eaa4203aa | ||
|
|
f27a18c712 | ||
| f47346cc35 | |||
|
|
2c4a920c3c | ||
| 0e02268950 | |||
| 94d9c425ad | |||
|
|
ed91cadd9d | ||
|
|
a6de282aec | ||
| 2db662c125 | |||
| b7892f4dfa | |||
|
|
3bbb138299 | ||
|
|
5b5c631001 | ||
|
|
e60b56a0b0 | ||
|
|
d3e025c293 | ||
|
|
6f4027f614 | ||
| 249811efe3 | |||
| bd2455458f | |||
| a053c48819 | |||
| 9486142b2a | |||
|
|
2fba7f2a55 | ||
|
|
31d13b9143 | ||
|
|
852bd93f3f | ||
| b707bfce40 | |||
| bdb8e2e32a | |||
| 06b173e861 | |||
| 6a8b9d36a7 | |||
| 52a6451a2d | |||
| 4b9cbd0e9f | |||
| a5e0c847b1 | |||
|
|
fd43da93a5 | ||
| b59bcf249a | |||
| b05b602acd | |||
| b8aaffbf8d | |||
|
|
5501ac1a2f | ||
|
|
b514d64068 | ||
|
|
c4537420b4 | ||
|
|
5f50338dd0 | ||
| 308386d829 | |||
| 999d7abc04 | |||
| f7f947bfdd | |||
| 26d9b134c7 | |||
| 43f942c905 | |||
| 8ee610c1bc | |||
| 8d15b7bfb8 | |||
| 5c57ee3e72 | |||
|
|
3f7bcbfd76 | ||
| ef0988c9ec | |||
| 22de6113e9 | |||
| 87139f203c | |||
| c8de13d376 |
1
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules
|
|||||||
signature.bin
|
signature.bin
|
||||||
*.pem
|
*.pem
|
||||||
verified.txt
|
verified.txt
|
||||||
|
myenv
|
||||||
|
|
||||||
*~
|
*~
|
||||||
# local env files
|
# local env files
|
||||||
|
|||||||
132
CHANGELOG.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
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/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.11] - 2024.01.28
|
||||||
|
### Added
|
||||||
|
- Actions to share claim data with contacts
|
||||||
|
- Bulk CSV import from Endorser Mobile export
|
||||||
|
- Dates on give summaries
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.10] - 2024.01.18 - 667e1e8890b42de59cd939caca1a01c7a7a702be
|
||||||
|
### Added
|
||||||
|
- Person identicons for contacts
|
||||||
|
- Confirmation & delivery directly from project page
|
||||||
|
- Offer dialog now allows units
|
||||||
|
- Links from claim detail page to the fulfilled project or offer
|
||||||
|
- Link to project from home feed
|
||||||
|
- Copy to clipboard in more places
|
||||||
|
### Fixed
|
||||||
|
- "More Contacts" for give on project page now links correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.9] - 2024.01.15 - e5e702f8a5a53a6efbed48d35f0bc3cee63024a0
|
||||||
|
### Fixed
|
||||||
|
- Set visibility for new contact.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.8] - 2024.01.14
|
||||||
|
### Added
|
||||||
|
- Automatic ID creation from home page
|
||||||
|
- Agent who can also edit a project
|
||||||
|
### Fixed
|
||||||
|
- Cannot declare anonymous gift
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.7] - 2024.01.12
|
||||||
|
### Added
|
||||||
|
- Give to fulfill a particular offer
|
||||||
|
- Give as part of a trade as opposed to a donation
|
||||||
|
- Error notifications on import
|
||||||
|
### Changed
|
||||||
|
- Library security updates
|
||||||
|
- Visibility of actions & confirmations on claim page
|
||||||
|
### Fixed
|
||||||
|
- Name of offerer
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.2] - 2024.01.05
|
||||||
|
### Added
|
||||||
|
- Check for notification capability on front screen
|
||||||
|
- Contact next-public-key-hash in manual textual input
|
||||||
|
- Confirmation for contact visibility change
|
||||||
|
- YAML rendering of full claim details
|
||||||
|
- Hints for onboarding on the contact screen
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.0] - 2024.01.04
|
||||||
|
### Added
|
||||||
|
- Contact next-public-key-hash
|
||||||
|
- Icon for Android
|
||||||
|
- More thorough messaging and testing for notifications
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.9] - 2024.01.01
|
||||||
|
### Added
|
||||||
|
- Import for contacts and settings
|
||||||
|
- Second download button for DuckDuckGo
|
||||||
|
### Changed
|
||||||
|
- Removed some keys from Dexie's IndexedDB declarations
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.8] - 2023.12.27- d26d1d360152a7d0e559b68486e85b72b88bd9ff
|
||||||
|
### Added
|
||||||
|
- DB logging for service-worker events
|
||||||
|
- Help page for notifications
|
||||||
|
- Test notification & web-push triggers inside app
|
||||||
|
- Check that the app is installed
|
||||||
|
### Fixed
|
||||||
|
- Project issuer display name
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.7] - 2023.12.19 - 91c6c7c11c71f96006cc876fc946f1f98a274ba2
|
||||||
|
### Changed
|
||||||
|
- Icons
|
||||||
|
### Fixed
|
||||||
|
- Notification switch now shows message
|
||||||
|
- Prod/test server warning message at top of page
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.6] - 2023.12.17 - b445b1234fbfcf6b37d695373f259aab0eda1118
|
||||||
|
### Added
|
||||||
|
- Infinite scroll on home page
|
||||||
|
### Changed
|
||||||
|
- UI improvements
|
||||||
|
- Show web-push subscription info
|
||||||
|
- Icon
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.5] - 2023.12.09 - 9c36bb509a9bae9bb3306d3bd9eeb144b67aa8ad
|
||||||
|
### Added
|
||||||
|
- Web push notifications (though not finalized)
|
||||||
|
- Credentials details page
|
||||||
|
- See more data without an ID
|
||||||
|
- Change units of a give
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.4] - 2023.11.20 - 7311d36726f3667ec4c68f241f91d404273ad4db
|
||||||
|
### Added
|
||||||
|
- Offer on a project
|
||||||
|
### Changed
|
||||||
|
- Automatically set as visible when importing a contact
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.3] - 2023.11.08 - 910f57ec7d2e50803ae3d04f4b927e0f5219fbde
|
||||||
|
### Added
|
||||||
|
- Contact name editing
|
||||||
|
### Changed
|
||||||
|
- Don't show actions on front page if not registered.
|
||||||
|
### Removed
|
||||||
|
- Home page Notiwind test buttons
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.2] - 2023.11.01 - 7f6c93802911a030a89fe3706e18b5c17151e5bb
|
||||||
|
### Added
|
||||||
|
- Basics: create ID, record a give, declare a project, search, and get notifications.
|
||||||
6
CONTRIBUTING.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Welcome! We are happy to have your help with this project.
|
||||||
|
|
||||||
|
Note that all contributions will be under our
|
||||||
|
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
||||||
264
README.md
@@ -1,6 +1,9 @@
|
|||||||
# kickstart-for-time-pwa
|
# TimeSafari.app - Crowd-Funder for Time - PWA
|
||||||
|
|
||||||
## Project setup
|
## Project setup
|
||||||
|
|
||||||
|
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
@@ -10,203 +13,162 @@ npm install
|
|||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and minifies for production
|
|
||||||
|
|
||||||
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lints and fixes files
|
### Lints and fixes files
|
||||||
```
|
```
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test key contents
|
### Compiles and minifies for production
|
||||||
|
|
||||||
See [this page](openssl_signing_console.rst)
|
* 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/`
|
||||||
|
|
||||||
|
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`, and commit.
|
||||||
|
|
||||||
|
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
||||||
|
|
||||||
|
... though maybe you do that after testing and release, since that isn't used in the build (and you often increment a lot during testing).
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* `npm run build`
|
||||||
|
|
||||||
|
...to make sure the service worker scripts are in proper form. (It's only important if you changed something in that directory.)
|
||||||
|
|
||||||
|
* `cp sw_scripts/[ns]* dist/`
|
||||||
|
|
||||||
|
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
|
||||||
|
|
||||||
|
* Get on the server and back up the time-safari folder.
|
||||||
|
|
||||||
|
* `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), 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
### Register new user on test server
|
### Register new user on test server
|
||||||
|
|
||||||
New users require registration. This can be done with a claim payload like this
|
|
||||||
by an existing user:
|
|
||||||
|
|
||||||
```
|
|
||||||
const vcClaim = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "RegisterAction",
|
|
||||||
agent: { identifier: identity0.did },
|
|
||||||
object: SERVICE_ID,
|
|
||||||
participant: { identifier: newIdentity.did },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
On the test server, User #0 has rights to register others, so you can start
|
On the test server, User #0 has rights to register others, so you can start
|
||||||
playing one of two ways:
|
playing by importing that user and registering others. Import the keys for the test User
|
||||||
|
`did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` by importing this seed phrase:
|
||||||
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing this seed phrase:
|
`rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||||
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
|
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
|
||||||
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
|
|
||||||
|
|
||||||
- Alternatively, register someone else under User #0 automatically:
|
|
||||||
|
|
||||||
* In the `src/views/AccountViewView.vue` file, uncomment the lines referring to "testServerRegisterUser".
|
|
||||||
|
|
||||||
* Visit the `/account` page.
|
|
||||||
|
|
||||||
### Create multiple identifiers
|
### Create multiple identifiers
|
||||||
|
|
||||||
Go to /start and create or import a new one. Then switch identifiers on the bottom of the Your Identity page.
|
Under the "Your Identity" screen, click "Advanced", click "Switch Identity / No Identity", then "Add Another Identity...".
|
||||||
|
|
||||||
### Create keys with alternate tools
|
### Create keys with alternate tools
|
||||||
|
|
||||||
See [this page](openssl_signing_console.rst)
|
[This page](openssl_signing_console.rst) is a tool to create a JWT from a locally-generated keypair.
|
||||||
|
|
||||||
|
### Web-push
|
||||||
|
|
||||||
|
For your own web-push tests, change the push server URL in Advanced settings on the account page, and install Time Safari & push server on the same domain.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Check that the version is updated.
|
||||||
|
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
||||||
|
- Clear the browser data again. (See "Reset" below.) Make sure that it's using the test API (under Identity in 'Advanced').
|
||||||
|
- Go to the account page before visiting the home page to see that there is no ID.
|
||||||
|
- On the home page:
|
||||||
|
- Check that it generated an ID.
|
||||||
|
- Check the feed without names.
|
||||||
|
- Copy the contact URL.
|
||||||
|
- 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 contacts page, check that they can add User #0 even without their own ID.
|
||||||
|
- As User #0 in another browser on the test API, add a give & a project.
|
||||||
|
- `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||||
|
- 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 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.
|
||||||
|
- On the account page, check that they see messages on limits.
|
||||||
|
- 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.
|
||||||
|
- On the contacts page, check that they cannot register someone else yet.
|
||||||
|
- Walk through the functions on each page.
|
||||||
|
- Set and run notifications.
|
||||||
|
- Export & import, both seed and contacts & settings.
|
||||||
|
- Choose location on the search map.
|
||||||
|
- Offer, deliver a give, and confirm. Create a third user and test connections.
|
||||||
|
- Switch to "no identifier" to see that things look OK without any ID.
|
||||||
|
|
||||||
### Customize Vue configuration
|
|
||||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
|
||||||
|
|
||||||
|
|
||||||
## Scenarios
|
## Scenarios
|
||||||
|
|
||||||
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard.
|
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard.
|
||||||
|
|
||||||
- Go back to /start and import test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` with this this seed phrase:
|
- Go back to /start and import test User #0 `did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` with this this seed phrase:
|
||||||
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
|
`rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||||
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
|
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
|
||||||
|
|
||||||
- Go to "Your Contacts" screen and add the ID you copied to the clipboard, and hit "+" to add them.
|
- Go to "Your Contacts" screen and add the ID you copied to the clipboard, and hit "+" to add them.
|
||||||
|
|
||||||
- Click on the "Registration Unknown" button and register that person to be able to make claims as them.
|
- Click on the "Registration Unknown" button and register that person to be able to make claims as them.
|
||||||
|
|
||||||
### Clear data & restart
|
### Clear/Reset data & restart
|
||||||
|
|
||||||
Clear cache for localhost, then go to http://localhost:8080/start
|
* 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.)
|
||||||
(because it'll generate a new one automatically if you start on the `/account` page).
|
* 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`.)
|
||||||
|
* 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.)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Dependencies
|
## Troubleshooting
|
||||||
|
|
||||||
|
* A problem with `GET http://localhost:8080/web-push/vapid` means the py-push-server is not running
|
||||||
|
(and notifications won't work for a local app without special routing from the browser's web push service provider, anyway).
|
||||||
|
|
||||||
|
* Red errors everywhere with a console message like this:
|
||||||
|
`Error: An ID is chosen but there are no keys for it so it cannot be used to talk with the service`
|
||||||
|
... has happened on account switching when the current account was erased (or maybe replaced -- once I had a duplicate and I don't know how).
|
||||||
|
|
||||||
|
* The error `DEXIE ENCRYPT ADDON: Could not decrypt message!` or
|
||||||
|
`Encryption key has changed` means that the encryption key is wrong,
|
||||||
|
sometimes seen after clearing storage for testing; you can make it happen by clearing localStorage.
|
||||||
|
Maybe only part of the storage was cleared out. Unless you got a copy of that password, you'll
|
||||||
|
have to erase storage and reload the identifier.
|
||||||
|
|
||||||
See https://tea.xyz
|
|
||||||
|
|
||||||
| Project | Version |
|
|
||||||
| ---------- | --------- |
|
|
||||||
| nodejs.org | ^16.0.0 |
|
|
||||||
| npmjs.com | ^8.0.0 |
|
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
### Reference Material
|
### Reference Material
|
||||||
|
|
||||||
```
|
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
|
||||||
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
|
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
|
||||||
|
|
||||||
// Import an existing ID
|
* [Customize Vue configuration](https://cli.vuejs.org/config/).
|
||||||
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
|
|
||||||
|
|
||||||
// just to get rid of variability that might cause an error
|
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||||
mnemonic = mnemonic.trim().toLowerCase()
|
|
||||||
|
|
||||||
/**
|
|
||||||
// an approach I pieced together
|
|
||||||
// requires: yarn add elliptic
|
|
||||||
// ... plus:
|
|
||||||
// const EC = require('elliptic').ec
|
|
||||||
// const secp256k1 = new EC('secp256k1')
|
|
||||||
//
|
|
||||||
const keyHex: string = bip39.mnemonicToEntropy(mnemonic)
|
|
||||||
// returns a KeyPair from the elliptic.ec library
|
|
||||||
const keyPair = secp256k1.keyFromPrivate(keyHex, 'hex')
|
|
||||||
// this code is from did-provider-eth createIdentifier
|
|
||||||
const privateHex = keyPair.getPrivate('hex')
|
|
||||||
const publicHex = keyPair.getPublic('hex')
|
|
||||||
const address = didJwt.toEthereumAddress(publicHex)
|
|
||||||
**/
|
|
||||||
|
|
||||||
/**
|
### Kudos
|
||||||
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
|
|
||||||
// ... which almost works but the didJwt.toEthereumAddress is wrong
|
|
||||||
// requires: yarn add bip32
|
|
||||||
// ... plus: import * as bip32 from 'bip32'
|
|
||||||
//
|
|
||||||
const seed: Buffer = await bip39.mnemonicToSeed(mnemonic)
|
|
||||||
const root = bip32.fromSeed(seed)
|
|
||||||
const node = root.derivePath(UPORT_ROOT_DERIVATION_PATH)
|
|
||||||
const privateHex = node.privateKey.toString("hex")
|
|
||||||
const publicHex = node.publicKey.toString("hex")
|
|
||||||
const address = didJwt.toEthereumAddress('0x' + publicHex)
|
|
||||||
**/
|
|
||||||
|
|
||||||
/**
|
|
||||||
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
|
|
||||||
// requires: yarn add @ethersproject/hdnode
|
|
||||||
// ... plus: import { HDNode } from '@ethersproject/hdnode'
|
|
||||||
**/
|
|
||||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic)
|
|
||||||
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH)
|
|
||||||
const privateHex = rootNode.privateKey.substring(2) // original starts with '0x'
|
|
||||||
const publicHex = rootNode.publicKey.substring(2) // original starts with '0x'
|
|
||||||
let address = rootNode.address
|
|
||||||
|
|
||||||
const prevIds = previousIdentifiers || [];
|
|
||||||
|
|
||||||
if (toLowercase) {
|
|
||||||
const foundEqual = R.find(
|
|
||||||
(id) => utility.rawAddressOfDid(id.did) === address,
|
|
||||||
prevIds
|
|
||||||
)
|
|
||||||
if (foundEqual) {
|
|
||||||
// They're trying to create a lowercase version of one that exists in normal case.
|
|
||||||
// (We really should notify the user.)
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a normal-case version of the DID since a regular version exists."}))
|
|
||||||
} else {
|
|
||||||
address = address.toLowerCase()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// They're not trying to convert to lowercase.
|
|
||||||
const foundLower = R.find((id) =>
|
|
||||||
utility.rawAddressOfDid(id.did) === address.toLowerCase(),
|
|
||||||
prevIds
|
|
||||||
)
|
|
||||||
if (foundLower) {
|
|
||||||
// They're trying to create a normal case version of one that exists in lowercase.
|
|
||||||
// (We really should notify the user.)
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a lowercase version of the DID since a lowercase version exists."}))
|
|
||||||
address = address.toLowerCase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... derived keys and address..."}))
|
|
||||||
|
|
||||||
const newId = newIdentifier(address, publicHex, privateHex, UPORT_ROOT_DERIVATION_PATH)
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... created new ID..."}))
|
|
||||||
|
|
||||||
// awaiting because otherwise the UI may not see that a mnemonic was created
|
|
||||||
const savedId = await storeIdentifier(newId, mnemonic, mnemonicPassword)
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... stored new ID..."}))
|
|
||||||
return savedId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a totally new ID
|
|
||||||
export const createAndStoreIdentifier = async (mnemonicPassword) => {
|
|
||||||
|
|
||||||
// This doesn't give us the entropy/seed.
|
|
||||||
//const id = await agent.didManagerCreate()
|
|
||||||
|
|
||||||
const entropy = crypto.randomBytes(32)
|
|
||||||
const mnemonic = bip39.entropyToMnemonic(entropy)
|
|
||||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... generated mnemonic..."}))
|
|
||||||
|
|
||||||
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kudos
|
|
||||||
|
|
||||||
Gifts make the world go 'round!
|
Gifts make the world go 'round!
|
||||||
|
|
||||||
|
* [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
|
||||||
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
|
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
|
||||||
* [Many tools & libraries]() such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
|
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
|
||||||
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
|
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
|
||||||
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
|
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
|
||||||
|
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
|
||||||
|
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
Prerequisites:
|
JWT Creation & Verification
|
||||||
|
|
||||||
jq
|
To run this in a script, see ./openssl_signing_console.sh
|
||||||
|
|
||||||
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
Prerequisites: openssl, jq
|
||||||
|
|
||||||
|
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using
|
||||||
|
a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
||||||
|
|
||||||
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
|
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
|
||||||
|
|
||||||
@@ -15,20 +18,22 @@ openssl ec -in private.pem -pubout -out public.pem
|
|||||||
|
|
||||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
||||||
|
|
||||||
Next, create a payload object as a JSON object containing the claims you want to include in the JWT. For example schema.org :
|
Next, create a payload object as a JSON object containing the claims you want to include in the JWT.
|
||||||
|
For example schema.org :
|
||||||
|
|
||||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
||||||
|
|
||||||
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
|
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
|
||||||
|
|
||||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
|
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
|
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||||
|
|
||||||
Concatenate the encoded header, payload, and a secret to create the signing input:
|
Concatenate the encoded header, payload, and a secret to create the signing input:
|
||||||
|
|
||||||
signing_input="$header_b64.$payload_b64"
|
signing_input="$header_b64.$payload_b64"
|
||||||
|
|
||||||
Create the signature by signing the signing input with a ES256K algorithm and your secret. You can use the openssl command line utility to do this:
|
Create the signature by signing the signing input with a ES256K algorithm and your secret.
|
||||||
|
You can use the openssl command line utility to do this:
|
||||||
|
|
||||||
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
|
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ Authorization: Bearer $jwt
|
|||||||
|
|
||||||
To verify the JWT, you can use the openssl utility with the public key:
|
To verify the JWT, you can use the openssl utility with the public key:
|
||||||
|
|
||||||
openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature")
|
||||||
|
|
||||||
This will verify the signature and output Verified OK if the signature is valid. If the signature is not valid, it will output an error.
|
|
||||||
|
|
||||||
|
This will verify the signature and output "Verified OK" if the signature is valid.
|
||||||
|
If the signature is not valid, it will give an error response and output "Verification failure".
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Generate a JWT, with signature verified using OpenSSL
|
||||||
|
#
|
||||||
|
# Prerequisites: openssl, jq
|
||||||
|
#
|
||||||
|
# Usage: source ./openssl_signing_console.sh
|
||||||
|
#
|
||||||
|
# For a more complete explanation, see ./openssl_signing_console.rst
|
||||||
|
|
||||||
|
|
||||||
|
# Generate a key and extract the public part
|
||||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
||||||
openssl ec -in private.pem -pubout -out public.pem
|
openssl ec -in private.pem -pubout -out public.pem
|
||||||
|
|
||||||
|
# Use test data
|
||||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
||||||
|
|
||||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
||||||
|
|
||||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
|
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
|
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||||
|
|
||||||
signing_input="$header_b64.$payload_b64"
|
signing_input="$header_b64.$payload_b64"
|
||||||
|
|
||||||
echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem -out signature.bin
|
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem | openssl base64 -e)
|
||||||
|
|
||||||
# Read binary signature from file and encode it to Base64 URL-Safe format
|
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
|
||||||
signature_b64=$(base64 -w 0 < signature.bin | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Read binary signature and encode it to Base64 URL-Safe format
|
||||||
|
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||||
|
|
||||||
# Construct the JWT
|
# Construct the JWT
|
||||||
jwt="$signing_input.$signature_b64"
|
jwt="$signing_input.$signature_b64"
|
||||||
|
|
||||||
openssl dgst -sha256 -verify public.pem -signature signature.bin -out verified.txt <(echo -n "$signing_input")
|
echo Resulting JWT: $jwt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12889
package-lock.json
generated
83
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kickstart-for-time-pwa",
|
"name": "TimeSafari_Test",
|
||||||
"version": "0.1.0",
|
"version": "0.2.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
@@ -8,59 +8,68 @@
|
|||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dicebear/collection": "^5.3.5",
|
||||||
|
"@dicebear/core": "^5.3.5",
|
||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||||
"@tweenjs/tween.js": "^21.0.0",
|
"@tweenjs/tween.js": "^21.0.0",
|
||||||
"@veramo/core": "^5.2.0",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@veramo/credential-w3c": "^5.2.0",
|
"@veramo/core": "^5.4.1",
|
||||||
"@veramo/data-store": "^5.2.0",
|
"@veramo/credential-w3c": "^5.4.1",
|
||||||
"@veramo/did-manager": "^5.1.2",
|
"@veramo/data-store": "^5.4.1",
|
||||||
"@veramo/did-provider-ethr": "^5.1.2",
|
"@veramo/did-manager": "^5.4.1",
|
||||||
"@veramo/did-resolver": "^5.2.0",
|
"@veramo/did-provider-ethr": "^5.4.1",
|
||||||
"@veramo/key-manager": "^5.1.2",
|
"@veramo/did-resolver": "^5.4.1",
|
||||||
"@vueuse/core": "^10.2.1",
|
"@veramo/key-manager": "^5.4.1",
|
||||||
|
"@vueuse/core": "^10.4.1",
|
||||||
"@zxing/text-encoding": "^0.9.0",
|
"@zxing/text-encoding": "^0.9.0",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.5.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"core-js": "^3.31.1",
|
"core-js": "^3.32.1",
|
||||||
"dexie": "^3.2.4",
|
"dexie": "^3.2.4",
|
||||||
"dexie-export-import": "^4.0.7",
|
"dexie-export-import": "^4.0.7",
|
||||||
"did-jwt": "^7.2.4",
|
"did-jwt": "^7.2.7",
|
||||||
"ethereum-cryptography": "^2.0.0",
|
"ethereum-cryptography": "^2.1.2",
|
||||||
"ethereumjs-util": "^7.1.5",
|
"ethereumjs-util": "^7.1.5",
|
||||||
"ethr-did-resolver": "^8.0.0",
|
"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",
|
||||||
"localstorage-slim": "^2.4.0",
|
"js-yaml": "^4.1.0",
|
||||||
"luxon": "^3.3.0",
|
"localstorage-slim": "^2.5.0",
|
||||||
"merkletreejs": "^0.3.10",
|
"luxon": "^3.4.3",
|
||||||
|
"merkletreejs": "^0.3.11",
|
||||||
"moment": "^2.29.4",
|
"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.1.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
"qr-code-generator-vue3": "^1.4.21",
|
"qr-code-generator-vue3": "^1.4.21",
|
||||||
"ramda": "^0.29.0",
|
"ramda": "^0.29.0",
|
||||||
"readable-stream": "^4.4.2",
|
"readable-stream": "^4.4.2",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"three": "^0.154.0",
|
"three": "^0.156.1",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
|
"util": "^0.12.5",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-axios": "^3.5.2",
|
"vue-axios": "^3.5.2",
|
||||||
"vue-facing-decorator": "^2.1.20",
|
"vue-facing-decorator": "^3.0.2",
|
||||||
"vue-property-decorator": "^9.1.2",
|
"vue-qrcode-reader": "^5.4.1",
|
||||||
"vue-router": "^4.2.3",
|
"vue-router": "^4.2.4",
|
||||||
"web-did-resolver": "^2.0.27"
|
"web-did-resolver": "^2.0.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.4",
|
||||||
"@types/ramda": "^0.29.3",
|
"@types/ramda": "^0.29.3",
|
||||||
"@types/three": "^0.152.1",
|
"@types/three": "^0.155.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@typescript-eslint/parser": "^5.61.0",
|
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||||
|
"@typescript-eslint/parser": "^6.6.0",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"@vue/cli-plugin-babel": "~5.0.8",
|
"@vue/cli-plugin-babel": "~5.0.8",
|
||||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||||
@@ -70,15 +79,15 @@
|
|||||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||||
"@vue/cli-service": "~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.14",
|
"autoprefixer": "^10.4.15",
|
||||||
"eslint": "^8.44.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0-alpha.1",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"eslint-plugin-vue": "^9.15.1",
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"postcss": "^8.4.24",
|
"postcss": "^8.4.29",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.1.0",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "~5.1.6"
|
"typescript": "~5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,74 @@
|
|||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
|
|
||||||
- .2 bug - on contacts view, click on "to" & "from" and nothing happens
|
|
||||||
- 01 add a location for a project via map pin :
|
|
||||||
- add with a "location" field containing this: { "geo":{ "@type":"GeoCoordinates", "latitude":40.883944, "longitude":-111.884787 } }
|
|
||||||
- 40 notifications :
|
|
||||||
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
|
|
||||||
|
|
||||||
- 01 add a location for a project via map pin
|
- anchor hash into BTC
|
||||||
- 04 search by a bounding box for local projects (see API by clicking on "Nearby")
|
- prompt for the name directly when they visit the QR scan page
|
||||||
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
|
- bug - user on new phone did not prompt him to install
|
||||||
- 02 Fix images on projectview - allow choice of image from a pallete of images or a url image (discovery page display also)
|
- image on give
|
||||||
- SEE: https://github.com/dmester/jdenticon assignee:jose
|
- 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
|
||||||
|
- put the image URL in the claim
|
||||||
|
- Rates - images erased?
|
||||||
|
- image not associated with JWT ULID since that's assigned later
|
||||||
|
- mark a project as inactive
|
||||||
|
- allow to see and reset DB password
|
||||||
|
- add share button for sending a message to confirmers when we can't see the claim (like the "visible" links)
|
||||||
|
- add TimeSafari as a shareable app https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
|
||||||
|
- 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
|
||||||
|
|
||||||
- 08 Scan QR code to import into contacts assignee:matthew
|
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.)
|
||||||
- SEE: https://github.com/gruhn/vue-qrcode-reader
|
- 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 pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
|
- 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
|
||||||
|
|
||||||
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) assignee-group:ui
|
- show feed of offers, new projects, etc -- maybe limited to my search area
|
||||||
- SEE: https://github.com/emmanuelsw/notiwind assignee:jose assignee-group:ui
|
|
||||||
|
|
||||||
- Home Feed & Quick Give screen :
|
- revenue to support server operation
|
||||||
- 01 save the feed-viewed status in settings storage ("afterQuery")
|
|
||||||
- 01 quick action - send action, maybe choose via canvas tool
|
|
||||||
- SEE: https://github.com/konvajs/vue-konva
|
|
||||||
|
|
||||||
- 24 Move to Vite assignee:matthew
|
- 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
|
||||||
|
|
||||||
- .5 include the hash of the latest commit, and maybe a version
|
- .1 update "offer" units to have same functionality as "give" units
|
||||||
- .5 add link to further project / people when a project pays ahead
|
- 01 on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed)
|
||||||
- .5 add project ID to the URL, to make a project publicly-accessible
|
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list)
|
||||||
- .5 remove edit from project page for projects owned by others
|
- bug (that is hard to reproduce) - got error adding on Firefox user #0 as contact for themselves
|
||||||
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
|
- 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"
|
||||||
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
|
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
|
||||||
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
|
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
|
||||||
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
|
- 04 look at other examples for better onboarding UI, eg friend.tech
|
||||||
- .2 move 'switch identity' to the advanced section assignee-group:ui
|
- .5 Add inactive flag / end date, start date to project
|
||||||
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
|
- .3 check that Android shows "back" buttons on screens without bottom tray
|
||||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
- .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?)
|
||||||
- Discuss whether the remaining tasks are worthwhile before MVP release.
|
|
||||||
|
|
||||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
|
||||||
- .5 change the derivation path, and regenerate test IDs
|
|
||||||
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
|
|
||||||
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
|
|
||||||
- .5 customize favicon assignee-group:ui
|
|
||||||
- .5 Do we want to combine first name & last name?
|
|
||||||
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
|
|
||||||
- 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
|
- 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 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+ :
|
- contacts v+ :
|
||||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||||
@@ -59,20 +77,23 @@ tasks:
|
|||||||
|
|
||||||
- stats v1 :
|
- stats v1 :
|
||||||
- 01 show numeric stats
|
- 01 show numeric stats
|
||||||
|
- 04 show different graphic for projects vs people (gnome?) on world
|
||||||
- 01 link to world for specific stats
|
- 01 link to world for specific stats
|
||||||
- .5 don't load another instance of a bush if it already exists
|
- .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")
|
- 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)
|
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||||
|
|
||||||
- Release Minimum Viable Product :
|
- .5 show seed phrase in a QR code for transfer to another device
|
||||||
- 08 thorough testing for errors & edge cases
|
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
|
||||||
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
|
- .5 don't show "Offer" on project screen if they aren't registered
|
||||||
- Add disclaimers.
|
|
||||||
- Switch default server to the public server.
|
- 24 Move to Vite
|
||||||
- Deploy to a server.
|
- 32 accept images for projects
|
||||||
- Ensure public server has limits that work for group adoption.
|
- 32 accept images for contacts
|
||||||
- Test PWA features on Android and iOS.
|
- import project interactions from GitHub/GitLab and manage signing
|
||||||
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
|
|
||||||
|
- show total time offered to & fulfilled to a project
|
||||||
|
- show total time offered by & fulfilled by a contact
|
||||||
|
|
||||||
- linking between projects or plans :
|
- linking between projects or plans :
|
||||||
- show total time given to & from a project
|
- show total time given to & from a project
|
||||||
@@ -80,6 +101,9 @@ tasks:
|
|||||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
- 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)
|
- 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 :
|
- Stats :
|
||||||
- 01 point out user's location on the world
|
- 01 point out user's location on the world
|
||||||
- 01 present a credential selected from the stats
|
- 01 present a credential selected from the stats
|
||||||
@@ -91,21 +115,25 @@ tasks:
|
|||||||
- automated tests, eg. cypress
|
- automated tests, eg. cypress
|
||||||
|
|
||||||
- Notifications (wake on the phone, push notifications)
|
- 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
|
||||||
|
|
||||||
- Connect with phone contacts
|
- Connect with phone contacts
|
||||||
|
|
||||||
- Multiple identities
|
- Multiple identities
|
||||||
|
|
||||||
- Peer DID
|
- Support KERI AIDs
|
||||||
|
- Support Peer DIDs
|
||||||
|
- Support messaging through DIDComm
|
||||||
|
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
|
||||||
|
|
||||||
- DIDComm
|
- 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.
|
||||||
|
- 16 From the home screen, make the quick action even easier.
|
||||||
|
|
||||||
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
|
- 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
|
||||||
|
- .1 When Chrome shows compatibility https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||||
- Do we want split first name & last name?
|
then change the canShare check in this app to check the real canShare() method.
|
||||||
|
|
||||||
- 40 notifications v+ :
|
|
||||||
- pull, w/ scheduled runs
|
|
||||||
|
|
||||||
log:
|
log:
|
||||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 37 KiB |
463
src/App.vue
@@ -156,28 +156,101 @@
|
|||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
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">
|
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||||
<p class="text-lg mb-4">
|
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
||||||
Would you like to turn on notifications for this app?
|
Would you like to <b>turn on</b> notifications for this app?
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else class="text-lg mb-4">
|
||||||
|
Waiting for system initialization, which may take up to 10
|
||||||
|
seconds...
|
||||||
|
<fa icon="spinner" spin />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="serviceWorkerReady"
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
@click="
|
||||||
|
close(notification.id);
|
||||||
|
turnOnNotifications();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Turn on Notifications
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Maybe Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="notification.type === 'notification-mute'"
|
||||||
|
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">
|
||||||
|
<p class="text-lg mb-4">Mute app notifications:</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
Turn on Notifications
|
For 1 Hour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
For 8 Hours
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
For 24 Hours
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
Until I turn it back on
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="notification.type === 'notification-off'"
|
||||||
|
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">
|
||||||
|
<p class="text-lg mb-4">
|
||||||
|
Would you like to <b>turn off</b> notifications for this app?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
close(notification.id);
|
||||||
|
turnOffNotifications();
|
||||||
|
"
|
||||||
|
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
Turn Off Notifications
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Leave it On
|
||||||
</button>
|
</button>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
@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"
|
|
||||||
>
|
|
||||||
Maybe Later
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Never
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,4 +262,360 @@
|
|||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
||||||
<script lang="ts"></script>
|
<script lang="ts">
|
||||||
|
import { Vue, Component } from "vue-facing-decorator";
|
||||||
|
import axios from "axios";
|
||||||
|
interface ServiceWorkerMessage {
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceWorkerResponse {
|
||||||
|
// Define the properties and their types
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example interface for error
|
||||||
|
interface ErrorResponse {
|
||||||
|
message: string;
|
||||||
|
// Other properties as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VapidResponse {
|
||||||
|
data: {
|
||||||
|
vapidKey: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { sendTestThroughPushServer } from "@/libs/util";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class App extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
b64 = "";
|
||||||
|
serviceWorkerReady = false;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||||
|
if (settings?.webPushServer) {
|
||||||
|
pushUrl = settings.webPushServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios
|
||||||
|
.get(pushUrl + "/web-push/vapid")
|
||||||
|
.then((response: VapidResponse) => {
|
||||||
|
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) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Setting Notifications",
|
||||||
|
text: "Could not set notifications.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (window.location.host.startsWith("localhost")) {
|
||||||
|
console.log("Ignoring the error getting VAPID for local development.");
|
||||||
|
} else {
|
||||||
|
console.error("Got an error initializing notifications:", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Setting Notifications",
|
||||||
|
text: "Got an error setting notifications.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// there may be a long pause here on first initialization
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
this.serviceWorkerReady = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendMessageToServiceWorker(
|
||||||
|
message: ServiceWorkerMessage,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
|
messageChannel.port1.onmessage = (event: MessageEvent) => {
|
||||||
|
if (event.data.error) {
|
||||||
|
reject(event.data.error as ErrorResponse);
|
||||||
|
} else {
|
||||||
|
resolve(event.data as ServiceWorkerResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage(message, [
|
||||||
|
messageChannel.port2,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
reject("Service worker controller not available");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private askPermission(): Promise<NotificationPermission> {
|
||||||
|
console.log("Requesting permission for notifications:", navigator);
|
||||||
|
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
|
||||||
|
return Promise.reject("Service worker not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = localStorage.getItem("secret");
|
||||||
|
if (!secret) {
|
||||||
|
return Promise.reject("No secret found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendSecretToServiceWorker(secret)
|
||||||
|
.then(() => this.checkNotificationSupport())
|
||||||
|
.then(() => this.requestNotificationPermission())
|
||||||
|
.catch((error) => Promise.reject(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendSecretToServiceWorker(secret: string): Promise<void> {
|
||||||
|
const message: ServiceWorkerMessage = {
|
||||||
|
type: "SEND_LOCAL_DATA",
|
||||||
|
data: secret,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendMessageToServiceWorker(message).then((response) => {
|
||||||
|
console.log("Response from service worker:", response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkNotificationSupport(): Promise<void> {
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
alert("This browser does not support notifications.");
|
||||||
|
return Promise.reject("This browser does not support notifications.");
|
||||||
|
}
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestNotificationPermission(): Promise<NotificationPermission> {
|
||||||
|
return Notification.requestPermission().then((permission) => {
|
||||||
|
if (permission !== "granted") {
|
||||||
|
alert(
|
||||||
|
"Allow this app permission to make notifications for personal reminders." +
|
||||||
|
" You can adjust them at any time in your settings.",
|
||||||
|
);
|
||||||
|
throw new Error("We weren't granted permission.");
|
||||||
|
}
|
||||||
|
return permission;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async turnOnNotifications() {
|
||||||
|
return this.askPermission()
|
||||||
|
.then((permission) => {
|
||||||
|
console.log("Permission granted:", permission);
|
||||||
|
|
||||||
|
// Call the function and handle promises
|
||||||
|
this.subscribeToPush()
|
||||||
|
.then(() => {
|
||||||
|
console.log("Subscribed successfully.");
|
||||||
|
return navigator.serviceWorker.ready;
|
||||||
|
})
|
||||||
|
.then((registration) => {
|
||||||
|
return registration.pushManager.getSubscription();
|
||||||
|
})
|
||||||
|
.then(async (subscription) => {
|
||||||
|
if (subscription) {
|
||||||
|
await this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Notification Setup Underway",
|
||||||
|
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
this.sendSubscriptionToServer(subscription);
|
||||||
|
return subscription;
|
||||||
|
} else {
|
||||||
|
throw new Error("Subscription object is not available.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(async (subscription) => {
|
||||||
|
console.log(
|
||||||
|
"Subscription data sent to server and all finished successfully.",
|
||||||
|
);
|
||||||
|
await sendTestThroughPushServer(subscription, true);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Notifications Turned On",
|
||||||
|
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"Subscription or server communication failed:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
alert(
|
||||||
|
"Subscription or server communication failed. Try again in a while.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"An error occurred setting notification permissions:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
alert("Some error occurred setting notification permissions.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding)
|
||||||
|
.replace(/-/g, "+")
|
||||||
|
.replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeToPush(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!("serviceWorker" in navigator && "PushManager" in window)) {
|
||||||
|
const errorMsg = "Push messaging is not supported";
|
||||||
|
console.warn(errorMsg);
|
||||||
|
return reject(new Error(errorMsg));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== "granted") {
|
||||||
|
const errorMsg = "Notification permission not granted";
|
||||||
|
console.warn(errorMsg);
|
||||||
|
return reject(new Error(errorMsg));
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
|
||||||
|
const options: PushSubscriptionOptions = {
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: applicationServerKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
return registration.pushManager.subscribe(options);
|
||||||
|
})
|
||||||
|
.then((subscription) => {
|
||||||
|
console.log("Push subscription successful:", subscription);
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Push subscription failed:", error, options);
|
||||||
|
|
||||||
|
// Inform the user about the issue
|
||||||
|
alert(
|
||||||
|
"We encountered an issue setting up push notifications. " +
|
||||||
|
"If you wish to revoke notification permissions, please do so in your browser settings.",
|
||||||
|
);
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendSubscriptionToServer(
|
||||||
|
subscription: PushSubscription,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log("About to send subscription...", subscription);
|
||||||
|
return fetch("/web-push/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to send subscription to server");
|
||||||
|
}
|
||||||
|
console.log("Subscription sent to server successfully.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async turnOffNotifications() {
|
||||||
|
let subscription;
|
||||||
|
const pushProviderSuccess = await navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
return registration.pushManager.getSubscription();
|
||||||
|
})
|
||||||
|
.then((subscript) => {
|
||||||
|
subscription = subscript;
|
||||||
|
if (subscription) {
|
||||||
|
return subscription.unsubscribe();
|
||||||
|
} else {
|
||||||
|
console.log("Subscription object is not available.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Push provider server communication failed:", error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
return response.ok;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Push server communication failed:", error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
alert(
|
||||||
|
"Notifications are off. Push provider unsubscribe " +
|
||||||
|
(pushProviderSuccess ? "succeeded" : "failed") +
|
||||||
|
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
|
||||||
|
" push server unsubscribe " +
|
||||||
|
(pushServerSuccess ? "succeeded" : "failed") +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
3
src/assets/blank-square.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" fill="#ffffff"></rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 145 B |
BIN
src/assets/help/apple-icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
3
src/assets/help/apple-share-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" enable-background="new 0 0 50 50">
|
||||||
|
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/><path d="M24 7h2v21h-2z"/><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 365 B |
BIN
src/assets/help/chrome-install-pwa.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
27
src/assets/help/creative-commons-circle.svg
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<circle fill="#FFFFFF" cx="37.785" cy="28.501" r="28.836"/>
|
||||||
|
<path d="M37.441-3.5c8.951,0,16.572,3.125,22.857,9.372c3.008,3.009,5.295,6.448,6.857,10.314
|
||||||
|
c1.561,3.867,2.344,7.971,2.344,12.314c0,4.381-0.773,8.486-2.314,12.313c-1.543,3.828-3.82,7.21-6.828,10.143
|
||||||
|
c-3.123,3.085-6.666,5.448-10.629,7.086c-3.961,1.638-8.057,2.457-12.285,2.457s-8.276-0.808-12.143-2.429
|
||||||
|
c-3.866-1.618-7.333-3.961-10.4-7.027c-3.067-3.066-5.4-6.524-7-10.372S5.5,32.767,5.5,28.5c0-4.229,0.809-8.295,2.428-12.2
|
||||||
|
c1.619-3.905,3.972-7.4,7.057-10.486C21.08-0.394,28.565-3.5,37.441-3.5z M37.557,2.272c-7.314,0-13.467,2.553-18.458,7.657
|
||||||
|
c-2.515,2.553-4.448,5.419-5.8,8.6c-1.354,3.181-2.029,6.505-2.029,9.972c0,3.429,0.675,6.734,2.029,9.913
|
||||||
|
c1.353,3.183,3.285,6.021,5.8,8.516c2.514,2.496,5.351,4.399,8.515,5.715c3.161,1.314,6.476,1.971,9.943,1.971
|
||||||
|
c3.428,0,6.75-0.665,9.973-1.999c3.219-1.335,6.121-3.257,8.713-5.771c4.99-4.876,7.484-10.99,7.484-18.344
|
||||||
|
c0-3.543-0.648-6.895-1.943-10.057c-1.293-3.162-3.18-5.98-5.654-8.458C50.984,4.844,44.795,2.272,37.557,2.272z M37.156,23.187
|
||||||
|
l-4.287,2.229c-0.458-0.951-1.019-1.619-1.685-2c-0.667-0.38-1.286-0.571-1.858-0.571c-2.856,0-4.286,1.885-4.286,5.657
|
||||||
|
c0,1.714,0.362,3.084,1.085,4.113c0.724,1.029,1.791,1.544,3.201,1.544c1.867,0,3.181-0.915,3.944-2.743l3.942,2
|
||||||
|
c-0.838,1.563-2,2.791-3.486,3.686c-1.484,0.896-3.123,1.343-4.914,1.343c-2.857,0-5.163-0.875-6.915-2.629
|
||||||
|
c-1.752-1.752-2.628-4.19-2.628-7.313c0-3.048,0.886-5.466,2.657-7.257c1.771-1.79,4.009-2.686,6.715-2.686
|
||||||
|
C32.604,18.558,35.441,20.101,37.156,23.187z M55.613,23.187l-4.229,2.229c-0.457-0.951-1.02-1.619-1.686-2
|
||||||
|
c-0.668-0.38-1.307-0.571-1.914-0.571c-2.857,0-4.287,1.885-4.287,5.657c0,1.714,0.363,3.084,1.086,4.113
|
||||||
|
c0.723,1.029,1.789,1.544,3.201,1.544c1.865,0,3.18-0.915,3.941-2.743l4,2c-0.875,1.563-2.057,2.791-3.541,3.686
|
||||||
|
c-1.486,0.896-3.105,1.343-4.857,1.343c-2.896,0-5.209-0.875-6.941-2.629c-1.736-1.752-2.602-4.19-2.602-7.313
|
||||||
|
c0-3.048,0.885-5.466,2.658-7.257c1.77-1.79,4.008-2.686,6.713-2.686C51.117,18.558,53.938,20.101,55.613,23.187z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
24
src/assets/help/creative-commons-zero.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="64px" height="64px" viewBox="-0.5 0.5 64 64" enable-background="new -0.5 0.5 64 64" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<circle fill="#FFFFFF" cx="31.325" cy="32.873" r="30.096"/>
|
||||||
|
<path id="text2809_1_" d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42
|
||||||
|
c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157
|
||||||
|
c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z
|
||||||
|
M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136
|
||||||
|
c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"/>
|
||||||
|
<path id="path2815_1_" d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532
|
||||||
|
C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396
|
||||||
|
c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465
|
||||||
|
c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5
|
||||||
|
c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259
|
||||||
|
c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061
|
||||||
|
c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994
|
||||||
|
c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516
|
||||||
|
c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628
|
||||||
|
C18.092,8.818,24.252,6.259,31.567,6.259z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/help/install-android-chrome.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/help/mac-installed-app-settings.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/assets/help/windows-system-enable-notifications.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 85 KiB |
@@ -1,47 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-bind:class="computedAlertClassNames()">
|
|
||||||
<button
|
|
||||||
class="close-button bg-amber-400 w-8 leading-loose rounded-full absolute top-2 right-2"
|
|
||||||
@click="onClickClose()"
|
|
||||||
>
|
|
||||||
<fa icon="xmark"></fa>
|
|
||||||
</button>
|
|
||||||
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
|
|
||||||
<p>{{ alertMessage }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class AlertMessage extends Vue {
|
|
||||||
@Prop alertTitle = "";
|
|
||||||
@Prop alertMessage = "";
|
|
||||||
isAlertVisible = this.alertMessage;
|
|
||||||
|
|
||||||
public onClickClose() {
|
|
||||||
this.isAlertVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public computedAlertClassNames() {
|
|
||||||
return {
|
|
||||||
hidden: !this.isAlertVisible,
|
|
||||||
"dismissable-alert": true,
|
|
||||||
"bg-amber-200": true,
|
|
||||||
"p-5": true,
|
|
||||||
rounded: true,
|
|
||||||
"drop-shadow-lg": true,
|
|
||||||
fixed: true,
|
|
||||||
"top-3": true,
|
|
||||||
"inset-x-3": true,
|
|
||||||
"transition-transform": true,
|
|
||||||
"ease-in": true,
|
|
||||||
"duration-300": true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-html="generateIdenticon()" class="w-fit"></div>
|
<div v-html="generateIcon()" class="w-fit"></div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||||
|
import { avataaars } from "@dicebear/collection";
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
import { toSvg } from "jdenticon";
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class EntityIcon extends Vue {
|
export default class EntityIcon extends Vue {
|
||||||
@Prop entityId = "";
|
@Prop entityId = "";
|
||||||
@Prop iconSize = "";
|
@Prop iconSize = 0;
|
||||||
|
|
||||||
generateIdenticon() {
|
generateIcon() {
|
||||||
const svgString = toSvg(this.entityId, this.iconSize);
|
const options: StyleOptions<object> = {
|
||||||
|
seed: this.entityId || "",
|
||||||
|
size: this.iconSize,
|
||||||
|
};
|
||||||
|
const avatar = createAvatar(avataaars, options);
|
||||||
|
const svgString = avatar.toString();
|
||||||
return svgString;
|
return svgString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<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 specified" }}
|
{{ message }} {{ giver?.name || "somebody not named" }}
|
||||||
</h1>
|
</h1>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -10,21 +10,24 @@
|
|||||||
placeholder="What was received"
|
placeholder="What was received"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row mb-6">
|
<div class="flex flex-row">
|
||||||
<span
|
<span
|
||||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
|
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"
|
||||||
>Hours</span
|
@click="changeUnitCode()"
|
||||||
>
|
>
|
||||||
|
{{ libsUtil.UNIT_SHORT[unitCode] }}
|
||||||
|
</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="decrement()"
|
||||||
|
v-if="amountInput !== '0'"
|
||||||
>
|
>
|
||||||
<fa icon="chevron-left" />
|
<fa icon="chevron-left" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||||
v-model="hours"
|
v-model="amountInput"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||||
@@ -33,7 +36,19 @@
|
|||||||
<fa icon="chevron-right" />
|
<fa icon="chevron-right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
|
<div class="mt-2 text-right">
|
||||||
|
<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>
|
||||||
|
<input type="checkbox" class="mr-2" v-model="isTrade" />
|
||||||
|
<label class="text-sm">Trade (not a gift)</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-center mb-2 mt-6 italic">
|
||||||
|
Sign & Send to publish to the world
|
||||||
|
</p>
|
||||||
<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"
|
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="confirm"
|
@click="confirm"
|
||||||
@@ -51,56 +66,289 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Vue, Component, Prop, Emit } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
import {
|
||||||
|
createAndSubmitGive,
|
||||||
|
didInfo,
|
||||||
|
GiverInputInfo,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class GiftedDialog extends Vue {
|
export default class GiftedDialog extends Vue {
|
||||||
@Prop message = "";
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
giver = null;
|
@Prop message = "";
|
||||||
|
@Prop projectId = "";
|
||||||
|
@Prop showGivenToUser = false;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
allMyDids: Array<string> = [];
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
|
amountInput = "0";
|
||||||
|
giver?: GiverInputInfo; // undefined means no identified giver agent
|
||||||
description = "";
|
description = "";
|
||||||
hours = "0";
|
givenToUser = false;
|
||||||
|
isTrade = false;
|
||||||
|
offerId = "";
|
||||||
|
unitCode = "HUR";
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
open(giver) {
|
libsUtil = libsUtil;
|
||||||
// giver: GiverInputInfo
|
|
||||||
this.giver = giver;
|
async open(giver?: GiverInputInfo, offerId?: string) {
|
||||||
|
this.description = "";
|
||||||
|
this.giver = giver || {};
|
||||||
|
if (!this.giver.name) {
|
||||||
|
this.giver.name = didInfo(
|
||||||
|
this.giver.did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// if we show "given to user" selection, default checkbox to true
|
||||||
|
this.givenToUser = this.showGivenToUser;
|
||||||
|
this.amountInput = "0";
|
||||||
|
this.offerId = offerId || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
|
||||||
|
this.allContacts = await db.contacts.toArray();
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
|
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
// close the dialog but don't change values (since it might be submitting info)
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeUnitCode() {
|
||||||
|
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||||
|
const index = units.indexOf(this.unitCode);
|
||||||
|
this.unitCode = units[(index + 1) % units.length];
|
||||||
|
}
|
||||||
|
|
||||||
increment() {
|
increment() {
|
||||||
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
|
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
decrement() {
|
decrement() {
|
||||||
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
|
this.amountInput = `${Math.max(
|
||||||
|
0,
|
||||||
|
(parseFloat(this.amountInput) || 1) - 1,
|
||||||
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit("dialog-result")
|
|
||||||
confirm() {
|
|
||||||
const result = {
|
|
||||||
action: "confirm",
|
|
||||||
giver: this.giver,
|
|
||||||
hours: parseFloat(this.hours),
|
|
||||||
description: this.description,
|
|
||||||
};
|
|
||||||
this.close();
|
|
||||||
this.description = "";
|
|
||||||
this.giver = null;
|
|
||||||
this.hours = "0";
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Emit("dialog-result")
|
|
||||||
cancel() {
|
cancel() {
|
||||||
const result = { action: "cancel" };
|
|
||||||
this.close();
|
this.close();
|
||||||
return result;
|
this.eraseValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
eraseValues() {
|
||||||
|
this.description = "";
|
||||||
|
this.giver = undefined;
|
||||||
|
this.givenToUser = this.showGivenToUser;
|
||||||
|
this.amountInput = "0";
|
||||||
|
this.unitCode = "HUR";
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm() {
|
||||||
|
this.close();
|
||||||
|
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(
|
||||||
|
(this.giver?.did as string) || null,
|
||||||
|
this.description,
|
||||||
|
parseFloat(this.amountInput),
|
||||||
|
this.unitCode,
|
||||||
|
).then(() => {
|
||||||
|
this.eraseValues();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string) {
|
||||||
|
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 Give records for DID ${activeDid} but no identifier was found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @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(
|
||||||
|
giverDid: string | null,
|
||||||
|
description: string,
|
||||||
|
amountInput: number,
|
||||||
|
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 {
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
const result = await createAndSubmitGive(
|
||||||
|
this.axios,
|
||||||
|
this.apiServer,
|
||||||
|
identity,
|
||||||
|
giverDid,
|
||||||
|
this.givenToUser ? this.activeDid : undefined,
|
||||||
|
description,
|
||||||
|
amountInput,
|
||||||
|
unitCode,
|
||||||
|
this.projectId,
|
||||||
|
this.offerId,
|
||||||
|
this.isTrade,
|
||||||
|
);
|
||||||
|
|
||||||
|
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.`,
|
||||||
|
},
|
||||||
|
7000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error with give recordation caught:", error);
|
||||||
|
const message =
|
||||||
|
error.userMessage ||
|
||||||
|
error.response?.data?.error?.message ||
|
||||||
|
"There was an error recording the give.";
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
-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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
340
src/components/OfferDialog.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||||
|
placeholder="Description, prerequisites, terms, etc."
|
||||||
|
v-model="description"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-row mt-2">
|
||||||
|
<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"
|
||||||
|
@click="changeUnitCode()"
|
||||||
|
>
|
||||||
|
{{ libsUtil.UNIT_SHORT[amountUnitCode] }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||||
|
@click="decrement()"
|
||||||
|
v-if="amountInput !== '0'"
|
||||||
|
>
|
||||||
|
<fa icon="chevron-left" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||||
|
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 flex-row mt-2">
|
||||||
|
<span
|
||||||
|
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||||
|
>
|
||||||
|
Expiration
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||||
|
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
|
||||||
|
v-model="expirationDateInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-center mt-6 mb-2 italic">
|
||||||
|
Sign & Send to publish to the world
|
||||||
|
</p>
|
||||||
|
<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="confirm"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
import { createAndSubmitOffer } from "@/libs/endorserServer";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class OfferDialog extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
@Prop message = "";
|
||||||
|
@Prop projectId = "";
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
|
amountInput = "0";
|
||||||
|
amountUnitCode = "HUR";
|
||||||
|
description = "";
|
||||||
|
expirationDateInput = "";
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// close the dialog but don't change values (since it might be submitting info)
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeUnitCode() {
|
||||||
|
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||||
|
const index = units.indexOf(this.amountUnitCode);
|
||||||
|
this.amountUnitCode = 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.close();
|
||||||
|
this.eraseValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
eraseValues() {
|
||||||
|
this.description = "";
|
||||||
|
this.amountInput = "0";
|
||||||
|
this.amountUnitCode = "HUR";
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm() {
|
||||||
|
this.close();
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
text: "Recording the offer...",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
// this is asynchronous, but we don't need to wait for it to complete
|
||||||
|
this.recordOffer(
|
||||||
|
this.description,
|
||||||
|
parseFloat(this.amountInput),
|
||||||
|
this.amountUnitCode,
|
||||||
|
this.expirationDateInput,
|
||||||
|
).then(() => {
|
||||||
|
this.description = "";
|
||||||
|
this.amountInput = "0";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string) {
|
||||||
|
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 Offer records for DID ${activeDid} but no identifier was found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param description may be an empty string
|
||||||
|
* @param hours may be 0
|
||||||
|
* @param unitCode may be omitted, defaults to "HUR"
|
||||||
|
*/
|
||||||
|
public async recordOffer(
|
||||||
|
description: string,
|
||||||
|
amount: number,
|
||||||
|
unitCode: string = "HUR",
|
||||||
|
expirationDateInput?: string,
|
||||||
|
) {
|
||||||
|
if (!this.activeDid) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "You must select an identifier before you can record an offer.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description && !amount) {
|
||||||
|
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 {
|
||||||
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
|
const result = await createAndSubmitOffer(
|
||||||
|
this.axios,
|
||||||
|
this.apiServer,
|
||||||
|
identity,
|
||||||
|
description,
|
||||||
|
amount,
|
||||||
|
unitCode,
|
||||||
|
expirationDateInput,
|
||||||
|
this.projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.type === "error" ||
|
||||||
|
this.isOfferCreationError(result.response)
|
||||||
|
) {
|
||||||
|
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||||
|
console.log("Error with offer creation result:", result);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: errorMessage || "There was an error creating the offer.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "That offer was recorded.",
|
||||||
|
},
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log("Error with offer recordation caught:", error);
|
||||||
|
const message =
|
||||||
|
error.userMessage ||
|
||||||
|
error.response?.data?.error?.message ||
|
||||||
|
"There was an error recording the offer.";
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
-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
|
||||||
|
isOfferCreationError(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
|
||||||
|
getOfferCreationErrorMessage(result: any) {
|
||||||
|
return (
|
||||||
|
result.error?.userMessage ||
|
||||||
|
result.error?.error ||
|
||||||
|
result.response?.data?.error?.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/components/ProjectIcon.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div v-html="generateIdenticon()" class="w-fit"></div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { toSvg } from "jdenticon";
|
||||||
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
const BLANK_CONFIG = {
|
||||||
|
lightness: {
|
||||||
|
color: [1.0, 1.0],
|
||||||
|
grayscale: [1.0, 1.0],
|
||||||
|
},
|
||||||
|
saturation: {
|
||||||
|
color: 0.0,
|
||||||
|
grayscale: 0.0,
|
||||||
|
},
|
||||||
|
backColor: "#0000",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class ProjectIcon extends Vue {
|
||||||
|
@Prop entityId = "";
|
||||||
|
@Prop iconSize = 0;
|
||||||
|
|
||||||
|
generateIdenticon() {
|
||||||
|
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||||
|
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||||
|
return svgString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped></style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- QUICK NAV -->
|
<!-- QUICK NAV -->
|
||||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||||
<ul class="flex text-2xl p-2 gap-2">
|
<ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
|
||||||
<!-- Home Feed -->
|
<!-- Home Feed -->
|
||||||
<li
|
<li
|
||||||
:class="{
|
:class="{
|
||||||
|
|||||||
58
src/components/TopMessage.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center text-red-500">{{ message }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { AppString } from "@/constants/app";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class TopMessage extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
@Prop selected = "";
|
||||||
|
|
||||||
|
message = "";
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
if (
|
||||||
|
settings?.warnIfTestServer &&
|
||||||
|
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||||
|
) {
|
||||||
|
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||||
|
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||||
|
} else if (
|
||||||
|
settings?.warnIfProdServer &&
|
||||||
|
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||||
|
) {
|
||||||
|
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||||
|
this.message =
|
||||||
|
"You're linked to the production server, user " + didPrefix;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Detecting Server",
|
||||||
|
text: JSON.stringify(err),
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Generic strings that could be used throughout the app.
|
* Generic strings that could be used throughout the app.
|
||||||
|
*
|
||||||
|
* See also ../libs/veramo/setup.ts
|
||||||
*/
|
*/
|
||||||
export enum AppString {
|
export enum AppString {
|
||||||
APP_NAME = "Kick-Start with Time",
|
|
||||||
|
|
||||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||||
|
|
||||||
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
|
PROD_PUSH_SERVER = "https://timesafari.app",
|
||||||
|
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||||
|
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER;
|
||||||
|
|
||||||
|
export const DEFAULT_PUSH_SERVER =
|
||||||
|
window.location.protocol + "//" + window.location.host;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The possible values for "group" and "type" are in App.vue.
|
||||||
|
* From the notiwind package
|
||||||
|
*/
|
||||||
|
export interface NotificationIface {
|
||||||
|
group: string; // "alert" | "modal"
|
||||||
|
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,57 @@
|
|||||||
import BaseDexie, { Table } from "dexie";
|
import BaseDexie, { Table } from "dexie";
|
||||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
||||||
import { Account, AccountsSchema } from "./tables/accounts";
|
import { Account, AccountsSchema } from "./tables/accounts";
|
||||||
import { Contact, ContactsSchema } from "./tables/contacts";
|
import { Contact, ContactSchema } from "./tables/contacts";
|
||||||
|
import { Log, LogSchema } from "./tables/logs";
|
||||||
import {
|
import {
|
||||||
MASTER_SETTINGS_KEY,
|
MASTER_SETTINGS_KEY,
|
||||||
Settings,
|
Settings,
|
||||||
SettingsSchema,
|
SettingsSchema,
|
||||||
} from "./tables/settings";
|
} from "./tables/settings";
|
||||||
import { AppString } from "@/constants/app";
|
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||||
|
|
||||||
// a separate DB because the seed is super-sensitive data
|
|
||||||
type SensitiveTables = {
|
|
||||||
accounts: Table<Account>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Define types for tables that hold sensitive and non-sensitive data
|
||||||
|
type SensitiveTables = { accounts: Table<Account> };
|
||||||
type NonsensitiveTables = {
|
type NonsensitiveTables = {
|
||||||
contacts: Table<Contact>;
|
contacts: Table<Contact>;
|
||||||
|
logs: Table<Log>;
|
||||||
settings: Table<Settings>;
|
settings: Table<Settings>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||||
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
|
|
||||||
* https://typescript-eslint.io/rules/no-unnecessary-type-constraint/
|
|
||||||
*
|
|
||||||
* and change *any* to *unknown*
|
|
||||||
*
|
|
||||||
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
|
|
||||||
*/
|
|
||||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
|
||||||
const SensitiveSchemas = Object.assign({}, AccountsSchema);
|
|
||||||
|
|
||||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||||
BaseDexie & T;
|
BaseDexie & T;
|
||||||
|
|
||||||
|
// Initialize Dexie databases for sensitive and non-sensitive data
|
||||||
|
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 = Object.assign({}, ContactsSchema, SettingsSchema);
|
const NonsensitiveSchemas = {
|
||||||
|
...ContactSchema,
|
||||||
|
...LogSchema,
|
||||||
|
...SettingsSchema,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
// Manage the encryption key. If not present in localStorage, create and store it.
|
||||||
* Needed to enable a special webpack setting to allow *await* below:
|
|
||||||
* https://stackoverflow.com/questions/72474803/error-the-top-level-await-experiment-is-not-enabled-set-experiments-toplevelaw
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create password and place password in localStorage.
|
|
||||||
*
|
|
||||||
* It's good practice to keep the data encrypted at rest, so we'll do that even
|
|
||||||
* if the secret is stored right next to the app.
|
|
||||||
*/
|
|
||||||
const secret =
|
const secret =
|
||||||
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
|
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
|
||||||
|
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
||||||
|
|
||||||
if (localStorage.getItem("secret") == null) {
|
// Apply encryption to the sensitive database using the secret key
|
||||||
localStorage.setItem("secret", secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypted(accountsDB, { secretKey: secret });
|
encrypted(accountsDB, { secretKey: secret });
|
||||||
|
|
||||||
|
// Define the schema for our databases
|
||||||
accountsDB.version(1).stores(SensitiveSchemas);
|
accountsDB.version(1).stores(SensitiveSchemas);
|
||||||
|
// v1 was contacts & settings
|
||||||
|
// v2 added logs
|
||||||
|
db.version(2).stores(NonsensitiveSchemas);
|
||||||
|
|
||||||
db.version(1).stores(NonsensitiveSchemas);
|
// Event handler to initialize the non-sensitive database with default settings
|
||||||
|
db.on("populate", () => {
|
||||||
// initialize, a la https://dexie.org/docs/Tutorial/Design#the-populate-event
|
|
||||||
db.on("populate", function () {
|
|
||||||
// ensure there's an initial entry for settings
|
|
||||||
db.settings.add({
|
db.settings.add({
|
||||||
id: MASTER_SETTINGS_KEY,
|
id: MASTER_SETTINGS_KEY,
|
||||||
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,
|
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Represents an account stored in the database.
|
||||||
|
*/
|
||||||
export type Account = {
|
export type Account = {
|
||||||
id?: number; // auto-generated by Dexie
|
/**
|
||||||
|
* Auto-generated ID by Dexie.
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the account was created.
|
||||||
|
*/
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The derivation path for the account.
|
||||||
|
*/
|
||||||
derivationPath: string;
|
derivationPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decentralized Identifier (DID) for the account.
|
||||||
|
*/
|
||||||
did: string;
|
did: string;
|
||||||
// stringified JSON containing underlying key material of type IIdentifier
|
|
||||||
// https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts
|
/**
|
||||||
|
* Stringified JSON containing underlying key material.
|
||||||
|
* Based on the IIdentifier type from Veramo.
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
publicKeyHex: string;
|
publicKeyHex: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mnemonic passphrase for the account.
|
||||||
|
*/
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// mark encrypted field by starting with a $ character
|
/**
|
||||||
// see https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon
|
* Schema for the accounts table in the database.
|
||||||
|
* Fields starting with a $ character are encrypted.
|
||||||
|
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
|
||||||
|
*/
|
||||||
export const AccountsSchema = {
|
export const AccountsSchema = {
|
||||||
accounts:
|
accounts:
|
||||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
export interface Contact {
|
export interface Contact {
|
||||||
did: string;
|
did: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||||
publicKeyBase64?: string;
|
publicKeyBase64?: string;
|
||||||
seesMe?: boolean;
|
seesMe?: boolean;
|
||||||
registered?: boolean;
|
registered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContactsSchema = {
|
export const ContactSchema = {
|
||||||
contacts: "++did, name, publicKeyBase64, registered, seesMe",
|
contacts: "&did, name", // no need to key by other things
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/db/tables/logs.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Log {
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogSchema = {
|
||||||
|
// Currently keyed by "date" because A) today's log data is what we need so we append, and
|
||||||
|
// B) we don't want it to grow so we remove everything if this is the first entry today.
|
||||||
|
// See safari-notifications.js logMessage for the associated logic.
|
||||||
|
logs: "date", // definitely don't key by the potentially large message field
|
||||||
|
};
|
||||||
@@ -1,17 +1,50 @@
|
|||||||
// a singleton
|
/**
|
||||||
export type Settings = {
|
* BoundingBox type describes the geographical bounding box coordinates.
|
||||||
id: number; // there's only one entry: MASTER_SETTINGS_KEY
|
*/
|
||||||
|
export type BoundingBox = {
|
||||||
activeDid?: string;
|
eastLong: number; // Eastern longitude
|
||||||
apiServer?: string;
|
maxLat: number; // Maximum (Northernmost) latitude
|
||||||
firstName?: string;
|
minLat: number; // Minimum (Southernmost) latitude
|
||||||
lastName?: string;
|
westLong: number; // Western longitude
|
||||||
lastViewedClaimId?: string;
|
|
||||||
showContactGivesInline?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings type encompasses user-specific configuration details.
|
||||||
|
*/
|
||||||
|
export type Settings = {
|
||||||
|
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
|
||||||
|
|
||||||
|
activeDid?: string; // Active Decentralized ID
|
||||||
|
apiServer?: string; // API server URL
|
||||||
|
firstName?: string; // User's first name
|
||||||
|
isRegistered?: boolean;
|
||||||
|
lastName?: string; // deprecated - put all names in firstName
|
||||||
|
lastNotifiedClaimId?: string; // Last notified claim ID
|
||||||
|
lastViewedClaimId?: string; // Last viewed claim ID
|
||||||
|
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
||||||
|
reminderOn?: boolean; // Toggle to enable or disable reminders
|
||||||
|
|
||||||
|
// Array of named search boxes defined by bounding boxes
|
||||||
|
searchBoxes?: Array<{
|
||||||
|
name: string;
|
||||||
|
bbox: BoundingBox;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
showContactGivesInline?: boolean; // Display contact inline or not
|
||||||
|
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||||
|
warnIfProdServer?: boolean; // Warn if using a production server
|
||||||
|
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||||
|
webPushServer?: string; // Web Push server URL
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for the Settings table in the database.
|
||||||
|
*/
|
||||||
export const SettingsSchema = {
|
export const SettingsSchema = {
|
||||||
settings: "id",
|
settings: "id",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants.
|
||||||
|
*/
|
||||||
export const MASTER_SETTINGS_KEY = 1;
|
export const MASTER_SETTINGS_KEY = 1;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
|
||||||
import { getRandomBytesSync } from "ethereum-cryptography/random";
|
import { getRandomBytesSync } from "ethereum-cryptography/random";
|
||||||
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
||||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
||||||
@@ -7,7 +6,10 @@ import { HDNode } from "@ethersproject/hdnode";
|
|||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
import * as u8a from "uint8arrays";
|
import * as u8a from "uint8arrays";
|
||||||
|
|
||||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
|
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
|
||||||
|
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||||
|
|
||||||
|
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -150,3 +152,40 @@ export function fromJose(signature: string): {
|
|||||||
export function bytesToHex(b: Uint8Array): string {
|
export function bytesToHex(b: Uint8Array): string {
|
||||||
return u8a.toString(b, "base16");
|
return u8a.toString(b, "base16");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return results of uportJwtPayload:
|
||||||
|
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
|
||||||
|
|
||||||
|
Note that similar code is also contained in time-safari
|
||||||
|
*/
|
||||||
|
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
|
||||||
|
let jwtText = jwtUrlText;
|
||||||
|
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
|
||||||
|
if (endorserContextLoc > -1) {
|
||||||
|
jwtText = jwtText.substring(
|
||||||
|
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT format: { header, payload, signature, data }
|
||||||
|
const jwt = didJwt.decodeJWT(jwtText);
|
||||||
|
|
||||||
|
return jwt.payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nextDerivationPath = (origDerivPath: string) => {
|
||||||
|
let lastStr = origDerivPath.split("/").slice(-1)[0];
|
||||||
|
if (lastStr.endsWith("'")) {
|
||||||
|
lastStr = lastStr.slice(0, -1);
|
||||||
|
}
|
||||||
|
const lastNum = parseInt(lastStr, 10);
|
||||||
|
const newLastNum = lastNum + 1;
|
||||||
|
const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : "");
|
||||||
|
const newDerivPath = origDerivPath
|
||||||
|
.split("/")
|
||||||
|
.slice(0, -1)
|
||||||
|
.concat([newLastStr])
|
||||||
|
.join("/");
|
||||||
|
return newDerivPath;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ import { Axios, AxiosResponse } from "axios";
|
|||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
|
||||||
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
||||||
|
// the object in RegisterAction claims
|
||||||
export const SERVICE_ID = "endorser.ch";
|
export const SERVICE_ID = "endorser.ch";
|
||||||
|
// the header line for contacts exported via Endorser Mobile
|
||||||
|
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
|
||||||
|
// the prefix for the contact URL
|
||||||
|
export const CONTACT_URL_PREFIX = "https://endorser.ch";
|
||||||
|
// the suffix for the contact URL
|
||||||
|
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
|
||||||
|
// the prefix for handle IDs, the permanent ID for claims on Endorser
|
||||||
|
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
|
||||||
|
|
||||||
export interface AgreeVerifiableCredential {
|
export interface AgreeVerifiableCredential {
|
||||||
"@context": string;
|
"@context": string;
|
||||||
@@ -21,19 +30,38 @@ export interface GiverInputInfo {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiverOutputInfo {
|
||||||
|
action: string;
|
||||||
|
giver?: GiverInputInfo;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
unitCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaimResult {
|
export interface ClaimResult {
|
||||||
success: { claimId: string; handleId: string };
|
success: { claimId: string; handleId: string };
|
||||||
error: { code: string; message: string };
|
error: { code: string; message: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericClaim {
|
export interface GenericVerifiableCredential {
|
||||||
"@context": string;
|
"@context": string;
|
||||||
"@type": string;
|
"@type": string;
|
||||||
issuedAt: string;
|
}
|
||||||
// "any" because arbitrary objects can be subject of agreement
|
|
||||||
|
export interface GenericServerRecord extends GenericVerifiableCredential {
|
||||||
|
handleId?: string;
|
||||||
|
id?: string;
|
||||||
|
issuedAt?: string;
|
||||||
|
issuer?: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
claim: Record<any, any>;
|
claim: Record<any, any>;
|
||||||
|
claimType?: string;
|
||||||
}
|
}
|
||||||
|
export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
|
||||||
|
"@context": SCHEMA_ORG_CONTEXT,
|
||||||
|
"@type": "",
|
||||||
|
claim: {},
|
||||||
|
};
|
||||||
|
|
||||||
export interface GiveServerRecord {
|
export interface GiveServerRecord {
|
||||||
agentDid: string;
|
agentDid: string;
|
||||||
@@ -41,21 +69,80 @@ export interface GiveServerRecord {
|
|||||||
amountConfirmed: number;
|
amountConfirmed: number;
|
||||||
description: string;
|
description: string;
|
||||||
fullClaim: GiveVerifiableCredential;
|
fullClaim: GiveVerifiableCredential;
|
||||||
|
fulfillsPlanHandleId: string;
|
||||||
handleId: string;
|
handleId: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
|
jwtId: string;
|
||||||
recipientDid: string;
|
recipientDid: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OfferServerRecord {
|
||||||
|
amount: number;
|
||||||
|
amountGiven: number;
|
||||||
|
fullClaim: OfferVerifiableCredential;
|
||||||
|
fulfillsPlanHandleId: string;
|
||||||
|
handleId: string;
|
||||||
|
offeredByDid: string;
|
||||||
|
recipientDid: string;
|
||||||
|
requirementsMet: boolean;
|
||||||
|
unit: string;
|
||||||
|
validThrough: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanServerRecord {
|
||||||
|
agentDid?: string; // optional, if the issuer wants someone else to manage as well
|
||||||
|
description: string;
|
||||||
|
endTime?: string;
|
||||||
|
fulfillsPlanHandleId: string;
|
||||||
|
issuerDid: string;
|
||||||
|
handleId: string;
|
||||||
|
locLat?: number;
|
||||||
|
locLon?: number;
|
||||||
|
startTime?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that previous VCs may have additional fields.
|
||||||
|
// https://endorser.ch/doc/html/transactions.html#id4
|
||||||
export interface GiveVerifiableCredential {
|
export interface GiveVerifiableCredential {
|
||||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||||
"@type": string;
|
"@type": "GiveAction";
|
||||||
agent?: { identifier: string };
|
agent?: { identifier: string };
|
||||||
description?: string;
|
description?: string;
|
||||||
fulfills?: { "@type": string; identifier: string };
|
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
object?: { amountOfThisGood: number; unitCode: string };
|
object?: { amountOfThisGood: number; unitCode: string };
|
||||||
recipient: { identifier: string };
|
recipient?: { identifier: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that previous VCs may have additional fields.
|
||||||
|
// https://endorser.ch/doc/html/transactions.html#id8
|
||||||
|
export interface OfferVerifiableCredential {
|
||||||
|
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||||
|
"@type": "Offer";
|
||||||
|
description?: string;
|
||||||
|
includesObject?: { amountOfThisGood: number; unitCode: string };
|
||||||
|
itemOffered?: {
|
||||||
|
description?: string;
|
||||||
|
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
||||||
|
};
|
||||||
|
offeredBy?: { identifier: string };
|
||||||
|
validThrough?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that previous VCs may have additional fields.
|
||||||
|
// https://endorser.ch/doc/html/transactions.html#id7
|
||||||
|
export interface PlanVerifiableCredential {
|
||||||
|
"@context": "https://schema.org";
|
||||||
|
"@type": "PlanAction";
|
||||||
|
name: string;
|
||||||
|
agent?: { identifier: string };
|
||||||
|
description?: string;
|
||||||
|
identifier?: string;
|
||||||
|
location?: {
|
||||||
|
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterVerifiableCredential {
|
export interface RegisterVerifiableCredential {
|
||||||
@@ -63,7 +150,7 @@ export interface RegisterVerifiableCredential {
|
|||||||
"@type": string;
|
"@type": string;
|
||||||
agent: { identifier: string };
|
agent: { identifier: string };
|
||||||
object: string;
|
object: string;
|
||||||
recipient: { identifier: string };
|
participant: { identifier: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InternalError {
|
export interface InternalError {
|
||||||
@@ -75,119 +162,320 @@ export interface InternalError {
|
|||||||
// 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";
|
||||||
|
|
||||||
export function isHiddenDid(did) {
|
export function isHiddenDid(did: string) {
|
||||||
return did === HIDDEN_DID;
|
return did === HIDDEN_DID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isEmptyOrHiddenDid(did?: string) {
|
||||||
|
return !did || did === HIDDEN_DID; // catching empty string as well
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true for any nested string where func(input) === true
|
||||||
|
*
|
||||||
|
* Similar logic is found in endorser-mobile.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) {
|
||||||
|
if (Object.prototype.toString.call(input) === "[object String]") {
|
||||||
|
return func(input);
|
||||||
|
} else if (input instanceof Object) {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
// it's an object
|
||||||
|
for (const key in input) {
|
||||||
|
if (testRecursivelyOnStrings(func, input[key])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// it's an array
|
||||||
|
for (const value of input) {
|
||||||
|
if (testRecursivelyOnStrings(func, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function containsHiddenDid(obj: any) {
|
||||||
|
return testRecursivelyOnStrings(isHiddenDid, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripEndorserPrefix(claimId: string) {
|
||||||
|
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
|
||||||
|
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
|
||||||
|
} else {
|
||||||
|
return claimId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function removeSchemaContext(obj: any) {
|
||||||
|
return obj["@context"] === SCHEMA_ORG_CONTEXT
|
||||||
|
? R.omit(["@context"], obj)
|
||||||
|
: obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
export function addLastClaimOrHandleAsIdIfMissing(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
obj: any,
|
||||||
|
lastClaimId?: string,
|
||||||
|
handleId?: string,
|
||||||
|
) {
|
||||||
|
if (!obj.identifier && lastClaimId) {
|
||||||
|
const result = R.clone(obj);
|
||||||
|
result.lastClaimId = lastClaimId;
|
||||||
|
return result;
|
||||||
|
} else if (!obj.identifier && handleId) {
|
||||||
|
const result = R.clone(obj);
|
||||||
|
result.identifier = handleId;
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return clone of object without any nested *VisibleToDids keys
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function removeVisibleToDids(input: any): any {
|
||||||
|
if (input instanceof Object) {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
// it's an object
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const key in input) {
|
||||||
|
if (!key.endsWith("VisibleToDids")) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
result[key] = removeVisibleToDids(R.clone(input[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
// it's an array
|
||||||
|
return R.map(removeVisibleToDids, input);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
|
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
|
||||||
|
|
||||||
|
Similar logic is found in endorser-mobile.
|
||||||
**/
|
**/
|
||||||
export function didInfo(
|
export function didInfo(
|
||||||
did: string,
|
did: string | undefined,
|
||||||
activeDid: string,
|
activeDid: string | undefined,
|
||||||
allMyDids: Array<string>,
|
allMyDids: string[],
|
||||||
contacts: Array<Contact>,
|
contacts: Contact[],
|
||||||
): string {
|
): string {
|
||||||
const myId: string | undefined = R.find(R.equals(did), allMyDids, did);
|
if (!did) return "Someone Anonymous";
|
||||||
if (myId) {
|
|
||||||
return "You" + (myId !== activeDid ? " (Alt ID)" : "");
|
const contact = R.find((c) => c.did === did, contacts);
|
||||||
|
if (contact) {
|
||||||
|
return contact.name || "Contact With No Name";
|
||||||
} else {
|
} else {
|
||||||
const contact: Contact | undefined = R.find((c) => c.did === did, contacts);
|
const myId = R.find(R.equals(did), allMyDids);
|
||||||
if (contact) {
|
return myId
|
||||||
return contact.name || "Someone Unnamed in Contacts";
|
? `You${myId !== activeDid ? " (Alt ID)" : ""}`
|
||||||
} else if (!did) {
|
: isHiddenDid(did)
|
||||||
return "Unspecified Person";
|
? "Someone Not In Network"
|
||||||
} else if (isHiddenDid(did)) {
|
: "Someone Not In Contacts";
|
||||||
return "Someone Not In Network";
|
|
||||||
} else {
|
|
||||||
return "Someone Not In Contacts";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResultWithType {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessResult extends ResultWithType {
|
||||||
|
type: "success";
|
||||||
|
response: AxiosResponse<ClaimResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResult {
|
||||||
|
type: "error";
|
||||||
|
error: InternalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
*
|
*
|
||||||
* @param identity
|
* @param identity
|
||||||
* @param fromDid may be null
|
* @param fromDid may be null
|
||||||
* @param toDid
|
* @param toDid
|
||||||
* @param description may be null; should have this or hours
|
* @param description may be null; should have this or amount
|
||||||
* @param hours may be null; should have this or description
|
* @param amount may be null; should have this or description
|
||||||
*/
|
*/
|
||||||
export async function createAndSubmitGive(
|
export async function createAndSubmitGive(
|
||||||
axios: Axios,
|
axios: Axios,
|
||||||
apiServer: string,
|
apiServer: string,
|
||||||
identity: IIdentifier,
|
identity: IIdentifier,
|
||||||
fromDid: string,
|
fromDid?: string | null,
|
||||||
toDid: string,
|
toDid?: string,
|
||||||
description: string,
|
description?: string,
|
||||||
hours: number,
|
amount?: number,
|
||||||
|
unitCode?: string,
|
||||||
fulfillsProjectHandleId?: string,
|
fulfillsProjectHandleId?: string,
|
||||||
): Promise<AxiosResponse<ClaimResult> | InternalError> {
|
fulfillsOfferHandleId?: string,
|
||||||
// Make a claim
|
isTrade: boolean = false,
|
||||||
|
): Promise<CreateAndSubmitClaimResult> {
|
||||||
const vcClaim: GiveVerifiableCredential = {
|
const vcClaim: GiveVerifiableCredential = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "GiveAction",
|
"@type": "GiveAction",
|
||||||
recipient: { identifier: toDid },
|
recipient: toDid ? { identifier: toDid } : undefined,
|
||||||
|
agent: fromDid ? { identifier: fromDid } : undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
object: amount
|
||||||
|
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
|
||||||
|
: undefined,
|
||||||
|
fulfills: [{ "@type": isTrade ? "TradeAction" : "DonateAction" }],
|
||||||
};
|
};
|
||||||
if (fromDid) {
|
if (fulfillsProjectHandleId) {
|
||||||
vcClaim.agent = { identifier: fromDid };
|
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this
|
||||||
|
vcClaim.fulfills.push({
|
||||||
|
"@type": "PlanAction",
|
||||||
|
identifier: fulfillsProjectHandleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fulfillsOfferHandleId) {
|
||||||
|
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this
|
||||||
|
vcClaim.fulfills.push({
|
||||||
|
"@type": "Offer",
|
||||||
|
identifier: fulfillsOfferHandleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createAndSubmitClaim(
|
||||||
|
vcClaim as GenericServerRecord,
|
||||||
|
identity,
|
||||||
|
apiServer,
|
||||||
|
axios,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||||
|
*
|
||||||
|
* @param identity
|
||||||
|
* @param description may be null; should have this or amount
|
||||||
|
* @param amount may be null; should have this or description
|
||||||
|
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
|
||||||
|
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
|
||||||
|
*/
|
||||||
|
export async function createAndSubmitOffer(
|
||||||
|
axios: Axios,
|
||||||
|
apiServer: string,
|
||||||
|
identity: IIdentifier,
|
||||||
|
description?: string,
|
||||||
|
amount?: number,
|
||||||
|
unitCode?: string,
|
||||||
|
expirationDate?: string,
|
||||||
|
fulfillsProjectHandleId?: string,
|
||||||
|
): Promise<CreateAndSubmitClaimResult> {
|
||||||
|
const vcClaim: OfferVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Offer",
|
||||||
|
offeredBy: { identifier: identity.did },
|
||||||
|
validThrough: expirationDate || undefined,
|
||||||
|
};
|
||||||
|
if (amount) {
|
||||||
|
vcClaim.includesObject = {
|
||||||
|
amountOfThisGood: amount,
|
||||||
|
unitCode: unitCode || "HUR",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (description) {
|
if (description) {
|
||||||
vcClaim.description = description;
|
vcClaim.itemOffered = { description };
|
||||||
}
|
|
||||||
if (hours) {
|
|
||||||
vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" };
|
|
||||||
}
|
}
|
||||||
if (fulfillsProjectHandleId) {
|
if (fulfillsProjectHandleId) {
|
||||||
vcClaim.fulfills = {
|
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||||
|
vcClaim.itemOffered.isPartOf = {
|
||||||
"@type": "PlanAction",
|
"@type": "PlanAction",
|
||||||
identifier: fulfillsProjectHandleId,
|
identifier: fulfillsProjectHandleId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Make a payload for the claim
|
return createAndSubmitClaim(
|
||||||
const vcPayload = {
|
vcClaim as GenericServerRecord,
|
||||||
vc: {
|
identity,
|
||||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
apiServer,
|
||||||
type: ["VerifiableCredential"],
|
axios,
|
||||||
credentialSubject: vcClaim,
|
);
|
||||||
},
|
}
|
||||||
};
|
|
||||||
// Create a signature using private key of identity
|
export async function createAndSubmitClaim(
|
||||||
if (identity.keys[0].privateKeyHex == null) {
|
vcClaim: GenericVerifiableCredential,
|
||||||
return new Promise<InternalError>((resolve, reject) => {
|
identity: IIdentifier,
|
||||||
reject({
|
apiServer: string,
|
||||||
|
axios: Axios,
|
||||||
|
): Promise<CreateAndSubmitClaimResult> {
|
||||||
|
try {
|
||||||
|
const vcPayload = {
|
||||||
|
vc: {
|
||||||
|
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||||
|
type: ["VerifiableCredential"],
|
||||||
|
credentialSubject: vcClaim,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a signature using private key of identity
|
||||||
|
const firstKey = identity.keys[0];
|
||||||
|
const privateKeyHex = firstKey?.privateKeyHex;
|
||||||
|
|
||||||
|
if (!privateKeyHex) {
|
||||||
|
throw {
|
||||||
error: "No private key",
|
error: "No private key",
|
||||||
message:
|
message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`,
|
||||||
"Your identifier " +
|
};
|
||||||
identity.did +
|
}
|
||||||
" is not configured correctly. Use a different identifier.",
|
|
||||||
});
|
const signer = await SimpleSigner(privateKeyHex);
|
||||||
|
|
||||||
|
// Create a JWT for the request
|
||||||
|
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||||
|
issuer: identity.did,
|
||||||
|
signer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make the xhr request payload
|
||||||
|
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||||
|
const url = `${apiServer}/api/v2/claim`;
|
||||||
|
const token = await accessToken(identity);
|
||||||
|
|
||||||
|
const response = await axios.post(url, payload, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { type: "success", response };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error creating claim:", error);
|
||||||
|
const errorMessage: string =
|
||||||
|
error.response?.data?.error?.message ||
|
||||||
|
error.message ||
|
||||||
|
"Got some error submitting the claim. Check your permissions, network, and error logs.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: {
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 token = await accessToken(identity);
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: "Bearer " + token,
|
|
||||||
};
|
|
||||||
|
|
||||||
return axios.post(url, payload, { headers });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// from https://stackoverflow.com/a/175787/845494
|
// from https://stackoverflow.com/a/175787/845494
|
||||||
@@ -231,6 +519,10 @@ export interface ProjectData {
|
|||||||
* URL referencing information about the project
|
* URL referencing information about the project
|
||||||
**/
|
**/
|
||||||
handleId: string;
|
handleId: string;
|
||||||
|
/**
|
||||||
|
* The DID of the issuer
|
||||||
|
*/
|
||||||
|
issuerDid: string;
|
||||||
/**
|
/**
|
||||||
* The Identier of the project
|
* The Identier of the project
|
||||||
**/
|
**/
|
||||||
|
|||||||
247
src/libs/util.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// many of these are also found in endorser-mobile utility.ts
|
||||||
|
|
||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||||
|
import { GenericServerRecord, containsHiddenDid } from "@/libs/endorserServer";
|
||||||
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const Buffer = require("buffer/").Buffer;
|
||||||
|
|
||||||
|
// 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) Check that they have entered their name on the profile page in their device. 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) Have them go to their Contact page and scan your QR to add you to their list.";
|
||||||
|
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
export const UNIT_SHORT: Record<string, string> = {
|
||||||
|
"BTC": "BTC",
|
||||||
|
"ETH": "ETH",
|
||||||
|
"HUR": "Hours",
|
||||||
|
"USD": "US $",
|
||||||
|
};
|
||||||
|
/* eslint-enable prettier/prettier */
|
||||||
|
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
export const UNIT_LONG: Record<string, string> = {
|
||||||
|
"BTC": "Bitcoin",
|
||||||
|
"ETH": "Ethereum",
|
||||||
|
"HUR": "hours",
|
||||||
|
"USD": "dollars",
|
||||||
|
};
|
||||||
|
/* eslint-enable prettier/prettier */
|
||||||
|
|
||||||
|
export const isGlobalUri = (uri: string) => {
|
||||||
|
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const giveIsConfirmable = (veriClaim: GenericServerRecord) => {
|
||||||
|
return veriClaim.claimType === "GiveAction";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
useClipboard()
|
||||||
|
.copy(text)
|
||||||
|
.then(() => setTimeout(fn, 2000));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the user can confirm the claim
|
||||||
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
|
*/
|
||||||
|
export const isGiveRecordTheUserCanConfirm = (
|
||||||
|
veriClaim: GenericServerRecord,
|
||||||
|
activeDid: string,
|
||||||
|
confirmerIdList: string[] = [],
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
giveIsConfirmable(veriClaim) &&
|
||||||
|
!confirmerIdList.includes(activeDid) &&
|
||||||
|
veriClaim.issuer !== activeDid &&
|
||||||
|
!containsHiddenDid(veriClaim.claim)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the DID of the person who offered, or undefined if hidden
|
||||||
|
* @param veriClaim is expected to have fields: claim and issuer
|
||||||
|
*/
|
||||||
|
export const offerGiverDid: (
|
||||||
|
arg0: GenericServerRecord,
|
||||||
|
) => string | undefined = (veriClaim) => {
|
||||||
|
let giver;
|
||||||
|
if (
|
||||||
|
veriClaim.claim.offeredBy?.identifier &&
|
||||||
|
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string)
|
||||||
|
) {
|
||||||
|
giver = veriClaim.claim.offeredBy.identifier;
|
||||||
|
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
|
||||||
|
giver = veriClaim.issuer;
|
||||||
|
}
|
||||||
|
return giver;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the user can fulfill the offer
|
||||||
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||||
|
*/
|
||||||
|
export const canFulfillOffer = (veriClaim: GenericServerRecord) => {
|
||||||
|
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
|
||||||
|
};
|
||||||
|
|
||||||
|
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
|
||||||
|
export function findAllVisibleToDids(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
input: any,
|
||||||
|
humanReadable = false,
|
||||||
|
): Record<string, Array<string>> {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
const result: Record<string, Array<string>> = {};
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const inside = findAllVisibleToDids(input[i], humanReadable);
|
||||||
|
for (const key in inside) {
|
||||||
|
const pathKey = humanReadable
|
||||||
|
? "#" + (i + 1) + " " + key
|
||||||
|
: "[" + i + "]" + key;
|
||||||
|
result[pathKey] = inside[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else if (input instanceof Object) {
|
||||||
|
// regular map (non-array) object
|
||||||
|
const result: Record<string, Array<string>> = {};
|
||||||
|
for (const key in input) {
|
||||||
|
if (key.endsWith("VisibleToDids")) {
|
||||||
|
const newKey = key.slice(0, -"VisibleToDids".length);
|
||||||
|
const pathKey = humanReadable ? newKey : "." + newKey;
|
||||||
|
result[pathKey] = input[key];
|
||||||
|
} else {
|
||||||
|
const inside = findAllVisibleToDids(input[key], humanReadable);
|
||||||
|
for (const insideKey in inside) {
|
||||||
|
const pathKey = humanReadable
|
||||||
|
? key + "'s " + insideKey
|
||||||
|
: "." + key + insideKey;
|
||||||
|
result[pathKey] = inside[insideKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test findAllVisibleToDids
|
||||||
|
*
|
||||||
|
|
||||||
|
pkgx +deno.land sh
|
||||||
|
|
||||||
|
deno
|
||||||
|
|
||||||
|
import * as R from 'ramda';
|
||||||
|
//import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function
|
||||||
|
|
||||||
|
console.log(R.equals(findAllVisibleToDids(null), {}));
|
||||||
|
console.log(R.equals(findAllVisibleToDids(9), {}));
|
||||||
|
console.log(R.equals(findAllVisibleToDids([]), {}));
|
||||||
|
console.log(R.equals(findAllVisibleToDids({}), {}));
|
||||||
|
console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {}));
|
||||||
|
console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] }));
|
||||||
|
console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] }));
|
||||||
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] }));
|
||||||
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] }));
|
||||||
|
|
||||||
|
*
|
||||||
|
**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new identity, saves it to the database, and sets it as the active identity.
|
||||||
|
* @return {Promise<string>} with the DID of the new identity
|
||||||
|
*/
|
||||||
|
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||||
|
const mnemonic = generateSeed();
|
||||||
|
// address is 0x... ETH address, without "did:eth:"
|
||||||
|
const [address, privateHex, publicHex, derivationPath] =
|
||||||
|
deriveAddress(mnemonic);
|
||||||
|
|
||||||
|
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
||||||
|
const identity = JSON.stringify(newId);
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
await accountsDB.accounts.add({
|
||||||
|
dateCreated: new Date().toISOString(),
|
||||||
|
derivationPath: derivationPath,
|
||||||
|
did: newId.did,
|
||||||
|
identity: identity,
|
||||||
|
mnemonic: mnemonic,
|
||||||
|
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
activeDid: newId.did,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newId.did;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendTestThroughPushServer = async (
|
||||||
|
subscription: PushSubscription,
|
||||||
|
skipFilter: boolean,
|
||||||
|
): Promise<AxiosResponse> => {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
|
||||||
|
if (settings?.webPushServer) {
|
||||||
|
pushUrl = settings.webPushServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
||||||
|
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
|
||||||
|
// 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 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 = {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: authB64,
|
||||||
|
p256dh: p256dhB64,
|
||||||
|
},
|
||||||
|
message: `Test, where you will see this message ${
|
||||||
|
skipFilter ? "un" : ""
|
||||||
|
}filtered.`,
|
||||||
|
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
||||||
|
};
|
||||||
|
console.log("Sending a test web push message:", newPayload);
|
||||||
|
const payloadStr = JSON.stringify(newPayload);
|
||||||
|
const response = await axios.post(
|
||||||
|
pushUrl + "/web-push/send-test",
|
||||||
|
payloadStr,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Got response from web push server:", response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
@@ -1,151 +1,7 @@
|
|||||||
// Created from the setup in https://veramo.io/docs/guides/react_native
|
// see also ../constants/app.ts and
|
||||||
|
|
||||||
// Core interfaces
|
|
||||||
/* import {
|
|
||||||
createAgent,
|
|
||||||
IDIDManager,
|
|
||||||
IResolver,
|
|
||||||
IDataStore,
|
|
||||||
IKeyManager,
|
|
||||||
} from "@veramo/core";
|
|
||||||
*/
|
|
||||||
// Core identity manager plugin
|
|
||||||
//import { DIDManager } from "@veramo/did-manager";
|
|
||||||
|
|
||||||
// Ethr did identity provider
|
|
||||||
//import { EthrDIDProvider } from "@veramo/did-provider-ethr";
|
|
||||||
|
|
||||||
// Core key manager plugin
|
|
||||||
//import { KeyManager } from "@veramo/key-manager";
|
|
||||||
|
|
||||||
// Custom key management system for RN
|
|
||||||
//import { KeyManagementSystem } from '@veramo/kms-local-react-native'
|
|
||||||
|
|
||||||
// Custom resolver
|
|
||||||
// Custom resolvers
|
|
||||||
//import { DIDResolverPlugin } from "@veramo/did-resolver";
|
|
||||||
/* import { Resolver } from "did-resolver";
|
|
||||||
import { getResolver as ethrDidResolver } from "ethr-did-resolver";
|
|
||||||
import { getResolver as webDidResolver } from "web-did-resolver";
|
|
||||||
*/
|
|
||||||
// for VCs and VPs https://veramo.io/docs/api/credential-w3c
|
|
||||||
//import { CredentialIssuer } from '@veramo/credential-w3c'
|
|
||||||
|
|
||||||
// Storage plugin using TypeOrm
|
|
||||||
/* import {
|
|
||||||
Entities,
|
|
||||||
KeyStore,
|
|
||||||
DIDStore,
|
|
||||||
IDataStoreORM,
|
|
||||||
} from "@veramo/data-store";
|
|
||||||
*/
|
|
||||||
// TypeORM is installed with @veramo/typeorm
|
|
||||||
//import { createConnection } from 'typeorm'
|
|
||||||
|
|
||||||
//import * as R from "ramda";
|
|
||||||
|
|
||||||
/*
|
|
||||||
import { Contact } from '../entity/contact'
|
|
||||||
import { Settings } from '../entity/settings'
|
|
||||||
import { PrivateData } from '../entity/privateData'
|
|
||||||
|
|
||||||
import { Initial1616938713828 } from '../migration/1616938713828-initial'
|
|
||||||
import { SettingsContacts1616967972293 } from '../migration/1616967972293-settings-contacts'
|
|
||||||
import { EncryptedSeed1637856484788 } from '../migration/1637856484788-EncryptedSeed'
|
|
||||||
import { HomeScreenConfig1639947962124 } from '../migration/1639947962124-HomeScreenConfig'
|
|
||||||
import { HandlePublicKeys1652142819353 } from '../migration/1652142819353-HandlePublicKeys'
|
|
||||||
import { LastClaimsSeen1656811846836 } from '../migration/1656811846836-LastClaimsSeen'
|
|
||||||
import { ContactRegistered1662256903367 }from '../migration/1662256903367-ContactRegistered'
|
|
||||||
import { PrivateData1663080623479 } from '../migration/1663080623479-PrivateData'
|
|
||||||
|
|
||||||
const ALL_ENTITIES = Entities.concat([Contact, Settings, PrivateData])
|
|
||||||
|
|
||||||
// Create react native DB connection configured by ormconfig.js
|
|
||||||
|
|
||||||
export const dbConnection = createConnection({
|
|
||||||
database: 'endorser-mobile.sqlite',
|
|
||||||
entities: ALL_ENTITIES,
|
|
||||||
location: 'default',
|
|
||||||
logging: ['error', 'info', 'warn'],
|
|
||||||
migrations: [ Initial1616938713828, SettingsContacts1616967972293, EncryptedSeed1637856484788, HomeScreenConfig1639947962124, HandlePublicKeys1652142819353, LastClaimsSeen1656811846836, ContactRegistered1662256903367, PrivateData1663080623479 ],
|
|
||||||
migrationsRun: true,
|
|
||||||
type: 'react-native',
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
function didProviderName(netName: string) {
|
function didProviderName(netName: string) {
|
||||||
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
|
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
|
||||||
}
|
}
|
||||||
|
|
||||||
//const NETWORK_NAMES = ["mainnet", "rinkeby"];
|
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");
|
||||||
|
|
||||||
const DEFAULT_DID_PROVIDER_NETWORK_NAME = "mainnet";
|
|
||||||
|
|
||||||
export const DEFAULT_DID_PROVIDER_NAME = didProviderName(
|
|
||||||
DEFAULT_DID_PROVIDER_NETWORK_NAME,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HANDY_APP = false;
|
|
||||||
|
|
||||||
// this is used as the object in RegisterAction claims
|
|
||||||
export const SERVICE_ID = "endorser.ch";
|
|
||||||
|
|
||||||
//const INFURA_PROJECT_ID = "INFURA_PROJECT_ID";
|
|
||||||
/*
|
|
||||||
const providers = {}
|
|
||||||
NETWORK_NAMES.forEach((networkName) => {
|
|
||||||
providers[didProviderName(networkName)] = new EthrDIDProvider({
|
|
||||||
defaultKms: 'local',
|
|
||||||
network: networkName,
|
|
||||||
rpcUrl: 'https://' + networkName + '.infura.io/v3/' + INFURA_PROJECT_ID,
|
|
||||||
gas: 1000001,
|
|
||||||
ttl: 60 * 60 * 24 * 30 * 12 + 1,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const didManager = new DIDManager({
|
|
||||||
store: new DIDStore(dbConnection),
|
|
||||||
defaultProvider: DEFAULT_DID_PROVIDER_NAME,
|
|
||||||
providers: providers,
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* const basicDidResolvers = NETWORK_NAMES.map((networkName) => [
|
|
||||||
networkName,
|
|
||||||
new Resolver({
|
|
||||||
ethr: ethrDidResolver({
|
|
||||||
networks: [
|
|
||||||
{
|
|
||||||
name: networkName,
|
|
||||||
rpcUrl:
|
|
||||||
"https://" + networkName + ".infura.io/v3/" + INFURA_PROJECT_ID,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).ethr,
|
|
||||||
web: webDidResolver().web,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const basicResolverMap = R.fromPairs(basicDidResolvers)
|
|
||||||
|
|
||||||
export const DEFAULT_BASIC_RESOLVER = basicResolverMap[DEFAULT_DID_PROVIDER_NETWORK_NAME]
|
|
||||||
|
|
||||||
const agentDidResolvers = NETWORK_NAMES.map((networkName) => {
|
|
||||||
return new DIDResolverPlugin({
|
|
||||||
resolver: basicResolverMap[networkName],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
let allPlugins = [
|
|
||||||
new CredentialIssuer(),
|
|
||||||
new KeyManager({
|
|
||||||
store: new KeyStore(dbConnection),
|
|
||||||
kms: {
|
|
||||||
local: new KeyManagementSystem(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
didManager,
|
|
||||||
].concat(agentDidResolvers)
|
|
||||||
*/
|
|
||||||
|
|
||||||
//export const agent = createAgent<IDIDManager & IKeyManager & IDataStore & IDataStoreORM & IResolver>({ plugins: allPlugins })
|
|
||||||
|
|||||||
22
src/main.ts
@@ -13,6 +13,9 @@ import { library } from "@fortawesome/fontawesome-svg-core";
|
|||||||
import {
|
import {
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowUpRightFromSquare,
|
||||||
|
faBan,
|
||||||
|
faBitcoinSign,
|
||||||
faBurst,
|
faBurst,
|
||||||
faCalendar,
|
faCalendar,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
@@ -26,6 +29,7 @@ import {
|
|||||||
faCoins,
|
faCoins,
|
||||||
faComment,
|
faComment,
|
||||||
faCopy,
|
faCopy,
|
||||||
|
faDollar,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
@@ -33,21 +37,28 @@ import {
|
|||||||
faFloppyDisk,
|
faFloppyDisk,
|
||||||
faFolderOpen,
|
faFolderOpen,
|
||||||
faGift,
|
faGift,
|
||||||
|
faGlobe,
|
||||||
faHand,
|
faHand,
|
||||||
|
faHandHoldingHeart,
|
||||||
faHouseChimney,
|
faHouseChimney,
|
||||||
|
faLocationDot,
|
||||||
faLongArrowAltLeft,
|
faLongArrowAltLeft,
|
||||||
faLongArrowAltRight,
|
faLongArrowAltRight,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
|
faMessage,
|
||||||
|
faMinus,
|
||||||
faPen,
|
faPen,
|
||||||
faPersonCircleCheck,
|
faPersonCircleCheck,
|
||||||
faPersonCircleQuestion,
|
faPersonCircleQuestion,
|
||||||
faPlus,
|
faPlus,
|
||||||
|
faQuestion,
|
||||||
faQrcode,
|
faQrcode,
|
||||||
faRotate,
|
faRotate,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
faSquareCaretDown,
|
faSquareCaretDown,
|
||||||
faSquareCaretUp,
|
faSquareCaretUp,
|
||||||
|
faSquarePlus,
|
||||||
faTrashCan,
|
faTrashCan,
|
||||||
faTriangleExclamation,
|
faTriangleExclamation,
|
||||||
faUser,
|
faUser,
|
||||||
@@ -58,6 +69,9 @@ import {
|
|||||||
library.add(
|
library.add(
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowUpRightFromSquare,
|
||||||
|
faBan,
|
||||||
|
faBitcoinSign,
|
||||||
faBurst,
|
faBurst,
|
||||||
faCalendar,
|
faCalendar,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
@@ -71,6 +85,7 @@ library.add(
|
|||||||
faCoins,
|
faCoins,
|
||||||
faComment,
|
faComment,
|
||||||
faCopy,
|
faCopy,
|
||||||
|
faDollar,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
@@ -78,21 +93,28 @@ library.add(
|
|||||||
faFloppyDisk,
|
faFloppyDisk,
|
||||||
faFolderOpen,
|
faFolderOpen,
|
||||||
faGift,
|
faGift,
|
||||||
|
faGlobe,
|
||||||
faHand,
|
faHand,
|
||||||
|
faHandHoldingHeart,
|
||||||
faHouseChimney,
|
faHouseChimney,
|
||||||
|
faLocationDot,
|
||||||
faLongArrowAltLeft,
|
faLongArrowAltLeft,
|
||||||
faLongArrowAltRight,
|
faLongArrowAltRight,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
|
faMessage,
|
||||||
|
faMinus,
|
||||||
faPen,
|
faPen,
|
||||||
faPersonCircleCheck,
|
faPersonCircleCheck,
|
||||||
faPersonCircleQuestion,
|
faPersonCircleQuestion,
|
||||||
faPlus,
|
faPlus,
|
||||||
faQrcode,
|
faQrcode,
|
||||||
|
faQuestion,
|
||||||
faRotate,
|
faRotate,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
faSquareCaretDown,
|
faSquareCaretDown,
|
||||||
faSquareCaretUp,
|
faSquareCaretUp,
|
||||||
|
faSquarePlus,
|
||||||
faTrashCan,
|
faTrashCan,
|
||||||
faTriangleExclamation,
|
faTriangleExclamation,
|
||||||
faUser,
|
faUser,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { register } from "register-service-worker";
|
import { register } from "register-service-worker";
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
register("/additional-scripts.js", {
|
||||||
ready() {
|
ready() {
|
||||||
console.log(
|
console.log(
|
||||||
"App is being served from cache by a service worker.\n" +
|
"App is being served from cache by a service worker.\n" +
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
import {
|
||||||
import { accountsDB } from "@/db";
|
createRouter,
|
||||||
|
createWebHistory,
|
||||||
|
NavigationGuardNext,
|
||||||
|
RouteLocationNormalized,
|
||||||
|
RouteRecordRaw,
|
||||||
|
} from "vue-router";
|
||||||
|
import { accountsDB } from "@/db/index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -7,7 +13,11 @@ import { accountsDB } from "@/db";
|
|||||||
* @param from :RouteLocationNormalized
|
* @param from :RouteLocationNormalized
|
||||||
* @param next :NavigationGuardNext
|
* @param next :NavigationGuardNext
|
||||||
*/
|
*/
|
||||||
const enterOrStart = async (to, from, next) => {
|
const enterOrStart = async (
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext,
|
||||||
|
) => {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const num_accounts = await accountsDB.accounts.count();
|
const num_accounts = await accountsDB.accounts.count();
|
||||||
if (num_accounts > 0) {
|
if (num_accounts > 0) {
|
||||||
@@ -23,14 +33,18 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "home",
|
name: "home",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
||||||
beforeEnter: enterOrStart,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/account",
|
path: "/account",
|
||||||
name: "account",
|
name: "account",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
||||||
beforeEnter: enterOrStart,
|
},
|
||||||
|
{
|
||||||
|
path: "/claim/:id?",
|
||||||
|
name: "claim",
|
||||||
|
component: () =>
|
||||||
|
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/confirm-contact",
|
path: "/confirm-contact",
|
||||||
@@ -48,6 +62,14 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/contact-gives",
|
||||||
|
name: "contact-gives",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-qr",
|
path: "/contact-qr",
|
||||||
name: "contact-qr",
|
name: "contact-qr",
|
||||||
@@ -61,15 +83,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "contacts",
|
name: "contacts",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
||||||
beforeEnter: enterOrStart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/scan-contact",
|
|
||||||
name: "scan-contact",
|
|
||||||
component: () =>
|
|
||||||
import(
|
|
||||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/discover",
|
path: "/discover",
|
||||||
@@ -83,6 +96,22 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
component: () =>
|
component: () =>
|
||||||
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/help-notifications",
|
||||||
|
name: "help-notifications",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/identity-switcher",
|
||||||
|
name: "identity-switcher",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/import-account",
|
path: "/import-account",
|
||||||
name: "import-account",
|
name: "import-account",
|
||||||
@@ -132,15 +161,7 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/identity-switcher",
|
path: "/project/:id?",
|
||||||
name: "identity-switcher",
|
|
||||||
component: () =>
|
|
||||||
import(
|
|
||||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/project",
|
|
||||||
name: "project",
|
name: "project",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
||||||
@@ -152,6 +173,22 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
||||||
beforeEnter: enterOrStart,
|
beforeEnter: enterOrStart,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/scan-contact",
|
||||||
|
name: "scan-contact",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/search-area",
|
||||||
|
name: "search-area",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/seed-backup",
|
path: "/seed-backup",
|
||||||
name: "seed-backup",
|
name: "seed-backup",
|
||||||
@@ -175,12 +212,10 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-gives",
|
path: "/test",
|
||||||
name: "contact-gives",
|
name: "test",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(
|
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
|
||||||
/* webpackChunkName: "statistics" */ "../views/ContactGiftingView.vue"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -190,7 +225,12 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorHandler = (error, to, from) => {
|
const errorHandler = (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
error: any,
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
) => {
|
||||||
// Handle the error here
|
// Handle the error here
|
||||||
console.error("Caught in top level error handler:", error, to, from);
|
console.error("Caught in top level error handler:", error, to, from);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
|||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
import { AppString } from "@/constants/app";
|
import { AppString } from "@/constants/app";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { SERVICE_ID } from "../libs/veramo/setup";
|
import { SERVICE_ID } from "../libs/endorserServer";
|
||||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
|
||||||
|
|||||||
2184
src/util.d.ts
vendored
Normal file
820
src/views/ClaimView.vue
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
<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"></fa>
|
||||||
|
</button>
|
||||||
|
Verifiable Claim Details
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||||
|
<div class="block flex gap-4 overflow-hidden">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<h2 class="text-md font-bold">
|
||||||
|
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
|
||||||
|
</h2>
|
||||||
|
<div class="text-sm">
|
||||||
|
<div>
|
||||||
|
{{ veriClaim.id }}
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
libsUtil.doCopyTwoSecRedo(
|
||||||
|
veriClaim.id as string,
|
||||||
|
() => (showIdCopy = !showIdCopy),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="ml-2 mr-2"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||||
|
</button>
|
||||||
|
<span v-show="showIdCopy">Copied ID</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<fa icon="message" class="fa-fw text-slate-400"></fa>
|
||||||
|
{{ veriClaim.claim?.description }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||||
|
{{ veriClaim.issuer }}
|
||||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
libsUtil.doCopyTwoSecRedo(
|
||||||
|
veriClaim.issuer as string,
|
||||||
|
() => (showDidCopy = !showDidCopy),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="ml-2 mr-2"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||||
|
</button>
|
||||||
|
<span v-show="showDidCopy">Copied DID</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||||
|
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullfills Links -->
|
||||||
|
|
||||||
|
<!-- fullfills links for a give -->
|
||||||
|
<div v-if="detailsForGive?.fulfillsPlanHandleId">
|
||||||
|
<router-link
|
||||||
|
:to="
|
||||||
|
'/project/' +
|
||||||
|
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
|
||||||
|
"
|
||||||
|
class="text-blue-500 mt-2"
|
||||||
|
>
|
||||||
|
Fulfills a bigger plan...
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<!-- if there's another, it's probably fulfilling an offer, too -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
detailsForGive?.fulfillsType &&
|
||||||
|
detailsForGive?.fulfillsType !== 'PlanAction' &&
|
||||||
|
detailsForGive?.fulfillsHandleId
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- router-link to /claim/ only changes URL path -->
|
||||||
|
<a
|
||||||
|
@click="
|
||||||
|
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
|
||||||
|
"
|
||||||
|
class="text-blue-500 mt-4"
|
||||||
|
>
|
||||||
|
Fulfills
|
||||||
|
{{
|
||||||
|
capitalizeAndInsertSpacesBeforeCaps(
|
||||||
|
detailsForGive.fulfillsType,
|
||||||
|
)
|
||||||
|
}}...
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- fullfills links for an offer -->
|
||||||
|
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
|
||||||
|
<router-link
|
||||||
|
:to="
|
||||||
|
'/project/' +
|
||||||
|
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
|
||||||
|
"
|
||||||
|
class="text-blue-500 mt-4"
|
||||||
|
>
|
||||||
|
Offered to a bigger plan...
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns-3">
|
||||||
|
<button
|
||||||
|
class="col-span-1 bg-blue-600 text-white px-4 py-2 rounded-md"
|
||||||
|
v-if="
|
||||||
|
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||||
|
veriClaim,
|
||||||
|
activeDid,
|
||||||
|
confirmerIdList,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="confirmClaim()"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="libsUtil.canFulfillOffer(veriClaim)"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Affirm Delivery
|
||||||
|
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<GiftedDialog ref="customGiveDialog" message="Offer fulfilled by" />
|
||||||
|
|
||||||
|
<div v-if="libsUtil.giveIsConfirmable(veriClaim)">
|
||||||
|
<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="veriClaim.issuer == 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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">
|
||||||
|
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
|
||||||
|
</h2>
|
||||||
|
<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)"
|
||||||
|
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)"
|
||||||
|
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"></fa>
|
||||||
|
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"></fa
|
||||||
|
> <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>
|
||||||
|
|
||||||
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Full Claim</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
The full claim includes the claim as it was originally issued, including
|
||||||
|
the signature (ie. the proof of issuance by that person).
|
||||||
|
</p>
|
||||||
|
<div v-if="!fullClaim">
|
||||||
|
<p v-if="fullClaimMessage" class="mb-4">
|
||||||
|
{{ fullClaimMessage }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="showFullClaim(veriClaim.id as string)"
|
||||||
|
>
|
||||||
|
Load Full Claim Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<pre>{{ fullClaimDump }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
:href="apiServer + '/api/claim/' + veriClaim.id"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
View on the Public Server
|
||||||
|
</a>
|
||||||
|
</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 OfferDialog from "@/components/OfferDialog.vue";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
|
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 * as libsUtil from "@/libs/util";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
||||||
|
})
|
||||||
|
export default class ClaimView extends Vue {
|
||||||
|
$notify!: (notification: Notification, 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
|
||||||
|
detailsForGive = null;
|
||||||
|
detailsForOffer = null;
|
||||||
|
fullClaim = null;
|
||||||
|
fullClaimDump = "";
|
||||||
|
fullClaimMessage = "";
|
||||||
|
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||||
|
showDidCopy = false;
|
||||||
|
showIdCopy = false;
|
||||||
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
|
veriClaimDump = "";
|
||||||
|
veriClaimDidsVisible = {};
|
||||||
|
windowLocation = window.location.href;
|
||||||
|
|
||||||
|
R = R;
|
||||||
|
yaml = yaml;
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
serverUtil = serverUtil;
|
||||||
|
|
||||||
|
resetThisValues() {
|
||||||
|
this.confirmerIdList = [];
|
||||||
|
this.confsVisibleErrorMessage = "";
|
||||||
|
this.confsVisibleToIdList = [];
|
||||||
|
this.detailsForGive = null;
|
||||||
|
this.detailsForOffer = null;
|
||||||
|
this.fullClaim = null;
|
||||||
|
this.fullClaimDump = "";
|
||||||
|
this.fullClaimMessage = "";
|
||||||
|
this.numConfsNotVisible = 0;
|
||||||
|
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
|
this.veriClaimDump = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
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 = await accounts?.toArray();
|
||||||
|
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||||
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
|
this.accountIdentityStr = account?.identity || "null";
|
||||||
|
const identity = JSON.parse(this.accountIdentityStr);
|
||||||
|
|
||||||
|
const pathParam = window.location.pathname.substring("/claim/".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.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return serverUtil.didInfo(
|
||||||
|
did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadClaim(claimId: string, identity: IIdentifier) {
|
||||||
|
const url =
|
||||||
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(claimId);
|
||||||
|
const headers = await this.getHeaders(identity);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve more details on Give, Offer, or Plan
|
||||||
|
if (this.veriClaim.claimType === "GiveAction") {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
if (giveResp.status === 200) {
|
||||||
|
this.detailsForGive = giveResp.data.data[0];
|
||||||
|
} else {
|
||||||
|
console.error("Error getting detailed give info:", giveResp);
|
||||||
|
}
|
||||||
|
} else if (this.veriClaim.claimType === "Offer") {
|
||||||
|
const offerUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/offers?handleId=" +
|
||||||
|
encodeURIComponent(this.veriClaim.handleId as string);
|
||||||
|
const offerHeaders = await this.getHeaders(identity);
|
||||||
|
const offerResp = await this.axios.get(offerUrl, {
|
||||||
|
headers: offerHeaders,
|
||||||
|
});
|
||||||
|
if (offerResp.status === 200) {
|
||||||
|
this.detailsForOffer = offerResp.data.data[0];
|
||||||
|
} else {
|
||||||
|
console.error("Error getting detailed offer info:", offerResp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.veriClaim.issuer,
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showFullClaim(claimId: string) {
|
||||||
|
await accountsDB.open();
|
||||||
|
const accounts = accountsDB.accounts;
|
||||||
|
const accountsArr: Account[] = await accounts?.toArray();
|
||||||
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||||
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
|
||||||
|
const url =
|
||||||
|
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||||
|
const headers = await this.getHeaders(identity);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
this.fullClaim = resp.data;
|
||||||
|
this.fullClaimDump = yaml.dump(this.fullClaim);
|
||||||
|
} else {
|
||||||
|
// actually, axios typically throws an error so we never get here
|
||||||
|
console.error("Error getting full claim:", resp);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "There was a problem getting that claim. See logs for more info.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Error retrieving full claim:", error);
|
||||||
|
const serverError = error as AxiosError;
|
||||||
|
if (serverError.response?.status === 403) {
|
||||||
|
this.fullClaimMessage =
|
||||||
|
"You are not authorized to view the full contents of this claim." +
|
||||||
|
" To see all the details, ask the issuer to allow you to see their claims." +
|
||||||
|
" If you cannot see the issuer's DID, ask someone in the Confirmations section above." +
|
||||||
|
" If there are no connections, you will have to ask people in your" +
|
||||||
|
" network for their help, some other way; send them to this page and" +
|
||||||
|
" see if they can make a connection for you.";
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong retrieving that claim. See logs for more info.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// similar code is found in ProjectViewView
|
||||||
|
async confirmClaim() {
|
||||||
|
if (confirm("Do you personally confirm that this is true?")) {
|
||||||
|
// 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 & {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
object: any;
|
||||||
|
} = {
|
||||||
|
"@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,
|
||||||
|
);
|
||||||
|
} 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDifferentClaimPage(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: GiverInputInfo = {
|
||||||
|
did: libsUtil.offerGiverDid(this.veriClaim),
|
||||||
|
};
|
||||||
|
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||||
|
giver,
|
||||||
|
this.veriClaim.handleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Contacts"></QuickNav>
|
<QuickNav selected="Contacts"></QuickNav>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1
|
||||||
|
id="ViewBreadcrumb"
|
||||||
|
class="text-lg text-center font-light relative px-7"
|
||||||
|
>
|
||||||
<!-- Back -->
|
<!-- Back -->
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'contacts' }"
|
:to="{ name: 'contacts' }"
|
||||||
@@ -11,16 +14,24 @@
|
|||||||
><fa icon="chevron-left" class="fa-fw"></fa
|
><fa icon="chevron-left" class="fa-fw"></fa
|
||||||
></router-link>
|
></router-link>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||||
|
Transferred with {{ contact?.name }}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
|
||||||
Given with {{ contact?.name }}
|
|
||||||
</h1>
|
|
||||||
<div class="flex justify-around">
|
<div class="flex justify-around">
|
||||||
<span />
|
<span />
|
||||||
<span class="justify-around">(Only 50 most recent)</span>
|
<span class="justify-around">(Only 50 most recent)</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-around">
|
||||||
|
<span />
|
||||||
|
<span class="justify-around">
|
||||||
|
(This does not include claims by them if they're not visible to you.)
|
||||||
|
</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Results List -->
|
<!-- Results List -->
|
||||||
<table
|
<table
|
||||||
@@ -90,10 +101,6 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { accountsDB, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
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";
|
||||||
@@ -113,17 +120,24 @@ import {
|
|||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import { IIdentifier } from "@veramo/core";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ components: { QuickNav } })
|
||||||
|
export default class ContactAmountssView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
@Component({ components: { AlertMessage, QuickNav } })
|
|
||||||
export default class ContactsView extends Vue {
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
contact: Contact | null = null;
|
contact: Contact | null = null;
|
||||||
giveRecords: Array<GiveServerRecord> = [];
|
giveRecords: Array<GiveServerRecord> = [];
|
||||||
alertTitle = "";
|
|
||||||
alertMessage = "";
|
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
|
||||||
async beforeCreate() {
|
async beforeCreate() {
|
||||||
@@ -131,7 +145,7 @@ export default class ContactsView extends Vue {
|
|||||||
this.numAccounts = await accountsDB.accounts.count();
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
@@ -141,13 +155,13 @@ export default class ContactsView extends Vue {
|
|||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Attempted to load Give records with no identity available.",
|
"Attempted to load Give records with no identifier available.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity) {
|
public async getHeaders(identity: IIdentifier) {
|
||||||
const token = await accessToken(identity);
|
const token = await accessToken(identity);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -169,7 +183,9 @@ export default class ContactsView extends Vue {
|
|||||||
if (this.activeDid && this.contact) {
|
if (this.activeDid && this.contact) {
|
||||||
this.loadGives(this.activeDid, this.contact);
|
this.loadGives(this.activeDid, this.contact);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("Error retrieving settings or gives.", err);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -177,7 +193,7 @@ export default class ContactsView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text:
|
text:
|
||||||
err.userMessage ||
|
err.userMessage ||
|
||||||
"There was an error retrieving the latest sweet, sweet action.",
|
"There was an error retrieving your settings or contacts or gives.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -187,7 +203,7 @@ export default class ContactsView 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 = [];
|
let result: Array<GiveServerRecord> = [];
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/gives?agentDid=" +
|
"/api/v2/report/gives?agentDid=" +
|
||||||
@@ -354,7 +370,10 @@ export default class ContactsView extends Vue {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
|
/*
|
||||||
|
Tooltip, generated on "title" attributes on "fa" icons
|
||||||
|
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
||||||
|
*/
|
||||||
/* Tooltip container */
|
/* Tooltip container */
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Home"></QuickNav>
|
<QuickNav selected="Home"></QuickNav>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -16,21 +16,17 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Search -->
|
|
||||||
|
|
||||||
<!-- Initial Loading Animation -->
|
|
||||||
|
|
||||||
<!-- Results List -->
|
<!-- Results List -->
|
||||||
<ul class="border-t border-slate-300">
|
<ul class="border-t border-slate-300">
|
||||||
<li class="border-b border-slate-300 py-3">
|
<li class="border-b border-slate-300 py-3">
|
||||||
<h2 class="text-base flex gap-4 items-center">
|
<h2 class="text-base flex gap-4 items-center">
|
||||||
<span class="grow italic text-slate-500"
|
<span class="grow">
|
||||||
><EntityIcon
|
<img
|
||||||
entityId="Anonymous"
|
src="../assets/blank-square.svg"
|
||||||
:iconSize="32"
|
width="32"
|
||||||
class="opacity-50 inline-block align-middle border border-dashed border-slate-400 bg-slate-200 rounded-md mr-1"
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||||
></EntityIcon>
|
/>
|
||||||
Anonymous
|
Anonymous/Unnamed
|
||||||
</span>
|
</span>
|
||||||
<span class="text-right">
|
<span class="text-right">
|
||||||
<button
|
<button
|
||||||
@@ -49,12 +45,12 @@
|
|||||||
class="border-b border-slate-300 py-3"
|
class="border-b border-slate-300 py-3"
|
||||||
>
|
>
|
||||||
<h2 class="text-base flex gap-4 items-center">
|
<h2 class="text-base flex gap-4 items-center">
|
||||||
<span class="grow font-semibold"
|
<span class="grow font-semibold">
|
||||||
><EntityIcon
|
<EntityIcon
|
||||||
:entityId="contact.did"
|
:entityId="contact.did"
|
||||||
: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"
|
||||||
></EntityIcon>
|
/>
|
||||||
{{ contact.name || "(no name)" }}
|
{{ contact.name || "(no name)" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-right">
|
<span class="text-right">
|
||||||
@@ -72,68 +68,94 @@
|
|||||||
|
|
||||||
<GiftedDialog
|
<GiftedDialog
|
||||||
ref="customDialog"
|
ref="customDialog"
|
||||||
@dialog-result="handleDialogResult"
|
|
||||||
message="Received from"
|
message="Received from"
|
||||||
>
|
:projectId="projectId"
|
||||||
</GiftedDialog>
|
/>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</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 GiftedDialog from "@/components/GiftedDialog.vue";
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
import { db, accountsDB } from "@/db";
|
import { db, accountsDB } from "@/db/index";
|
||||||
import { AccountsSchema } from "@/db/tables/accounts";
|
import { Account, AccountsSchema } from "@/db/tables/accounts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import { createAndSubmitGive } from "@/libs/endorserServer";
|
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||||
import { Account } from "@/db/tables/accounts";
|
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon";
|
import { IIdentifier } from "@veramo/core";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||||
})
|
})
|
||||||
export default class HomeView extends Vue {
|
export default class ContactGiftingView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
allAccounts: Array<Account> = [];
|
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
isHiddenSpinner = true;
|
accounts: typeof AccountsSchema;
|
||||||
alertTitle = "";
|
|
||||||
alertMessage = "";
|
|
||||||
accounts: AccountsSchema;
|
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
projectId = localStorage.getItem("projectId") || "";
|
||||||
|
|
||||||
async beforeCreate() {
|
async beforeCreate() {
|
||||||
accountsDB.open();
|
accountsDB.open();
|
||||||
this.accounts = accountsDB.accounts;
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
this.numAccounts = await this.accounts.count();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
async created() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.activeDid = settings?.activeDid || "";
|
||||||
|
this.allContacts = await db.contacts.toArray();
|
||||||
|
|
||||||
|
localStorage.removeItem("projectId");
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("Error retrieving settings & contacts:", err);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text:
|
||||||
|
err.message ||
|
||||||
|
"There was an error retrieving your settings or contacts.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = (await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
.equals(activeDid)
|
.equals(activeDid)
|
||||||
.first();
|
.first()) as Account;
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Attempted to load Give records with no identity available.",
|
"Attempted to load Give records with no identifier available.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity) {
|
public async getHeaders(identity: IIdentifier) {
|
||||||
const token = await accessToken(identity);
|
const token = await accessToken(identity);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -142,170 +164,8 @@ export default class HomeView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async created() {
|
openDialog(giver: GiverInputInfo) {
|
||||||
try {
|
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||||
await accountsDB.open();
|
|
||||||
this.allAccounts = await accountsDB.accounts.toArray();
|
|
||||||
await db.open();
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
this.apiServer = settings?.apiServer || "";
|
|
||||||
this.activeDid = settings?.activeDid || "";
|
|
||||||
this.allContacts = await db.contacts.toArray();
|
|
||||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
|
||||||
this.updateAllFeed();
|
|
||||||
} catch (err) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text:
|
|
||||||
err.userMessage ||
|
|
||||||
"There was an error retrieving the latest sweet, sweet action.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async buildHeaders() {
|
|
||||||
const headers = { "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 || "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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
|
||||||
} else {
|
|
||||||
// it's OK without auth... we just won't get any identifiers
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
openDialog(giver) {
|
|
||||||
this.$refs.customDialog.open(giver);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDialogResult(result) {
|
|
||||||
if (result.action === "confirm") {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// action was "cancel" so do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param giverDid may be null
|
|
||||||
* @param description may be an empty string
|
|
||||||
* @param hours may be 0
|
|
||||||
*/
|
|
||||||
public async recordGive(giverDid, description, hours) {
|
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identity before you can record a give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description && !hours) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must enter a description or some number of hours.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
|
||||||
const result = await createAndSubmitGive(
|
|
||||||
this.axios,
|
|
||||||
this.apiServer,
|
|
||||||
identity,
|
|
||||||
giverDid,
|
|
||||||
this.activeDid,
|
|
||||||
description,
|
|
||||||
hours,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isGiveCreationError(result)) {
|
|
||||||
const errorMessage = getGiveCreationErrorMessage(result);
|
|
||||||
console.log("Error with give result:", result);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: errorMessage || "There was an error recording the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "That gift was recorded.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error with give caught:", error);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text:
|
|
||||||
getGiveErrorMessage(error) ||
|
|
||||||
"There was an error recording the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setAlert(title, message) {
|
|
||||||
this.alertTitle = title;
|
|
||||||
this.alertMessage = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for readability
|
|
||||||
|
|
||||||
isGiveCreationError(result) {
|
|
||||||
return result.status !== 201 || result.data?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGiveCreationErrorMessage(result) {
|
|
||||||
return result.data?.error?.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGiveErrorMessage(error) {
|
|
||||||
return error.userMessage || error.response?.data?.error?.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,51 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav selected="Profile"></QuickNav>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Heading -->
|
<!-- Breadcrumb -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
<div class="mb-8">
|
||||||
Your Contact Info
|
<!-- Back -->
|
||||||
</h1>
|
<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 -->
|
||||||
Play with display options: https://qr-code-styling.com/
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||||
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
|
Your Contact Info
|
||||||
-->
|
</h1>
|
||||||
<QRCodeVue3
|
<p v-if="!givenName" class="text-center mt-2">
|
||||||
:value="this.qrValue"
|
<span class="text-red">Beware!</span>
|
||||||
:cornersSquareOptions="{ type: 'extra-rounded' }"
|
You aren't sharing your name, so hurry and
|
||||||
:dotsOptions="{ type: 'square' }"
|
<router-link
|
||||||
class="flex justify-center"
|
:to="{ name: 'new-edit-account' }"
|
||||||
/>
|
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
|
||||||
|
>
|
||||||
|
go here to set it for them.
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="onCopyToClipboard()" v-if="activeDid">
|
||||||
|
<!--
|
||||||
|
Play with display options: https://qr-code-styling.com/
|
||||||
|
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
|
||||||
|
-->
|
||||||
|
<QRCodeVue3
|
||||||
|
:value="this.qrValue"
|
||||||
|
:cornersSquareOptions="{ type: 'extra-rounded' }"
|
||||||
|
:dotsOptions="{ type: 'square' }"
|
||||||
|
class="flex justify-center"
|
||||||
|
/>
|
||||||
|
<span class="flex justify-center">
|
||||||
|
Click QR to copy your contact URL to your clipboard.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center" v-else>
|
||||||
|
You have no identitifiers yet, so
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'start' }"
|
||||||
|
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
|
||||||
|
>
|
||||||
|
create your identifier.
|
||||||
|
</router-link>
|
||||||
|
<br />
|
||||||
|
If you don't that first, these contacts won't see your activity.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
|
||||||
|
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
|
||||||
|
<span>
|
||||||
|
If you do not see a scanning camera window here, check your camera
|
||||||
|
permissions.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
|
||||||
import { accountsDB, db } from "@/db";
|
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import * as R from "ramda";
|
|
||||||
import { SimpleSigner } from "@/libs/crypto";
|
|
||||||
import * as didJwt from "did-jwt";
|
import * as didJwt from "did-jwt";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||||
|
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||||
|
import * as R from "ramda";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { QrcodeStream } from "vue-qrcode-reader";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
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_URL_PREFIX,
|
||||||
|
ENDORSER_JWT_URL_LOCATION,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const Buffer = require("buffer/").Buffer;
|
const Buffer = require("buffer/").Buffer;
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
|
QrcodeStream,
|
||||||
QRCodeVue3,
|
QRCodeVue3,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class ContactQRScanShow extends Vue {
|
export default class ContactQRScanShow extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
|
givenName = "";
|
||||||
qrValue = "";
|
qrValue = "";
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
const account: Account | undefined = R.find(
|
const account: Account | undefined = R.find(
|
||||||
@@ -56,7 +123,7 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Attempted to load Give records with no identity available.",
|
"Attempted to show contact info with no identifier available.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
@@ -67,30 +134,32 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
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 || "";
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.givenName = settings?.firstName || "";
|
||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||||
if (!account) {
|
if (account) {
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "warning",
|
|
||||||
title: "",
|
|
||||||
text: "You have no identity yet.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
const publicKeyHex = identity.keys[0].publicKeyHex;
|
const publicKeyHex = identity.keys[0].publicKeyHex;
|
||||||
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
||||||
|
|
||||||
|
const newDerivPath = nextDerivationPath(account.derivationPath);
|
||||||
|
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
|
||||||
|
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
|
||||||
|
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
|
||||||
|
const nextPublicEncKeyHashBase64 =
|
||||||
|
Buffer.from(nextPublicEncKeyHash).toString("base64");
|
||||||
|
|
||||||
const contactInfo = {
|
const contactInfo = {
|
||||||
iat: Date.now(),
|
iat: Date.now(),
|
||||||
iss: this.activeDid,
|
iss: this.activeDid,
|
||||||
own: {
|
own: {
|
||||||
name: (settings?.firstName || "") + " " + (settings?.lastName || ""),
|
name:
|
||||||
|
(settings?.firstName || "") +
|
||||||
|
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
||||||
publicEncKey,
|
publicEncKey,
|
||||||
|
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,9 +172,64 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
issuer: identity.did,
|
issuer: identity.did,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
});
|
});
|
||||||
const viewPrefix = "https://endorser.ch/contact?jwt=";
|
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
|
||||||
this.qrValue = viewPrefix + vcJwt;
|
this.qrValue = viewPrefix + vcJwt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onScanDetect(content: any) {
|
||||||
|
if (content[0]?.rawValue) {
|
||||||
|
//console.log("onDetect", content[0].rawValue);
|
||||||
|
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
|
||||||
|
this.$router.push({ name: "contacts" });
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Contact QR Code",
|
||||||
|
text: "No QR code detected with contact information.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onScanError(error: any) {
|
||||||
|
console.log("Scan was invalid:", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Scan",
|
||||||
|
text: "The scan was invalid.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCopyToClipboard() {
|
||||||
|
useClipboard()
|
||||||
|
.copy(this.qrValue)
|
||||||
|
.then(() => {
|
||||||
|
console.log("Contact URL:", this.qrValue);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
title: "Copied",
|
||||||
|
text: "Contact URL was copied to clipboard.",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Discover"></QuickNav>
|
<QuickNav selected="Discover"></QuickNav>
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<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
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Quick Search -->
|
<!-- Quick Search -->
|
||||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="search()">
|
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchSelected()">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="searchTerms"
|
v-model="searchTerms"
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="search()"
|
@click="searchSelected()"
|
||||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||||
>
|
>
|
||||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
href="#"
|
href="#"
|
||||||
@click="
|
@click="
|
||||||
projects = [];
|
projects = [];
|
||||||
|
isLocalActive = true;
|
||||||
|
isRemoteActive = false;
|
||||||
searchLocal();
|
searchLocal();
|
||||||
"
|
"
|
||||||
v-bind:class="computedLocalTabClassNames()"
|
v-bind:class="computedLocalTabClassNames()"
|
||||||
@@ -39,8 +42,10 @@
|
|||||||
Nearby
|
Nearby
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||||
>{{ localCount }}</span
|
v-if="isLocalActive"
|
||||||
>
|
>
|
||||||
|
{{ localCount > -1 ? localCount : "?" }}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -49,19 +54,34 @@
|
|||||||
v-bind:class="computedRemoteTabClassNames()"
|
v-bind:class="computedRemoteTabClassNames()"
|
||||||
@click="
|
@click="
|
||||||
projects = [];
|
projects = [];
|
||||||
search();
|
isRemoteActive = true;
|
||||||
|
isLocalActive = false;
|
||||||
|
searchAll();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Remote
|
Anywhere
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||||
>{{ remoteCount }}</span
|
v-if="isRemoteActive"
|
||||||
>
|
>
|
||||||
|
{{ remoteCount > -1 ? remoteCount : "?" }}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLocalActive">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
|
@click="$router.push({ name: 'search-area' })"
|
||||||
|
>
|
||||||
|
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Animation -->
|
<!-- Loading Animation -->
|
||||||
<div
|
<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"
|
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||||
@@ -83,11 +103,11 @@
|
|||||||
class="block py-4 flex gap-4"
|
class="block py-4 flex gap-4"
|
||||||
>
|
>
|
||||||
<div class="w-12">
|
<div class="w-12">
|
||||||
<EntityIcon
|
<ProjectIcon
|
||||||
:entityId="project.handleId"
|
:entityId="project.handleId"
|
||||||
:iconSize="48"
|
:iconSize="48"
|
||||||
class="block border border-slate-300 rounded-md"
|
class="block border border-slate-300 rounded-md"
|
||||||
></EntityIcon>
|
></ProjectIcon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
@@ -103,43 +123,54 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</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, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import { didInfo } from "@/libs/endorserServer";
|
import { didInfo, ProjectData } from "@/libs/endorserServer";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
import InfiniteScroll from "@/components/InfiniteScroll";
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon";
|
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { AlertMessage, QuickNav, InfiniteScroll, EntityIcon },
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
InfiniteScroll,
|
||||||
|
ProjectIcon,
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export default class DiscoverView extends Vue {
|
export default class DiscoverView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
searchTerms = "";
|
searchTerms = "";
|
||||||
alertMessage = "";
|
|
||||||
alertTitle = "";
|
|
||||||
projects: ProjectData[] = [];
|
projects: ProjectData[] = [];
|
||||||
|
isLoading = false;
|
||||||
isLocalActive = true;
|
isLocalActive = true;
|
||||||
isRemoteActive = false;
|
isRemoteActive = false;
|
||||||
localCount = 0;
|
localCount = -1;
|
||||||
remoteCount = 0;
|
remoteCount = -1;
|
||||||
isLoading = false;
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||||
|
|
||||||
// make this function available to the Vue template
|
// make this function available to the Vue template
|
||||||
didInfo = didInfo;
|
didInfo = didInfo;
|
||||||
@@ -149,17 +180,40 @@ export default class DiscoverView extends Vue {
|
|||||||
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 || "";
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||||
|
|
||||||
this.allContacts = await db.contacts.toArray();
|
this.allContacts = await db.contacts.toArray();
|
||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
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);
|
||||||
|
|
||||||
this.searchLocal();
|
if (this.searchBox) {
|
||||||
|
await this.searchLocal();
|
||||||
|
} else {
|
||||||
|
this.isLocalActive = false;
|
||||||
|
this.isRemoteActive = true;
|
||||||
|
await this.searchAll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async buildHeaders() {
|
public resetCounts() {
|
||||||
const headers = { "Content-Type": "application/json" };
|
this.localCount = -1;
|
||||||
|
this.remoteCount = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchSelected() {
|
||||||
|
if (this.isLocalActive) {
|
||||||
|
await this.searchLocal();
|
||||||
|
} else {
|
||||||
|
await this.searchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async buildHeaders(): Promise<HeadersInit> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
if (this.activeDid) {
|
if (this.activeDid) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
@@ -169,7 +223,7 @@ export default class DiscoverView extends Vue {
|
|||||||
|
|
||||||
if (!identity) {
|
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.",
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +234,20 @@ export default class DiscoverView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(beforeId?: string) {
|
public async searchAll(beforeId?: string) {
|
||||||
|
this.resetCounts();
|
||||||
|
|
||||||
|
if (!beforeId) {
|
||||||
|
// this was an initial search so clear any previous results
|
||||||
|
this.projects = [];
|
||||||
|
}
|
||||||
|
|
||||||
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
||||||
|
|
||||||
if (beforeId) {
|
if (beforeId) {
|
||||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isRemoteActive = true;
|
|
||||||
this.isLocalActive = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -202,12 +260,13 @@ export default class DiscoverView extends Vue {
|
|||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
const details = await response.text();
|
const details = await response.text();
|
||||||
|
console.log("Problem with full search:", details);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
text: `There was a problem accessing the server. Try again later.`,
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -220,14 +279,15 @@ export default class DiscoverView extends Vue {
|
|||||||
const plans: ProjectData[] = results.data;
|
const plans: ProjectData[] = results.data;
|
||||||
if (plans) {
|
if (plans) {
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { name, description, handleId, rowid, issuerDid } = plan;
|
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||||
this.projects.push({ name, description, handleId, rowid, issuerDid });
|
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||||
}
|
}
|
||||||
this.remoteCount = this.projects.length;
|
this.remoteCount = this.projects.length;
|
||||||
} else {
|
} else {
|
||||||
throw JSON.stringify(results);
|
throw JSON.stringify(results);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (e: any) {
|
||||||
console.log("Error with feed load:", e);
|
console.log("Error with feed load:", e);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
@@ -244,14 +304,27 @@ export default class DiscoverView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async searchLocal(beforeId?: string) {
|
public async searchLocal(beforeId?: string) {
|
||||||
|
this.resetCounts();
|
||||||
|
|
||||||
|
if (!this.searchBox) {
|
||||||
|
this.projects = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beforeId) {
|
||||||
|
// this was an initial search so clear any previous results
|
||||||
|
this.projects = [];
|
||||||
|
}
|
||||||
|
|
||||||
const claimContents =
|
const claimContents =
|
||||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||||
|
|
||||||
let queryParams = [
|
let queryParams = [
|
||||||
claimContents,
|
claimContents,
|
||||||
"minLocLat=40.901000",
|
"minLocLat=" + this.searchBox.bbox.minLat,
|
||||||
"maxLocLat=40.904000",
|
"maxLocLat=" + this.searchBox.bbox.maxLat,
|
||||||
"westLocLon=-111.914000",
|
"westLocLon=" + this.searchBox.bbox.westLong,
|
||||||
"eastLocLon=-111.909000",
|
"eastLocLon=" + this.searchBox.bbox.eastLong,
|
||||||
].join("&");
|
].join("&");
|
||||||
|
|
||||||
if (beforeId) {
|
if (beforeId) {
|
||||||
@@ -260,8 +333,6 @@ export default class DiscoverView extends Vue {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.isLocalActive = true;
|
|
||||||
this.isRemoteActive = false;
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
||||||
{
|
{
|
||||||
@@ -272,12 +343,13 @@ export default class DiscoverView extends Vue {
|
|||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
const details = await response.text();
|
const details = await response.text();
|
||||||
|
console.log("Problem with nearby search:", details);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
text: "There was a problem accessing the server. Try again later.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -290,10 +362,14 @@ export default class DiscoverView extends Vue {
|
|||||||
if (beforeId) {
|
if (beforeId) {
|
||||||
const plans: ProjectData[] = results.data;
|
const plans: ProjectData[] = results.data;
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { name, description, handleId = plan.handleId, rowid } = plan;
|
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||||
if (beforeId !== plan["rowid"]) {
|
this.projects.push({
|
||||||
this.projects.push({ name, description, handleId, rowid });
|
name,
|
||||||
}
|
description,
|
||||||
|
handleId,
|
||||||
|
issuerDid,
|
||||||
|
rowid,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.projects = results.data;
|
this.projects = results.data;
|
||||||
@@ -302,7 +378,8 @@ export default class DiscoverView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
throw JSON.stringify(results);
|
throw JSON.stringify(results);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (e: any) {
|
||||||
console.log("Error with feed load:", e);
|
console.log("Error with feed load:", e);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
@@ -328,7 +405,7 @@ export default class DiscoverView extends Vue {
|
|||||||
if (this.isLocalActive) {
|
if (this.isLocalActive) {
|
||||||
this.searchLocal(latestProject["rowid"]);
|
this.searchLocal(latestProject["rowid"]);
|
||||||
} else if (this.isRemoteActive) {
|
} else if (this.isRemoteActive) {
|
||||||
this.search(latestProject["rowid"]);
|
this.searchAll(latestProject["rowid"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +417,7 @@ export default class DiscoverView extends Vue {
|
|||||||
onClickLoadProject(id: string) {
|
onClickLoadProject(id: string) {
|
||||||
localStorage.setItem("projectId", id);
|
localStorage.setItem("projectId", id);
|
||||||
const route = {
|
const route = {
|
||||||
name: "project",
|
path: "/project/" + encodeURIComponent(id),
|
||||||
};
|
};
|
||||||
this.$router.push(route);
|
this.$router.push(route);
|
||||||
}
|
}
|
||||||
|
|||||||
417
src/views/HelpNotificationsView.vue
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- 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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Notification Help
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable prettier/prettier -->
|
||||||
|
<div>
|
||||||
|
<p>Here are ways to test notifications and get them working.</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Full Test</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
If this works then you're all set.
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Send Yourself a Test Web Push Message (Through Push Server but
|
||||||
|
Skipping Client Filter)
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">
|
||||||
|
If this app doesn't support notifications...
|
||||||
|
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
To be notified of interesting updates, install this app on your device
|
||||||
|
(as opposed to using it inside the browser app). In Chrome, it may prompt
|
||||||
|
you, and you can also look for the "Install" command in the browser
|
||||||
|
settings; on the the desktop, look for this icon in the address bar:
|
||||||
|
<img
|
||||||
|
src="../assets/help/chrome-install-pwa.png"
|
||||||
|
alt="Chrome 'install' icon"
|
||||||
|
class="ml-4"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">
|
||||||
|
If you must enable notifications...
|
||||||
|
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<button class="text-blue-500" @click="showNotificationChoice()">
|
||||||
|
Click here.
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">
|
||||||
|
If you're waiting for system initialization...
|
||||||
|
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
... and it never stops, then there is a problem with the underlying
|
||||||
|
service worker or push server mechanism in your browser. Your best bet
|
||||||
|
is to follow the "Reinstall" steps below or use a different browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Check App Permissions</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
In Apple iOS, check "Settings" -> "Notifications", look for the Time
|
||||||
|
Safari app (or the browser you're using), and make sure notifications
|
||||||
|
are enabled.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In Android, hold on to the app icon, then select "App Info", then
|
||||||
|
"Notifications" and make sure they're enabled. If it's still a problem
|
||||||
|
then go further:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you installed the app with Chrome, make sure there are no other
|
||||||
|
tabs with it open. Here are some ways to clear caches that can mess
|
||||||
|
things up (and note that this clears out data from the installed app
|
||||||
|
-- which is good to do while the app is installed):
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li class="list-disc ml-4">
|
||||||
|
Go to Chrome "App Info", then "Storage & Cache" and "Clear Storage".
|
||||||
|
</li>
|
||||||
|
<li class="list-disc ml-4">
|
||||||
|
Go to Chrome "Settings", then "Privacy and Security" and "Clear
|
||||||
|
"Clear browsing data", then "Cookies and site data". Make sure the
|
||||||
|
"Time Range" at the top shows "All time".
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
On a Mac, go to "Settings" and check "Notifications".
|
||||||
|
<img
|
||||||
|
src="../assets/help/mac-installed-app-settings.png"
|
||||||
|
alt="Mac app settings"
|
||||||
|
class="ml-4"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Check Browser Permissions</h2>
|
||||||
|
<div>
|
||||||
|
<p>In Apple iOS, check Settings -> Notifications.</p>
|
||||||
|
<p>In Android, check Settings -> Notifications.</p>
|
||||||
|
|
||||||
|
You can find more details about compatibility
|
||||||
|
<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
|
||||||
|
class="text-blue-500"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">
|
||||||
|
Check Operating System (OS) Permissions
|
||||||
|
</h2>
|
||||||
|
<div class="px-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Mobile Phone - Apple iOS</h3>
|
||||||
|
<div>
|
||||||
|
Notifications require iOS 16.4 or higher. To check your iOS version,
|
||||||
|
go to Settings > General > About > Software Version.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold">Mobile Phone - Google Android</h3>
|
||||||
|
<div>
|
||||||
|
We recommend Chrome. It must be version 42 or higher. Check your
|
||||||
|
version under Settings -> About Chrome.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold">Desktop - Mac</h3>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
See "System Settings" -> "Notifications" and make sure it is
|
||||||
|
enabled for the browser you're using. Note that these
|
||||||
|
notifications require Mac OS 13; see your macOS version under
|
||||||
|
Apple -> "About This Mac".
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold">Desktop - Windows</h3>
|
||||||
|
In Windows, check "Settings" -> "Notifications".
|
||||||
|
<img
|
||||||
|
src="../assets/help/windows-system-enable-notifications.png"
|
||||||
|
alt="Windows system settings"
|
||||||
|
class="ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
You can find more details about compatibility
|
||||||
|
<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
|
||||||
|
class="text-blue-500"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Reinstall</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
If all else fails, uninstall the app, ensure all the browser tabs with
|
||||||
|
it are closed, and clear out caches and storage.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Of course, you'll want to back up all your data first -- all seeds as
|
||||||
|
well as the contacts & settings -- on the Account
|
||||||
|
<fa icon="circle-user" /> page.
|
||||||
|
</p>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li>
|
||||||
|
Clear cache.
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
In mobile, look for the browser app settings. This is true even
|
||||||
|
for an installed app: go to the browser which you used to
|
||||||
|
initially visit timesafari.app, because those settings affect
|
||||||
|
the app. Look for "Delete browsing data" in the "Settings",
|
||||||
|
under "Privacy and Security".
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
In Chrome, go to `chrome://settings/cookies` and "all site data
|
||||||
|
and permissions" for timesafari.app; in Firefox, go to
|
||||||
|
`about:preferences` and search for "cache" then "Manage Data"
|
||||||
|
for timesafari.app. Also manually remove the IndexedDB data if
|
||||||
|
the DBs still show.)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Clear notification permission. (In Chrome, go to
|
||||||
|
`chrome://settings/content/notifications`; in Firefox, go to
|
||||||
|
`about:preferences` and search for "notifications".)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Unregister service worker. (In Chrome, go to
|
||||||
|
`chrome://serviceworker-internals/`; in Firefox, go to
|
||||||
|
`about:serviceworkers`.)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Clear "Cache Storage". (In Chrome, in dev tools under "Application";
|
||||||
|
in Firefox, in dev tools under "Storage".)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Then reinstall the app.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">Tests</h2>
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Send Test Notification Directly to Device (Not Through Push Server)
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
If that didn't show a notification on your device, the problem is that
|
||||||
|
your browser or your operating system are not allowing notifications
|
||||||
|
through. See "Check App Permissions" and "Check Browser Permissions" and
|
||||||
|
"Check Operating System (OS) Permissions" above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Show Web Push Subscription Info
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
If that showed "null" then the notification is not active.
|
||||||
|
<button class="text-blue-500" @click="showNotificationChoice()">
|
||||||
|
Click here.
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Send Yourself a Test Web Push Message (Through Push Server but Skipping
|
||||||
|
Client Filter)
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
If that didn't show a notification on your device, there is a problem
|
||||||
|
getting to the push server. Disable notifications and then enable them
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
Send Yourself a Test Web Push Message (Through Push Server and Client
|
||||||
|
Filter)
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
If you don't see a message, it could be that there is nothing new for
|
||||||
|
you to see. If the previous test worked, then things should work fine.
|
||||||
|
If you notice a full 24 hours where you get no notification and you know
|
||||||
|
that there are new items that should show, gather as many details as
|
||||||
|
possible and go to the bottom of
|
||||||
|
<router-link to="help" class="text-blue-500"> this page </router-link>
|
||||||
|
for ways to contact us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-enable -->
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import { sendTestThroughPushServer } from "@/libs/util";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ components: { QuickNav } })
|
||||||
|
export default class HelpNotificationsView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
subscription: PushSubscription | null = null;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
this.subscription = await registration.pushManager.getSubscription();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Mount error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alertWebPushSubscription() {
|
||||||
|
console.log(
|
||||||
|
"Web push subscription:",
|
||||||
|
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging
|
||||||
|
);
|
||||||
|
alert(JSON.stringify(this.subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestWebPushMessage(skipFilter: boolean = false) {
|
||||||
|
if (!this.subscription) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Not Subscribed",
|
||||||
|
// Note that this exact verbiage shows in help text.
|
||||||
|
text: "You must enable notifications before testing the web push.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendTestThroughPushServer(this.subscription, skipFilter);
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Test Web Push Sent",
|
||||||
|
text:
|
||||||
|
"Check your device for the test web push message" +
|
||||||
|
(skipFilter ? "." : " if there are new items in your feed."),
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Got an error sending test notification:", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Sending Test",
|
||||||
|
text: "Got an error sending the test web push notification.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showTestNotification() {
|
||||||
|
const TEST_NOTIFICATION_TITLE = "It Worked";
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
return registration.showNotification(TEST_NOTIFICATION_TITLE, {
|
||||||
|
body: "This is your test notification.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Sent",
|
||||||
|
text: `A notification was triggered, so one should show on your device entitled '${TEST_NOTIFICATION_TITLE}'.`,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Got a notification error:", error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Failed",
|
||||||
|
text: "Got an error sending a notification.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotificationChoice() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "notification-permission",
|
||||||
|
title: "", // unused, only here to satisfy type check
|
||||||
|
text: "", // unused, only here to satisfy type check
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,31 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav />
|
||||||
<!-- CONTENT -->
|
|
||||||
<section id="Content" class="p-6 pb-24">
|
|
||||||
<!-- Heading -->
|
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
|
||||||
Help
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- 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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Help
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable prettier/prettier -->
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
This app is a window into data that you and your friends own, focused on
|
This app is a window into data that you and your friends own, focused on
|
||||||
gifts and collaboration.
|
gifts and collaboration.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">What is the philosophy here?</h2>
|
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||||
<p>
|
<p>
|
||||||
We are building networks of people who want to grow a gifting society.
|
We are building networks of people who want to grow a giving society.
|
||||||
First of all, you can record ways you've seen people give, and that
|
First of all, you can see what people have given, and also recognize
|
||||||
leaves a permanent record -- one that came from you, and the recipient
|
gifts you've seen, in a way that leaves a permanent record -- one that
|
||||||
can prove it was for them. This is personally gratifying, but it extends
|
came from you, and the recipient can prove it was for them. This is
|
||||||
to broader work: volunteers can get confirmation of activity and
|
personally gratifying, but it extends to broader work: volunteers get
|
||||||
selectively show off their contributions and network.
|
confirmation of activity, and selectively show off their contributions
|
||||||
|
and network.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can also record projects and plans and invite others to collaborate.
|
You can show giving and also offer help to ideas, based on others'
|
||||||
Soon you'll be able to see when others are interested and see how much
|
willingness to help out, too. You can record your own ideas and invite
|
||||||
they're willing to contribute, even if there are conditions.
|
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
|
||||||
@@ -36,19 +52,50 @@
|
|||||||
the control; this app gives you the control.
|
the control; this app gives you the control.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I take my first action?</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 -- usually the person who told you
|
||||||
about this app, on the Contacts
|
about this app, on the Contacts
|
||||||
<fa icon="circle-user" class="fa-fw" /> page. After they register you,
|
<fa icon="users" class="fa-fw" /> page. After they register you, you can
|
||||||
and after you have contacts, you can select any contact on the home page
|
select any contact on the home page (or "anonymous") and record your
|
||||||
and record your appreciation for... whatever. That is a claim recorded
|
appreciation for... whatever. The main goal is to record what people
|
||||||
on a custom ledger. The day after being registered, you'll be able to
|
have given you, to grow giving economies. Each claim is recorded on a
|
||||||
|
custom ledger. The day after being registered, you'll be able to able to
|
||||||
register others; later, you can create projects, too.
|
register others; later, you can create projects, too.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Note that there are limits to how many each person can register, so you
|
Note that there are rate limits to how many others you can register,
|
||||||
may have to wait.
|
so it may take some time to register everyone you want. Take your time...
|
||||||
|
make it an opportunity to get to know their projects, and show your own.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
I had an identifier, but I reinstalled and I got a new one automatically.
|
||||||
|
How do I restore my old one?
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Go
|
||||||
|
<router-link class="text-blue-500" to="/import-account">import your identifier</router-link>.
|
||||||
|
If you don't want the old one, click "Advanced" and check the box to erase it.
|
||||||
|
(The erase option only shows if you have exactly one identifier.
|
||||||
|
For more in-depth surgery, you'll have to erase data from the browser or reinstall.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
||||||
|
<p>
|
||||||
|
<button class="text-blue-500" @click="showOnboardInfo">
|
||||||
|
Click here to show an alert with the steps.
|
||||||
|
</button>
|
||||||
|
To start scanning, go
|
||||||
|
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If they are not nearby to scan QR codes, tell them to copy their ID from
|
||||||
|
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
|
||||||
|
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>
|
||||||
@@ -61,7 +108,7 @@
|
|||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
How do I backup my identifier (secret) data?
|
How do I backup my identifier (secret) data?
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>
|
<li>
|
||||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
||||||
</li>
|
</li>
|
||||||
@@ -77,7 +124,7 @@
|
|||||||
<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 other (non-identifier-secret) data?
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>
|
<li>
|
||||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
||||||
</li>
|
</li>
|
||||||
@@ -91,7 +138,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
<p>
|
<p>
|
||||||
There are two parts to restore your data: the identity secrets and the
|
There are two steps to restore your data: the identity secrets, then the
|
||||||
other data such as settings, contacts, etc.
|
other data such as settings, contacts, etc.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -99,7 +146,7 @@
|
|||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
How do I restore my identifier (secret) data?
|
How do I restore my identifier (secret) data?
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>
|
<li>
|
||||||
<router-link class="text-blue-500" to="/import-account">
|
<router-link class="text-blue-500" to="/import-account">
|
||||||
Go to the import page
|
Go to the import page
|
||||||
@@ -111,80 +158,199 @@
|
|||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
How do I restore my other (non-identifier-secret) data?
|
How do I restore my other (non-identifier-secret) data?
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>Make sure you have your backup file (above), then contact us.</li>
|
<li>
|
||||||
|
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
|
||||||
|
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
|
||||||
How do I add someone to my contacts?
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
Tell them to copy their ID, which typically starts with "did:ethr:...",
|
|
||||||
and send it to you. Go to the Contacts
|
|
||||||
<fa icon="circle-user" class="fa-fw" /> page and enter that into the top
|
|
||||||
form. You may add a name by adding a comma followed by their name; you
|
|
||||||
may also add their public key by adding another comma followed by the
|
|
||||||
key.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
||||||
<p>
|
<p>
|
||||||
Go
|
Before doing this, note that it is an advanced feature that affects
|
||||||
|
functionality (eg. the words "Alt ID" next to results, backup features)
|
||||||
|
so beware. You can
|
||||||
<router-link to="start" class="text-blue-500">
|
<router-link to="start" class="text-blue-500">
|
||||||
create another identity here.
|
create another identity here.
|
||||||
</router-link>
|
</router-link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
|
||||||
|
<p>
|
||||||
|
Before doing this, note the two kinds of data to backup: identity data,
|
||||||
|
and other data for contacts and settings (see instructions above).
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Mobile
|
||||||
|
<ul>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
|
||||||
|
</li>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
DuckDuckGo: long hold -> Clear Data (takes effect immediately)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Desktop
|
||||||
|
<ul>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Chrome:
|
||||||
|
<a href="chrome://settings/content/all" class="text-blue-500"
|
||||||
|
>clear here</a
|
||||||
|
>
|
||||||
|
also clear under dev tools Application
|
||||||
|
</li>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Firefox: <a href="about:preferences">go here</a>, Manage Data,
|
||||||
|
find timesafari.app and select, hit Remove Selected, then Save
|
||||||
|
Changes
|
||||||
|
</li>
|
||||||
|
<li class="list-disc list-outside ml-4">
|
||||||
|
Safari: Settings -> Privacy -> Manage Website Data, search for
|
||||||
|
timesafari.app and select, hit Remove Selected, then Done.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>To erase your data from our servers, contact us (below).</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>
|
||||||
<p>
|
<p>
|
||||||
If you don't see anything associated with a person, this is typically
|
If you don't see anything associated with a person, this is typically
|
||||||
because they have not given you permission to see their information. Ask
|
because they have not given you permission to see their information. Ask
|
||||||
them to add you to their contact list and make sure the eye next to your
|
them to add you to their contact list, and ask specifically to make sure
|
||||||
name is open like this
|
the eye next to your name is open like this
|
||||||
<fa icon="eye" class="fa-fw" /> and not closed like this
|
<fa icon="eye" class="fa-fw" /> and not closed like this
|
||||||
<fa icon="eye-slash" class="fa-fw" />.
|
<fa icon="eye-slash" class="fa-fw" />.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Sometimes the reason you don't see something is because the search time
|
Sometimes the reason you don't see something is because the search
|
||||||
is limited. Go to the bottom and make sure to load all the data on a
|
results are limited. Go to the bottom and make sure to load all the data
|
||||||
list. If you still don't see it, try a search or view on a different
|
on a list. If you still don't see it, try a search or view on a
|
||||||
page.
|
different page.
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
|
|
||||||
<p>
|
|
||||||
See
|
|
||||||
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
|
|
||||||
the Endorser Service Privacy Policy.
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
|
||||||
<p>
|
|
||||||
{{ package.version }}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">
|
||||||
For any other questions, including remove your data:
|
Where do I get help with notifications?
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Contact us through
|
<router-link class="text-blue-500" to="/help-notifications"
|
||||||
<a href="https://communitycred.org">CommunityCred.org</a>.
|
>Here.</router-link
|
||||||
|
>
|
||||||
|
</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" class="text-blue-500">
|
||||||
|
EndorserSearch.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||||
|
<p style="display:inline; align-items: center">
|
||||||
|
This work is marked with
|
||||||
|
<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>
|
||||||
|
<img
|
||||||
|
src="../assets/help/creative-commons-circle.svg"
|
||||||
|
alt="CC circle"
|
||||||
|
width="20"
|
||||||
|
class="display: inline"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="../assets/help/creative-commons-zero.svg"
|
||||||
|
alt="CC zero"
|
||||||
|
width="20"
|
||||||
|
style="display: inline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
For notifications, this service stores push token data; that can be revoked at any time
|
||||||
|
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
|
||||||
|
<br />
|
||||||
|
For all other claim data,
|
||||||
|
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
|
||||||
|
the Endorser Service has this Privacy Policy.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
||||||
|
<p>
|
||||||
|
This is part of the
|
||||||
|
<a href="https://livesofgiving.org" class="text-blue-500">
|
||||||
|
Lives of Giving
|
||||||
|
</a>
|
||||||
|
initiative.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||||
|
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
For any other questions, including removing your data:
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Contact us at
|
||||||
|
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
|
||||||
|
>info@TimeSafari.app</a
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- eslint enable -->
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Package from "../../package.json";
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import QuickNav from "@/components/QuickNav";
|
|
||||||
|
import * as Package from "../../package.json";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import { ONBOARD_MESSAGE } from "@/libs/util";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class Help extends Vue {
|
export default class Help extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
package = Package;
|
package = Package;
|
||||||
|
commitHash = process.env.VUE_APP_GIT_HASH;
|
||||||
|
|
||||||
|
showOnboardInfo() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Onboard Someone",
|
||||||
|
text: ONBOARD_MESSAGE,
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,259 +1,289 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Home"></QuickNav>
|
<QuickNav selected="Home"></QuickNav>
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<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">
|
||||||
Time Safari
|
Time Safari
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<!-- prompt to install notifications -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
<div
|
||||||
|
v-if="!notificationsSupported()"
|
||||||
<button
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||||
@click="
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: 'alert',
|
|
||||||
type: 'toast',
|
|
||||||
text: 'I\'m a toast. Don\'t mind me.',
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2"
|
|
||||||
>
|
>
|
||||||
Toast (self-dismiss)
|
<p style="display: inline; align-items: center">
|
||||||
</button>
|
This currently doesn't support notifications, so let's fix that.
|
||||||
|
<br />
|
||||||
|
<!-- Note that that exact verbiage shows in the help. -->
|
||||||
|
|
||||||
<button
|
<span v-if="userAgentInfo.getOS().name === 'iOS'">
|
||||||
@click="
|
Tap on "Share"<img
|
||||||
this.$notify(
|
src="../assets/help/apple-share-icon.svg"
|
||||||
{
|
alt="Apple 'share' icon"
|
||||||
group: 'alert',
|
width="30"
|
||||||
type: 'info',
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
||||||
title: 'Information Alert',
|
/>and then "Add to Home Screen"
|
||||||
text: 'Just wanted you to know.',
|
<fa icon="square-plus" title="Apple 'Add' icon" />
|
||||||
},
|
and go click on that new app.
|
||||||
-1,
|
</span>
|
||||||
)
|
<span
|
||||||
"
|
v-else-if="userAgentInfo.getBrowser().name.startsWith('Chrome')"
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
>
|
||||||
>
|
You should see a prompt to install, or you can click on the
|
||||||
Info
|
top-right dots
|
||||||
</button>
|
<fa
|
||||||
|
icon="ellipsis-vertical"
|
||||||
<button
|
title="vertical ellipsis"
|
||||||
@click="
|
class="fa-fw"
|
||||||
this.$notify(
|
/>
|
||||||
{
|
and then "Install"<img
|
||||||
group: 'alert',
|
src="../assets/help/install-android-chrome.png"
|
||||||
type: 'success',
|
alt="Android 'install' icon"
|
||||||
title: 'Success Alert',
|
width="30"
|
||||||
text: 'Congratulations!',
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
||||||
},
|
/>
|
||||||
-1,
|
and go use that app. If you already did these steps, reload this app
|
||||||
)
|
so that it is fully detected.
|
||||||
"
|
</span>
|
||||||
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
<span v-else>
|
||||||
>
|
Try
|
||||||
Success
|
<a href="https://www.google.com/chrome/" class="text-blue-500"
|
||||||
</button>
|
>Google Chrome</a
|
||||||
|
>
|
||||||
<button
|
or look for a way to install as an app from this browser.
|
||||||
@click="
|
</span>
|
||||||
this.$notify(
|
</p>
|
||||||
{
|
</div>
|
||||||
group: 'alert',
|
|
||||||
type: 'warning',
|
|
||||||
title: 'Warning Alert',
|
|
||||||
text: 'You might wanna look at this.',
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
|
||||||
>
|
|
||||||
Warning
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: 'alert',
|
|
||||||
type: 'danger',
|
|
||||||
title: 'Danger Alert',
|
|
||||||
text: 'Something terrible has happened!',
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
|
||||||
>
|
|
||||||
Danger
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: 'modal',
|
|
||||||
type: 'notification-permission',
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
|
||||||
>
|
|
||||||
Notification Permission
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- show the actions for recognizing a give -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-xl font-bold">Quick Action</h2>
|
<div v-if="isCreatingIdentifier">
|
||||||
<p class="mb-4">Show appreciation to a contact:</p>
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||||
|
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
||||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
</p>
|
||||||
<li @click="openDialog()">
|
</div>
|
||||||
<EntityIcon
|
|
||||||
:entityId="Anonymous"
|
|
||||||
:iconSize="64"
|
|
||||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
|
||||||
></EntityIcon>
|
|
||||||
<h3
|
|
||||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
|
||||||
>
|
|
||||||
Anonymous
|
|
||||||
</h3>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
v-for="contact in allContacts"
|
|
||||||
:key="contact.did"
|
|
||||||
@click="openDialog(contact)"
|
|
||||||
>
|
|
||||||
<EntityIcon
|
|
||||||
:entityId="contact.did"
|
|
||||||
:iconSize="64"
|
|
||||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
|
||||||
></EntityIcon>
|
|
||||||
<h3
|
|
||||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
|
||||||
>
|
|
||||||
{{ contact.name || contact.did }}
|
|
||||||
</h3>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
|
||||||
<router-link
|
|
||||||
v-if="allContacts.length > 7"
|
|
||||||
:to="{ name: 'contact-gives' }"
|
|
||||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
|
||||||
>
|
|
||||||
Show More Contacts…
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<!-- If there are no contacts, show this instead: -->
|
|
||||||
<div
|
<div
|
||||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
v-if="!activeDid && !isCreatingIdentifier"
|
||||||
v-if="allContacts.length === 0"
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||||
>
|
>
|
||||||
(No contacts to show.)
|
<p class="text-lg mb-3">
|
||||||
|
Want to connect with your contacts, or share contributions or
|
||||||
|
projects?
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
: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"
|
||||||
|
>
|
||||||
|
Create An Identifier</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!isRegistered"
|
||||||
|
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
|
||||||
|
giving.
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'contact-qr' }"
|
||||||
|
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 mb-4 px-2 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
Show Them Your Identifier Info</router-link
|
||||||
|
>
|
||||||
|
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
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- activeDid && isRegistered -->
|
||||||
|
<h2 class="text-xl font-bold mb-4">Record Something Given</h2>
|
||||||
|
|
||||||
|
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||||
|
<li @click="openDialog()">
|
||||||
|
<img
|
||||||
|
src="../assets/blank-square.svg"
|
||||||
|
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
Anonymous/Unnamed
|
||||||
|
</h3>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="contact in allContacts.slice(0, 7)"
|
||||||
|
:key="contact.did"
|
||||||
|
@click="openDialog(contact)"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
:entityId="contact.did"
|
||||||
|
:iconSize="64"
|
||||||
|
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
{{ contact.name || contact.did }}
|
||||||
|
</h3>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||||
|
<router-link
|
||||||
|
v-if="allContacts.length >= 7"
|
||||||
|
:to="{ name: 'contact-gives' }"
|
||||||
|
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
Show More Contacts…
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- If there are no contacts, show this instead: -->
|
||||||
|
<div
|
||||||
|
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||||
|
v-if="allContacts.length === 0"
|
||||||
|
>
|
||||||
|
(No contacts to show.)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GiftedDialog
|
<GiftedDialog
|
||||||
ref="customDialog"
|
ref="customDialog"
|
||||||
@dialog-result="handleDialogResult"
|
|
||||||
message="Received from"
|
message="Received from"
|
||||||
>
|
showGivenToUser="true"
|
||||||
</GiftedDialog>
|
/>
|
||||||
|
|
||||||
|
<!-- 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>
|
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
||||||
<div :class="{ hidden: isHiddenSpinner }">
|
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||||
|
<ul class="border-t border-slate-300">
|
||||||
|
<li
|
||||||
|
class="border-b border-slate-300 py-2"
|
||||||
|
v-for="record in feedData"
|
||||||
|
:key="record.jwtId"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||||
|
v-if="record.jwtId == feedLastViewedClaimId"
|
||||||
|
>
|
||||||
|
You've seen all the following before
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-12">
|
||||||
|
<span class="col-span-11 justify-self-start">
|
||||||
|
<fa
|
||||||
|
icon="gift"
|
||||||
|
class="col-span-1 pt-1 pr-2 text-slate-500"
|
||||||
|
></fa>
|
||||||
|
{{ this.giveDescription(record) }}
|
||||||
|
<a @click="onClickLoadClaim(record.jwtId)">
|
||||||
|
<fa
|
||||||
|
icon="circle-info"
|
||||||
|
class="pl-2 text-blue-500 cursor-pointer"
|
||||||
|
></fa>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span class="col-span-1 justify-self-end shrink">
|
||||||
|
<router-link
|
||||||
|
v-if="record.fulfillsPlanHandleId"
|
||||||
|
:to="
|
||||||
|
'/project/' +
|
||||||
|
encodeURIComponent(record.fulfillsPlanHandleId)
|
||||||
|
"
|
||||||
|
class="justify-end"
|
||||||
|
>
|
||||||
|
<fa
|
||||||
|
icon="hand-holding-heart"
|
||||||
|
class="ml-4 pl-2 text-blue-500"
|
||||||
|
></fa>
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfiniteScroll>
|
||||||
|
<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"></fa> Loading…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="border-t border-slate-300">
|
|
||||||
<li
|
|
||||||
class="border-b border-slate-300 py-2"
|
|
||||||
v-for="record in feedData"
|
|
||||||
:key="record.jwtId"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
|
||||||
v-if="record.jwtId == feedLastViewedId"
|
|
||||||
>
|
|
||||||
You've seen all claims below:
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
|
|
||||||
<!-- icon values: "coins" = money; "clock" = time; "gift" = others -->
|
|
||||||
<span class="">{{ this.giveDescription(record) }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { UAParser } from "ua-parser-js";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
import { db, accountsDB } from "@/db";
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
|
import { db, accountsDB } from "@/db/index";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import { accessToken } from "@/libs/crypto";
|
||||||
import EntityIcon from "@/components/EntityIcon";
|
import {
|
||||||
|
didInfo,
|
||||||
|
GiverInputInfo,
|
||||||
|
GiveServerRecord,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { IIdentifier } from "@veramo/core";
|
||||||
|
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
components: {
|
||||||
|
GiftedDialog,
|
||||||
|
QuickNav,
|
||||||
|
EntityIcon,
|
||||||
|
InfiniteScroll,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export default class HomeView extends Vue {
|
export default class HomeView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
feedAllLoaded = false;
|
feedData: GiveServerRecord[] = [];
|
||||||
feedData = [];
|
feedPreviousOldestId?: string;
|
||||||
feedPreviousOldestId = null;
|
feedLastViewedClaimId?: string;
|
||||||
feedLastViewedId = null;
|
isCreatingIdentifier = false;
|
||||||
isHiddenSpinner = true;
|
isFeedLoading = true;
|
||||||
alertTitle = "";
|
isRegistered = false;
|
||||||
alertMessage = "";
|
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
||||||
numAccounts = 0;
|
|
||||||
|
|
||||||
async beforeCreate() {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
const account = (await accountsDB.accounts
|
||||||
}
|
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
|
||||||
await accountsDB.open();
|
|
||||||
const account = await accountsDB.accounts
|
|
||||||
.where("did")
|
.where("did")
|
||||||
.equals(activeDid)
|
.equals(activeDid)
|
||||||
.first();
|
.first()) as Account;
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
return identity; // may be null
|
||||||
if (!identity) {
|
|
||||||
throw new Error(
|
|
||||||
"Attempted to load Give records with no identity available.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity) {
|
public async getHeaders(identity: IIdentifier) {
|
||||||
const token = await accessToken(identity);
|
const token = await accessToken(identity);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -269,13 +299,27 @@ export default class HomeView extends Vue {
|
|||||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||||
|
|
||||||
await db.open();
|
await db.open();
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
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.toArray();
|
this.allContacts = await db.contacts.toArray();
|
||||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
||||||
this.updateAllFeed();
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
} catch (err) {
|
|
||||||
|
if (this.allMyDids.length === 0) {
|
||||||
|
this.isCreatingIdentifier = true;
|
||||||
|
this.activeDid = await generateSaveAndActivateIdentity();
|
||||||
|
this.allMyDids = [this.activeDid];
|
||||||
|
this.isCreatingIdentifier = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// this returns a Promise but we don't need to wait for it
|
||||||
|
|
||||||
|
await this.updateAllFeed();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("Error retrieving settings or feed.", err);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -283,25 +327,33 @@ export default class HomeView extends Vue {
|
|||||||
title: "Error",
|
title: "Error",
|
||||||
text:
|
text:
|
||||||
err.userMessage ||
|
err.userMessage ||
|
||||||
"There was an error retrieving the latest sweet, sweet action.",
|
"There was an error retrieving your settings or the latest activity.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notificationsSupported() {
|
||||||
|
return "Notification" in window;
|
||||||
|
}
|
||||||
|
|
||||||
public async buildHeaders() {
|
public async buildHeaders() {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
if (this.activeDid) {
|
if (this.activeDid) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const allAccounts = await accountsDB.accounts.toArray();
|
const allAccounts = await accountsDB.accounts.toArray();
|
||||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
const account = allAccounts.find(
|
||||||
|
(acc) => acc.did === this.activeDid,
|
||||||
|
) as Account;
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
|
||||||
if (!identity) {
|
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.",
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,24 +364,33 @@ export default class HomeView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data loader used by infinite scroller
|
||||||
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||||
|
**/
|
||||||
|
public async loadMoreGives(payload: boolean) {
|
||||||
|
if (payload) {
|
||||||
|
this.updateAllFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async updateAllFeed() {
|
public async updateAllFeed() {
|
||||||
this.isHiddenSpinner = false;
|
this.isFeedLoading = true;
|
||||||
await this.retrieveClaims(this.apiServer, null, 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) {
|
||||||
this.feedData = this.feedData.concat(results.data);
|
this.feedData = this.feedData.concat(results.data);
|
||||||
this.feedAllLoaded = results.hitLimit;
|
|
||||||
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.
|
||||||
if (
|
if (
|
||||||
this.feedLastViewedId == null ||
|
this.feedLastViewedClaimId == null ||
|
||||||
this.feedLastViewedId < results.data[0].jwtId
|
this.feedLastViewedClaimId < results.data[0].jwtId
|
||||||
) {
|
) {
|
||||||
await db.open();
|
await db.open();
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
lastViewedClaimId: results.data[0].jwtId,
|
lastViewedClaimId: results.data[0].jwtId,
|
||||||
});
|
});
|
||||||
// but not for this page because we need to remember what it was before
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -339,20 +400,27 @@ export default class HomeView extends Vue {
|
|||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Export Error",
|
title: "Feed Error",
|
||||||
text: e.userMessage || "There was an error retrieving feed data.",
|
text: e.userMessage || "There was an error retrieving feed data.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
this.isFeedLoading = false;
|
||||||
this.isHiddenSpinner = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async retrieveClaims(endorserApiServer, identifier, beforeId) {
|
/**
|
||||||
|
* Retrieve claims in reverse chronological order
|
||||||
|
*
|
||||||
|
* @param beforeId the earliest ID (of previous searches) to search earlier
|
||||||
|
* @return claims in reverse chronological order
|
||||||
|
*/
|
||||||
|
public async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
|
endorserApiServer +
|
||||||
|
"/api/v2/report/gives?giftNotTrade=true&" +
|
||||||
|
beforeQuery,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: await this.buildHeaders(),
|
headers: await this.buildHeaders(),
|
||||||
@@ -372,24 +440,36 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
giveDescription(giveRecord) {
|
giveDescription(giveRecord: GiveServerRecord) {
|
||||||
let claim = giveRecord.fullClaim;
|
// similar code is in endorser-mobile utility.ts
|
||||||
if (claim.claim) {
|
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||||
claim = claim.claim;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
}
|
const claim = (giveRecord.fullClaim as any).claim || giveRecord.fullClaim;
|
||||||
// agent.did is for legacy data, before March 2023
|
// agent.did is for legacy data, before March 2023
|
||||||
const giverDid = claim.agent?.identifier || claim.agent?.did;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const giverDid = claim.agent?.identifier || (claim.agent as any)?.did;
|
||||||
const giverInfo = didInfo(
|
const giverInfo = didInfo(
|
||||||
giverDid,
|
giverDid,
|
||||||
this.activeDid,
|
this.activeDid,
|
||||||
this.allMyDids,
|
this.allMyDids,
|
||||||
this.allContacts,
|
this.allContacts,
|
||||||
);
|
);
|
||||||
const gaveAmount = claim.object?.amountOfThisGood
|
let gaveAmount = claim.object?.amountOfThisGood
|
||||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||||
: claim.description || "something unknown";
|
: "";
|
||||||
|
if (claim.description) {
|
||||||
|
if (gaveAmount) {
|
||||||
|
gaveAmount = gaveAmount + ", and also: ";
|
||||||
|
}
|
||||||
|
gaveAmount = gaveAmount + claim.description;
|
||||||
|
}
|
||||||
|
if (!gaveAmount) {
|
||||||
|
gaveAmount = "something not described";
|
||||||
|
}
|
||||||
// recipient.did is for legacy data, before March 2023
|
// recipient.did is for legacy data, before March 2023
|
||||||
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
|
const gaveRecipientId =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
claim.recipient?.identifier || (claim.recipient as any)?.did;
|
||||||
const gaveRecipientInfo = gaveRecipientId
|
const gaveRecipientInfo = gaveRecipientId
|
||||||
? " to " +
|
? " to " +
|
||||||
didInfo(
|
didInfo(
|
||||||
@@ -399,133 +479,26 @@ export default class HomeView extends Vue {
|
|||||||
this.allContacts,
|
this.allContacts,
|
||||||
)
|
)
|
||||||
: "";
|
: "";
|
||||||
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
|
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
displayAmount(code, amt) {
|
onClickLoadClaim(jwtId: string) {
|
||||||
|
const route = {
|
||||||
|
path: "/claim/" + encodeURIComponent(jwtId),
|
||||||
|
};
|
||||||
|
this.$router.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
displayAmount(code: string, amt: number) {
|
||||||
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
currencyShortWordForCode(unitCode, single) {
|
currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(giver) {
|
openDialog(giver: GiverInputInfo) {
|
||||||
this.$refs.customDialog.open(giver);
|
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||||
}
|
|
||||||
|
|
||||||
handleDialogResult(result) {
|
|
||||||
if (result.action === "confirm") {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.recordGive(result.giver?.did, result.description, result.hours);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// action was "cancel" so do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param giverDid may be null
|
|
||||||
* @param description may be an empty string
|
|
||||||
* @param hours may be 0
|
|
||||||
*/
|
|
||||||
public async recordGive(giverDid, description, hours) {
|
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identity before you can record a give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description && !hours) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must enter a description or some number of hours.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
|
||||||
const result = await createAndSubmitGive(
|
|
||||||
this.axios,
|
|
||||||
this.apiServer,
|
|
||||||
identity,
|
|
||||||
giverDid,
|
|
||||||
this.activeDid,
|
|
||||||
description,
|
|
||||||
hours,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.isGiveCreationError(result)) {
|
|
||||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
|
||||||
console.log("Error with give result:", result);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: errorMessage || "There was an error recording the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "That gift was recorded.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error with give caught:", error);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text:
|
|
||||||
this.getGiveErrorMessage(error) ||
|
|
||||||
"There was an error recording the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setAlert(title, message) {
|
|
||||||
this.alertTitle = title;
|
|
||||||
this.alertMessage = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for readability
|
|
||||||
|
|
||||||
isGiveCreationError(result) {
|
|
||||||
return result.status !== 201 || result.data?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGiveCreationErrorMessage(result) {
|
|
||||||
return result.data?.error?.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGiveErrorMessage(error) {
|
|
||||||
return error.userMessage || error.response?.data?.error?.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav selected="Profile"></QuickNav>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -21,9 +21,6 @@
|
|||||||
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4">
|
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4">
|
||||||
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
|
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
|
||||||
<span class="overflow-hidden">
|
<span class="overflow-hidden">
|
||||||
<h2 class="text-xl font-semibold mb-0">
|
|
||||||
{{ firstName }} {{ lastName }}
|
|
||||||
</h2>
|
|
||||||
<div class="text-sm text-slate-500 truncate">
|
<div class="text-sm text-slate-500 truncate">
|
||||||
<b>ID:</b> <code>{{ activeDid }}</code>
|
<b>ID:</b> <code>{{ activeDid }}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +46,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
<!-- id used by puppeteer test script -->
|
||||||
<router-link
|
<router-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-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||||
>
|
>
|
||||||
@@ -62,52 +61,52 @@
|
|||||||
>
|
>
|
||||||
No Identity
|
No Identity
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</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 { AppString } from "@/constants/app";
|
import { AppString } from "@/constants/app";
|
||||||
import { db, accountsDB } from "@/db";
|
import { db, accountsDB } from "@/db/index";
|
||||||
import { AccountsSchema } from "@/db/tables/accounts";
|
import { AccountsSchema } from "@/db/tables/accounts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
|
||||||
|
|
||||||
@Component({ components: { AlertMessage, QuickNav } })
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ components: { QuickNav } })
|
||||||
export default class IdentitySwitcherView extends Vue {
|
export default class IdentitySwitcherView extends Vue {
|
||||||
Constants = AppString;
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
public accounts: AccountsSchema;
|
|
||||||
public activeDid;
|
|
||||||
public firstName;
|
|
||||||
public lastName;
|
|
||||||
public alertTitle;
|
|
||||||
public alertMessage;
|
|
||||||
public otherIdentities = [];
|
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
Constants = AppString;
|
||||||
|
public accounts: typeof AccountsSchema;
|
||||||
|
public activeDid = "";
|
||||||
|
public apiServer = "";
|
||||||
|
public apiServerInput = "";
|
||||||
|
public otherIdentities: Array<{ did: string }> = [];
|
||||||
|
public showContactGives = false;
|
||||||
|
|
||||||
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
.equals(activeDid)
|
.equals(activeDid)
|
||||||
.first();
|
.first();
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse((account?.identity as string) || "null");
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
async 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)) as Settings;
|
||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = settings?.activeDid || "";
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
this.apiServerInput = settings?.apiServer || "";
|
this.apiServerInput = settings?.apiServer || "";
|
||||||
this.firstName = settings?.firstName || "No";
|
|
||||||
this.lastName = settings?.lastName || "Name";
|
|
||||||
this.showContactGives = !!settings?.showContactGivesInline;
|
this.showContactGives = !!settings?.showContactGivesInline;
|
||||||
|
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
@@ -126,40 +125,29 @@ export default class IdentitySwitcherView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
this.$notify(
|
||||||
err.message ===
|
{
|
||||||
"Attempted to load account records with no identity available."
|
group: "alert",
|
||||||
) {
|
type: "danger",
|
||||||
this.limitsMessage = "No identity.";
|
title: "Error Loading Accounts",
|
||||||
this.loadingLimits = false;
|
text: "Clear your cache and start over (after data backup).",
|
||||||
} else {
|
},
|
||||||
this.$notify(
|
-1,
|
||||||
{
|
);
|
||||||
group: "alert",
|
console.error("Telling user to clear cache at page create because:", err);
|
||||||
type: "danger",
|
|
||||||
title: "Error Creating Account",
|
|
||||||
text: "Clear your cache and start over (after data backup).",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
"Telling user to clear cache at page create because:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchAccount(did: string) {
|
async switchAccount(did?: string) {
|
||||||
// 0 means none
|
// 0 means none
|
||||||
if (did === "0") {
|
if (did === "0") {
|
||||||
did = undefined;
|
did = undefined;
|
||||||
}
|
}
|
||||||
await db.open();
|
await db.open();
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
activeDid: did,
|
activeDid: did,
|
||||||
});
|
});
|
||||||
this.activeDid = did;
|
this.activeDid = did || "";
|
||||||
this.otherIdentities = [];
|
this.otherIdentities = [];
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -10,20 +10,22 @@
|
|||||||
>
|
>
|
||||||
<fa icon="chevron-left"></fa>
|
<fa icon="chevron-left"></fa>
|
||||||
</button>
|
</button>
|
||||||
Import Existing Identity
|
Import Existing Identifier
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<!-- Import Account Form -->
|
<!-- Import Account Form -->
|
||||||
<p class="text-center text-xl mb-4 font-light">
|
<p class="text-center text-xl mb-4 font-light">
|
||||||
Enter your seed phrase below to import your identity on this device.
|
Enter your seed phrase below to import your identifier on this device.
|
||||||
</p>
|
</p>
|
||||||
|
<!-- id used by puppeteer test script -->
|
||||||
<input
|
<input
|
||||||
|
id="seed-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Seed Phrase"
|
placeholder="Seed Phrase"
|
||||||
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"
|
||||||
v-model="mnemonic"
|
v-model="mnemonic"
|
||||||
/>
|
/>
|
||||||
{{ mnemonic }}
|
|
||||||
<h3
|
<h3
|
||||||
class="text-sm uppercase font-semibold mb-3"
|
class="text-sm uppercase font-semibold mb-3"
|
||||||
@click="showAdvanced = !showAdvanced"
|
@click="showAdvanced = !showAdvanced"
|
||||||
@@ -34,17 +36,28 @@
|
|||||||
Enter a custom derivation path
|
Enter a custom derivation path
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
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-2 px-3 py-2"
|
||||||
v-model="derivationPath"
|
v-model="derivationPath"
|
||||||
/>
|
/>
|
||||||
For previous uPort or Endorser users,
|
<span class="ml-4">
|
||||||
<a @click="derivationPath = UPORT_DERIVATION_PATH" class="text-blue-500">
|
For previous uPort or Endorser users,
|
||||||
click here to use that value.
|
<a
|
||||||
</a>
|
@click="derivationPath = UPORT_DERIVATION_PATH"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
click here to use that value.
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="mt-4" v-if="numAccounts == 1">
|
||||||
|
<input type="checkbox" class="mr-2" v-model="shouldErase" />
|
||||||
|
<label>Erase the previous identifier.</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<button
|
||||||
@click="from_mnemonic()"
|
@click="fromMnemonic()"
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-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"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
@@ -67,29 +80,45 @@ import {
|
|||||||
deriveAddress,
|
deriveAddress,
|
||||||
newIdentifier,
|
newIdentifier,
|
||||||
} from "../libs/crypto";
|
} from "../libs/crypto";
|
||||||
import { accountsDB, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {},
|
components: {},
|
||||||
})
|
})
|
||||||
export default class ImportAccountView extends Vue {
|
export default class ImportAccountView extends Vue {
|
||||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
|
||||||
|
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
mnemonic = "";
|
mnemonic = "";
|
||||||
address = "";
|
address = "";
|
||||||
|
numAccounts = 0;
|
||||||
privateHex = "";
|
privateHex = "";
|
||||||
publicHex = "";
|
publicHex = "";
|
||||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||||
showAdvanced = false;
|
showAdvanced = false;
|
||||||
|
shouldErase = false;
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
await accountsDB.open();
|
||||||
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
|
}
|
||||||
|
|
||||||
public onCancelClick() {
|
public onCancelClick() {
|
||||||
this.$router.back();
|
this.$router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async from_mnemonic() {
|
public async fromMnemonic() {
|
||||||
const mne: string = this.mnemonic.trim().toLowerCase();
|
const mne: string = this.mnemonic.trim().toLowerCase();
|
||||||
if (this.mnemonic.trim().length > 0) {
|
try {
|
||||||
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
||||||
mne,
|
mne,
|
||||||
this.derivationPath,
|
this.derivationPath,
|
||||||
@@ -102,25 +131,48 @@ export default class ImportAccountView extends Vue {
|
|||||||
this.derivationPath,
|
this.derivationPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
await accountsDB.open();
|
||||||
await accountsDB.open();
|
if (this.shouldErase) {
|
||||||
await accountsDB.accounts.add({
|
await accountsDB.accounts.clear();
|
||||||
dateCreated: new Date().toISOString(),
|
}
|
||||||
derivationPath: this.derivationPath,
|
await accountsDB.accounts.add({
|
||||||
did: newId.did,
|
dateCreated: new Date().toISOString(),
|
||||||
identity: JSON.stringify(newId),
|
derivationPath: this.derivationPath,
|
||||||
mnemonic: mne,
|
did: newId.did,
|
||||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
identity: JSON.stringify(newId),
|
||||||
});
|
mnemonic: mne,
|
||||||
|
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||||
|
});
|
||||||
|
|
||||||
// record that as the active DID
|
// record that as the active DID
|
||||||
await db.open();
|
await db.open();
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
activeDid: newId.did,
|
activeDid: newId.did,
|
||||||
});
|
});
|
||||||
this.$router.push({ name: "account" });
|
this.$router.push({ name: "account" });
|
||||||
} catch (err) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
console.error("Error saving mnemonic & updating settings:", err);
|
} catch (err: any) {
|
||||||
|
console.error("Error saving mnemonic & updating settings:", err);
|
||||||
|
if (err == "Error: invalid mnemonic") {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Invalid Mnemonic",
|
||||||
|
text: "Please check your mnemonic and try again.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Got an error creating that identifier.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -72,8 +72,9 @@ import {
|
|||||||
DEFAULT_ROOT_DERIVATION_PATH,
|
DEFAULT_ROOT_DERIVATION_PATH,
|
||||||
deriveAddress,
|
deriveAddress,
|
||||||
newIdentifier,
|
newIdentifier,
|
||||||
|
nextDerivationPath,
|
||||||
} from "../libs/crypto";
|
} from "../libs/crypto";
|
||||||
import { accountsDB, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -87,9 +88,9 @@ export default class ImportAccountView extends Vue {
|
|||||||
async mounted() {
|
async mounted() {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
const seedDids = {};
|
const seedDids: Record<string, Array<string>> = {};
|
||||||
accounts.forEach((account) => {
|
accounts.forEach((account) => {
|
||||||
const prevDids = seedDids[account.mnemonic] || [];
|
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
|
||||||
seedDids[account.mnemonic] = prevDids.concat([account.did]);
|
seedDids[account.mnemonic] = prevDids.concat([account.did]);
|
||||||
});
|
});
|
||||||
this.didArrays = Object.values(seedDids);
|
this.didArrays = Object.values(seedDids);
|
||||||
@@ -107,9 +108,9 @@ export default class ImportAccountView extends Vue {
|
|||||||
public async incrementDerivation() {
|
public async incrementDerivation() {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
// find the maximum derivation path for the selected DIDs
|
// find the maximum derivation path for the selected DIDs
|
||||||
const selectedArray: Array<string> = this.didArrays.find(
|
const selectedArray: Array<string> =
|
||||||
(dids) => dids[0] === this.selectedArrayFirstDid,
|
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
|
||||||
);
|
[];
|
||||||
const allMatchingAccounts = await accountsDB.accounts
|
const allMatchingAccounts = await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
.anyOf(...selectedArray)
|
.anyOf(...selectedArray)
|
||||||
@@ -121,17 +122,7 @@ export default class ImportAccountView extends Vue {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// increment the last number in that max derivation path
|
// increment the last number in that max derivation path
|
||||||
let lastStr = accountWithMaxDeriv.derivationPath.split("/").slice(-1)[0];
|
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
|
||||||
if (lastStr.endsWith("'")) {
|
|
||||||
lastStr = lastStr.slice(0, -1);
|
|
||||||
}
|
|
||||||
const lastNum = parseInt(lastStr, 10);
|
|
||||||
const newLastNum = lastNum + 1;
|
|
||||||
const newDerivPath = accountWithMaxDeriv.derivationPath
|
|
||||||
.split("/")
|
|
||||||
.slice(0, -1)
|
|
||||||
.concat([newLastNum.toString() + "'"])
|
|
||||||
.join("/");
|
|
||||||
|
|
||||||
const mne: string = accountWithMaxDeriv.mnemonic;
|
const mne: string = accountWithMaxDeriv.mnemonic;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -10,21 +10,15 @@
|
|||||||
>
|
>
|
||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||||
</button>
|
</button>
|
||||||
[New/Edit] Identity
|
Edit Identity
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="First Name"
|
placeholder="Name"
|
||||||
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"
|
||||||
v-model="firstName"
|
v-model="givenName"
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Last Name"
|
|
||||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
||||||
v-model="lastName"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
@@ -49,38 +43,32 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {},
|
components: {},
|
||||||
})
|
})
|
||||||
export default class NewEditAccountView extends Vue {
|
export default class NewEditAccountView extends Vue {
|
||||||
firstName =
|
givenName = "";
|
||||||
localStorage.getItem("firstName") === null
|
|
||||||
? "--"
|
|
||||||
: localStorage.getItem("firstName");
|
|
||||||
lastName =
|
|
||||||
localStorage.getItem("lastName") === null
|
|
||||||
? "--"
|
|
||||||
: localStorage.getItem("lastName");
|
|
||||||
|
|
||||||
// 'created' hook runs when the Vue instance is first created
|
// 'created' hook runs when the Vue instance is first created
|
||||||
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)) as Settings;
|
||||||
this.firstName = settings?.firstName || "";
|
this.givenName =
|
||||||
this.lastName = settings?.lastName || "";
|
(settings?.firstName || "") +
|
||||||
|
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickSaveChanges() {
|
onClickSaveChanges() {
|
||||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
firstName: this.firstName,
|
firstName: this.givenName,
|
||||||
lastName: this.lastName,
|
lastName: "", // deprecated, pre v 0.1.3
|
||||||
});
|
});
|
||||||
localStorage.setItem("firstName", this.firstName as string);
|
localStorage.setItem("firstName", this.givenName as string);
|
||||||
localStorage.setItem("lastName", this.lastName as string);
|
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
|
||||||
this.$router.push({ name: "account" });
|
this.$router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickCancel() {
|
onClickCancel() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<QuickNav selected="Projects"></QuickNav>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -10,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"
|
||||||
><fa icon="chevron-left" class="fa-fw"></fa
|
><fa icon="chevron-left" class="fa-fw"></fa
|
||||||
></router-link>
|
></router-link>
|
||||||
[New/Edit] Plan
|
Edit Idea
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,32 +24,60 @@
|
|||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Project Name"
|
placeholder="Idea Name"
|
||||||
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"
|
||||||
v-model="projectName"
|
v-model="fullClaim.name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Other Authorized Representative"
|
||||||
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||||||
|
v-model="agentDid"
|
||||||
|
/>
|
||||||
|
<div class="mb-4">
|
||||||
|
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
|
||||||
|
<span class="text-red-500">Beware!</span>
|
||||||
|
If you save this, the original project owner will no longer be able to
|
||||||
|
edit it.
|
||||||
|
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
|
||||||
|
Click here to make the original owner an authorized representative.
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
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"
|
||||||
rows="5"
|
rows="5"
|
||||||
v-model="description"
|
v-model="fullClaim.description"
|
||||||
maxlength="500"
|
maxlength="5000"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||||
{{ description.length }}/500 max. characters
|
{{ fullClaim.description?.length }}/5000 max. characters
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="fullClaim.url"
|
||||||
|
placeholder="Website"
|
||||||
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
v-model="includeLocation"
|
v-model="includeLocation"
|
||||||
@change="includeLocation = true"
|
@click="includeLocation = !includeLocation"
|
||||||
/>
|
/>
|
||||||
<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" style="height: 600px; width: 800px">
|
||||||
|
<div class="px-2 py-2">
|
||||||
|
For your security, choose a location nearby but not exactly at the
|
||||||
|
place.
|
||||||
|
</div>
|
||||||
|
|
||||||
<l-map
|
<l-map
|
||||||
ref="map"
|
ref="map"
|
||||||
v-model:zoom="zoom"
|
v-model:zoom="zoom"
|
||||||
@@ -66,7 +95,7 @@
|
|||||||
name="OpenStreetMap"
|
name="OpenStreetMap"
|
||||||
/>
|
/>
|
||||||
<l-marker
|
<l-marker
|
||||||
v-if="latitude || longitude"
|
v-if="latitude && longitude"
|
||||||
:lat-lng="[latitude, longitude]"
|
:lat-lng="[latitude, longitude]"
|
||||||
@click="maybeEraseLatLong()"
|
@click="maybeEraseLatLong()"
|
||||||
/>
|
/>
|
||||||
@@ -86,7 +115,7 @@
|
|||||||
<span :class="{ hidden: isHiddenSpinner }">
|
<span :class="{ hidden: isHiddenSpinner }">
|
||||||
<!-- icon no worky? -->
|
<!-- icon no worky? -->
|
||||||
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
||||||
Saving…</span
|
Saving...</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -97,10 +126,6 @@
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -111,28 +136,45 @@ import * as didJwt from "did-jwt";
|
|||||||
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 { accountsDB, db } from "@/db";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
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 { useAppStore } from "@/store/app";
|
import { useAppStore } from "@/store/app";
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { AlertMessage, LMap, LMarker, LTileLayer },
|
components: { LMap, LMarker, LTileLayer, QuickNav },
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
alertTitle = "";
|
agentDid = "";
|
||||||
alertMessage = "";
|
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
description = "";
|
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
|
fullClaim: PlanVerifiableCredential = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "PlanAction",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
}; // this default is only to avoid errors before plan is loaded
|
||||||
includeLocation = false;
|
includeLocation = false;
|
||||||
|
isHiddenSave = false;
|
||||||
|
isHiddenSpinner = true;
|
||||||
latitude = 0;
|
latitude = 0;
|
||||||
longitude = 0;
|
longitude = 0;
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
projectName = "";
|
projectId = localStorage.getItem("projectId") || "";
|
||||||
|
projectIssuerDid = "";
|
||||||
zoom = 2;
|
zoom = 2;
|
||||||
|
|
||||||
async beforeCreate() {
|
async beforeCreate() {
|
||||||
@@ -140,7 +182,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
this.numAccounts = await accountsDB.accounts.count();
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
@@ -150,13 +192,13 @@ export default class NewEditProjectView extends Vue {
|
|||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Attempted to load project records with no identity available.",
|
"Attempted to load project records with no identifier available.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity) {
|
public async getHeaders(identity: IIdentifier) {
|
||||||
const token = await accessToken(identity);
|
const token = await accessToken(identity);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -165,10 +207,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
projectId = localStorage.getItem("projectId") || "";
|
|
||||||
isHiddenSave = false;
|
|
||||||
isHiddenSpinner = true;
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -182,7 +220,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
const identity = await this.getIdentity(this.activeDid);
|
const identity = await this.getIdentity(this.activeDid);
|
||||||
if (!identity) {
|
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.",
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.LoadProject(identity);
|
this.LoadProject(identity);
|
||||||
@@ -204,9 +242,16 @@ export default class NewEditProjectView 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 claim = resp.data.claim;
|
this.projectIssuerDid = resp.data.issuer;
|
||||||
this.projectName = claim.name;
|
this.fullClaim = resp.data.claim;
|
||||||
this.description = claim.description;
|
if (this.fullClaim?.location) {
|
||||||
|
this.includeLocation = true;
|
||||||
|
this.latitude = this.fullClaim.location.geo.latitude;
|
||||||
|
this.longitude = this.fullClaim.location.geo.longitude;
|
||||||
|
}
|
||||||
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Got error retrieving that project", error);
|
console.error("Got error retrieving that project", error);
|
||||||
@@ -215,16 +260,15 @@ export default class NewEditProjectView extends Vue {
|
|||||||
|
|
||||||
private async SaveProject(identity: IIdentifier) {
|
private async SaveProject(identity: IIdentifier) {
|
||||||
// Make a claim
|
// Make a claim
|
||||||
const vcClaim: VerifiableCredential = {
|
const vcClaim: PlanVerifiableCredential = this.fullClaim;
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "PlanAction",
|
|
||||||
name: this.projectName,
|
|
||||||
description: this.description,
|
|
||||||
identifier: this.projectId || undefined,
|
|
||||||
};
|
|
||||||
if (this.projectId) {
|
if (this.projectId) {
|
||||||
vcClaim.identifier = this.projectId;
|
vcClaim.identifier = this.projectId;
|
||||||
}
|
}
|
||||||
|
if (this.agentDid) {
|
||||||
|
vcClaim.agent = {
|
||||||
|
identifier: this.agentDid,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (this.includeLocation) {
|
if (this.includeLocation) {
|
||||||
vcClaim.location = {
|
vcClaim.location = {
|
||||||
geo: {
|
geo: {
|
||||||
@@ -266,35 +310,38 @@ export default class NewEditProjectView extends Vue {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await this.axios.post(url, payload, { headers });
|
const resp = await this.axios.post(url, payload, { headers });
|
||||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
if (resp.data?.success?.handleId) {
|
||||||
// version shows up here: https://api.endorser.ch/api-docs/
|
|
||||||
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
|
|
||||||
this.errorMessage = "";
|
this.errorMessage = "";
|
||||||
this.alertTitle = "";
|
|
||||||
this.alertMessage = "";
|
useAppStore()
|
||||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
.setProjectId(resp.data.success.handleId)
|
||||||
// version shows up here: https://api.endorser.ch/api-docs/
|
.then(() => {
|
||||||
useAppStore().setProjectId(
|
this.$router.push({ name: "project" });
|
||||||
resp.data.success.handleId || resp.data.success.fullIri,
|
});
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Got unexpected 'data' inside response from server",
|
||||||
|
resp,
|
||||||
);
|
);
|
||||||
setTimeout(
|
this.$notify(
|
||||||
function (that: Vue) {
|
{
|
||||||
const route = {
|
group: "alert",
|
||||||
name: "project",
|
type: "danger",
|
||||||
};
|
title: "Error Saving Idea",
|
||||||
that.$router.push(route);
|
text: "Server did not save the idea. Try again.",
|
||||||
},
|
},
|
||||||
2000,
|
-1,
|
||||||
this,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let userMessage = "There was an error saving the project.";
|
let userMessage = "There was an error saving the project.";
|
||||||
const serverError = error as AxiosError;
|
const serverError = error as AxiosError<{
|
||||||
|
error?: { message?: string };
|
||||||
|
}>;
|
||||||
if (serverError) {
|
if (serverError) {
|
||||||
|
console.error("Got error from server", serverError);
|
||||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||||
console.log(serverError);
|
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
|
||||||
userMessage = serverError.response.data.error.message; // This is info for the user.
|
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Heading -->
|
<!-- Breadcrumb -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
<div class="mb-8">
|
||||||
Your Identity
|
<!-- Back -->
|
||||||
</h1>
|
<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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Your Identity
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center py-12">
|
<div class="flex justify-center py-12">
|
||||||
<span />
|
<span />
|
||||||
@@ -40,41 +54,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "dexie-export-import";
|
import "dexie-export-import";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { accountsDB, db } from "@/db";
|
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
||||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import QuickNav from "@/components/QuickNav";
|
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class AccountViewView extends Vue {
|
export default class NewIdentifierView extends Vue {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const mnemonic = generateSeed();
|
await generateSaveAndActivateIdentity();
|
||||||
// address is 0x... ETH address, without "did:eth:"
|
|
||||||
const [address, privateHex, publicHex, derivationPath] =
|
|
||||||
deriveAddress(mnemonic);
|
|
||||||
|
|
||||||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
|
||||||
const identity = JSON.stringify(newId);
|
|
||||||
|
|
||||||
await accountsDB.open();
|
|
||||||
await accountsDB.accounts.add({
|
|
||||||
dateCreated: new Date().toISOString(),
|
|
||||||
derivationPath: derivationPath,
|
|
||||||
did: newId.did,
|
|
||||||
identity: identity,
|
|
||||||
mnemonic: mnemonic,
|
|
||||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
||||||
activeDid: newId.did,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.$router.push({ name: "account" });
|
this.$router.push({ name: "home" });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Projects"></QuickNav>
|
<QuickNav />
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div id="ViewBreadcrumb" class="mb-8">
|
<div id="ViewBreadcrumb" class="mb-8">
|
||||||
<h1 class="text-lg text-center font-light relative px-7">
|
<h1 class="text-lg text-center font-light relative px-7">
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
>
|
>
|
||||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||||
</button>
|
</button>
|
||||||
View Plan
|
Idea
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,11 +23,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="block pb-4 flex gap-4">
|
<div class="block pb-4 flex gap-4">
|
||||||
<div class="flex-none w-16 pt-1">
|
<div class="flex-none w-16 pt-1">
|
||||||
<EntityIcon
|
<ProjectIcon
|
||||||
:entityId="projectId"
|
:entityId="projectId"
|
||||||
:iconSize="64"
|
:iconSize="64"
|
||||||
class="block border border-slate-300 rounded-md"
|
class="block border border-slate-300 rounded-md"
|
||||||
></EntityIcon>
|
></ProjectIcon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
@@ -33,12 +35,44 @@
|
|||||||
<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>
|
||||||
{{ issuer }}
|
{{
|
||||||
|
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
|
||||||
|
}}
|
||||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
libsUtil.doCopyTwoSecRedo(
|
||||||
|
issuer,
|
||||||
|
() => (showDidCopy = !showDidCopy),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="ml-2 mr-2"
|
||||||
|
>
|
||||||
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||||
|
</button>
|
||||||
|
<span v-show="showDidCopy">Copied DID</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="timeSince">
|
||||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||||
{{ timeSince }}
|
{{ timeSince }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="latitude || longitude">
|
||||||
|
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||||
|
<a
|
||||||
|
:href="getOpenStreetMapUrl()"
|
||||||
|
target="_blank"
|
||||||
|
class="underline"
|
||||||
|
>Map View
|
||||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="url">
|
||||||
|
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
||||||
|
<a :href="addScheme(url)" target="_blank" class="underline"
|
||||||
|
>{{ domainForWebsite(this.url) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,8 +80,11 @@
|
|||||||
<div class="text-sm text-slate-500">
|
<div class="text-sm text-slate-500">
|
||||||
<div v-if="!expanded">
|
<div v-if="!expanded">
|
||||||
{{ truncatedDesc }}
|
{{ truncatedDesc }}
|
||||||
<a v-if="description.length >= truncateLength" @click="expandText"
|
<a
|
||||||
>Read More</a
|
v-if="description.length >= truncateLength"
|
||||||
|
@click="expandText"
|
||||||
|
class="uppercase text-xs font-semibold text-slate-700"
|
||||||
|
>... Read More</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -55,13 +92,18 @@
|
|||||||
<a
|
<a
|
||||||
@click="collapseText"
|
@click="collapseText"
|
||||||
class="uppercase text-xs font-semibold text-slate-700"
|
class="uppercase text-xs font-semibold text-slate-700"
|
||||||
>Read Less</a
|
>- Read Less</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
|
||||||
|
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="issuer == activeDid"
|
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-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||||
@click="onEditClick()"
|
@click="onEditClick()"
|
||||||
@@ -70,41 +112,58 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="activeDid" class="mb-4">
|
||||||
<div v-if="activeDid" class="text-center">
|
<div class="text-center">
|
||||||
<button
|
<button
|
||||||
@click="openDialog({ name: 'you', did: activeDid })"
|
@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 uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
I gave…
|
Offer (maybe with conditions)...
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 mb-4 text-center">Or, record a gift from:</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p>
|
</div>
|
||||||
|
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
|
||||||
|
|
||||||
|
<div v-if="activeDid">
|
||||||
|
<GiftedDialog
|
||||||
|
ref="customGiveDialog"
|
||||||
|
message="Received from"
|
||||||
|
:projectId="this.projectId"
|
||||||
|
>
|
||||||
|
</GiftedDialog>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="mt-2 mb-4 text-center">Record a contribution from:</p>
|
||||||
|
</div>
|
||||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||||
<li @click="openDialog()">
|
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
|
||||||
<EntityIcon
|
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
|
||||||
:entityId="Anonymous"
|
<h3
|
||||||
:iconSize="64"
|
class="mt-5 text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
You
|
||||||
|
</h3>
|
||||||
|
</li>
|
||||||
|
<li @click="openGiftDialog()">
|
||||||
|
<img
|
||||||
|
src="../assets/blank-square.svg"
|
||||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||||
></EntityIcon>
|
/>
|
||||||
<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
|
Anonymous/Unnamed
|
||||||
</h3>
|
</h3>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-for="contact in allContacts"
|
v-for="contact in allContacts.slice(0, 6)"
|
||||||
:key="contact.did"
|
:key="contact.did"
|
||||||
@click="openDialog(contact)"
|
@click="openGiftDialog(contact)"
|
||||||
>
|
>
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entityId="contact.did"
|
:entityId="contact.did"
|
||||||
: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"
|
||||||
></EntityIcon>
|
/>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -114,23 +173,88 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||||
<router-link
|
<a
|
||||||
v-if="allContacts.length > 7"
|
v-if="allContacts.length >= 7"
|
||||||
:to="{ name: 'contact-gives' }"
|
@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 uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
Show More Contacts…
|
Show More Contacts…
|
||||||
</router-link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gifts to & from this -->
|
<!-- Gifts to & from this -->
|
||||||
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-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 uppercase font-semibold mb-3">
|
||||||
Given to this Project
|
Offered To This Idea
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<ul class="text-sm border-t border-slate-300">
|
<div v-if="offersToThis.length === 0">
|
||||||
|
(None yet. Wanna
|
||||||
|
<span @click="openOfferDialog()" class="cursor-pointer text-blue-500"
|
||||||
|
>offer something... especially if others join you</span
|
||||||
|
>?)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="text-sm border-t border-slate-300">
|
||||||
|
<li
|
||||||
|
v-for="offer in offersToThis"
|
||||||
|
:key="offer.id"
|
||||||
|
class="py-1.5 border-b border-slate-300"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||||
|
{{
|
||||||
|
serverUtil.didInfo(
|
||||||
|
offer.offeredByDid,
|
||||||
|
activeDid,
|
||||||
|
allMyDids,
|
||||||
|
allContacts,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-if="offer.amount" class="whitespace-nowrap">
|
||||||
|
<fa
|
||||||
|
:icon="iconForUnitCode(offer.unit)"
|
||||||
|
class="fa-fw text-slate-400"
|
||||||
|
/>{{ offer.amount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="offer.objectDescription" class="text-slate-500">
|
||||||
|
<fa icon="comment" class="fa-fw text-slate-400" />
|
||||||
|
{{ offer.objectDescription }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<a
|
||||||
|
@click="onClickLoadClaim(offer.jwtId as string)"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="checkIsFulfillable(offer)"
|
||||||
|
@click="onClickFulfillGiveToOffer(offer)"
|
||||||
|
>
|
||||||
|
<fa
|
||||||
|
icon="hand-holding-heart"
|
||||||
|
class="text-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-if="givesToThis.length === 0">
|
||||||
|
(None yet. If you've seen something, say something by clicking a
|
||||||
|
contact above.)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="text-sm border-t border-slate-300">
|
||||||
<li
|
<li
|
||||||
v-for="give in givesToThis"
|
v-for="give in givesToThis"
|
||||||
:key="give.id"
|
:key="give.id"
|
||||||
@@ -139,138 +263,187 @@
|
|||||||
<div class="flex justify-between gap-4">
|
<div class="flex justify-between gap-4">
|
||||||
<span
|
<span
|
||||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
{{
|
||||||
|
serverUtil.didInfo(
|
||||||
|
give.agentDid,
|
||||||
|
activeDid,
|
||||||
|
allMyDids,
|
||||||
|
allContacts,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="give.amount"
|
<span v-if="give.amount" class="whitespace-nowrap">
|
||||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
<fa
|
||||||
{{ give.amount }}
|
:icon="iconForUnitCode(give.unit)"
|
||||||
|
class="fa-fw text-slate-400"
|
||||||
|
/>{{ give.amount }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-slate-500">
|
||||||
|
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||||
|
{{ give.issuedAt?.substring(0, 10) }}
|
||||||
|
</div>
|
||||||
<div v-if="give.description" class="text-slate-500">
|
<div v-if="give.description" class="text-slate-500">
|
||||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
<fa icon="comment" class="fa-fw text-slate-400" />
|
||||||
{{ give.description }}
|
{{ give.description }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<a @click="onClickLoadClaim(give.jwtId)">
|
||||||
|
<fa icon="circle-info" class="text-blue-500 cursor-pointer" />
|
||||||
|
</a>
|
||||||
|
<a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
|
||||||
|
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
<div class="grid items-start grid-cols-1 gap-4">
|
||||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
<div
|
||||||
…and from this Project
|
v-if="fulfillersToThis.length > 0"
|
||||||
</h3>
|
class="bg-slate-100 px-4 py-3 rounded-md"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||||
|
Contributions To This Idea
|
||||||
|
</h3>
|
||||||
|
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
|
||||||
|
<button
|
||||||
|
@click="onClickLoadProject(plan.handleId)"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
{{ plan.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="text-sm border-t border-slate-300">
|
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
||||||
<li
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||||
v-for="give in givesByThis"
|
Contributions From This Idea
|
||||||
:key="give.id"
|
</h3>
|
||||||
class="py-1.5 border-b border-slate-300"
|
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||||
>
|
<div class="text-center">
|
||||||
<div class="flex justify-between gap-4">
|
<button
|
||||||
<span
|
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
||||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
class="text-blue-500"
|
||||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
>
|
||||||
</span>
|
{{ fulfilledByThis.name }}
|
||||||
<span v-if="give.amount"
|
</button>
|
||||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
</div>
|
||||||
{{ give.amount }}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="give.description" class="text-slate-500">
|
|
||||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
|
||||||
{{ give.description }}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GiftedDialog
|
|
||||||
ref="customDialog"
|
|
||||||
@dialog-result="handleDialogResult"
|
|
||||||
message="Received from"
|
|
||||||
>
|
|
||||||
</GiftedDialog>
|
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||||
import * as moment from "moment";
|
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";
|
||||||
|
|
||||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||||
import { accountsDB, db } from "@/db";
|
import OfferDialog from "@/components/OfferDialog.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||||
import { accessToken } from "@/libs/crypto";
|
import { accessToken } from "@/libs/crypto";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
import {
|
import {
|
||||||
createAndSubmitGive,
|
BLANK_GENERIC_SERVER_RECORD,
|
||||||
didInfo,
|
GenericServerRecord,
|
||||||
|
GiverInputInfo,
|
||||||
GiveServerRecord,
|
GiveServerRecord,
|
||||||
|
OfferServerRecord,
|
||||||
|
PlanServerRecord,
|
||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon";
|
import EntityIcon from "@/components/EntityIcon.vue";
|
||||||
|
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||||
|
import { Account } from "@/db/tables/accounts";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
GiftedDialog,
|
||||||
|
OfferDialog,
|
||||||
|
ProjectIcon,
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export default class ProjectViewView extends Vue {
|
export default class ProjectViewView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
alertMessage = "";
|
agentDid = "";
|
||||||
alertTitle = "";
|
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
description = "";
|
description = "";
|
||||||
expanded = false;
|
expanded = false;
|
||||||
|
fulfilledByThis: PlanServerRecord | null = null;
|
||||||
|
fulfillersToThis: Array<PlanServerRecord> = [];
|
||||||
givesToThis: Array<GiveServerRecord> = [];
|
givesToThis: Array<GiveServerRecord> = [];
|
||||||
givesByThis: Array<GiveServerRecord> = [];
|
|
||||||
name = "";
|
|
||||||
issuer = "";
|
issuer = "";
|
||||||
|
latitude = 0;
|
||||||
|
longitude = 0;
|
||||||
|
name = "";
|
||||||
|
offersToThis: Array<OfferServerRecord> = [];
|
||||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||||
|
showDidCopy = false;
|
||||||
timeSince = "";
|
timeSince = "";
|
||||||
truncatedDesc = "";
|
truncatedDesc = "";
|
||||||
truncateLength = 40;
|
truncateLength = 40;
|
||||||
|
url = "";
|
||||||
|
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
serverUtil = serverUtil;
|
||||||
|
|
||||||
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)) as Settings;
|
||||||
this.activeDid = settings?.activeDid || "";
|
this.activeDid = settings?.activeDid || "";
|
||||||
this.apiServer = settings?.apiServer || "";
|
this.apiServer = settings?.apiServer || "";
|
||||||
this.allContacts = await db.contacts.toArray();
|
this.allContacts = await db.contacts.toArray();
|
||||||
|
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const accounts = accountsDB.accounts;
|
const accounts = accountsDB.accounts;
|
||||||
const accountsArr = 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 || "null");
|
||||||
this.LoadProject(identity);
|
|
||||||
|
const pathParam = window.location.pathname.substring("/project/".length);
|
||||||
|
if (pathParam) {
|
||||||
|
this.projectId = decodeURIComponent(pathParam);
|
||||||
|
}
|
||||||
|
this.loadProject(this.projectId, identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = (await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
.equals(activeDid)
|
.equals(activeDid)
|
||||||
.first();
|
.first()) as Account;
|
||||||
const identity = JSON.parse(account?.identity || "null");
|
const identity = JSON.parse(account?.identity || "null");
|
||||||
|
|
||||||
if (!identity) {
|
|
||||||
throw new Error(
|
|
||||||
"Attempted to load project records with no identity available.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHeaders(identity) {
|
public async getHeaders(identity: IIdentifier) {
|
||||||
const token = await accessToken(identity);
|
const token = await accessToken(identity);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -288,10 +461,6 @@ export default class ProjectViewView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Isn't there a better way to make this available to the template?
|
// Isn't there a better way to make this available to the template?
|
||||||
didInfo(did, activeDid, dids, contacts) {
|
|
||||||
return didInfo(did, activeDid, dids, contacts);
|
|
||||||
}
|
|
||||||
|
|
||||||
expandText() {
|
expandText() {
|
||||||
this.expanded = true;
|
this.expanded = true;
|
||||||
}
|
}
|
||||||
@@ -300,12 +469,12 @@ export default class ProjectViewView extends Vue {
|
|||||||
this.expanded = false;
|
this.expanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async LoadProject(identity: IIdentifier) {
|
async loadProject(projectId: string, identity: IIdentifier) {
|
||||||
|
this.projectId = projectId;
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||||
"/api/claim/byHandle/" +
|
const headers: RawAxiosRequestHeaders = {
|
||||||
encodeURIComponent(this.projectId);
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
if (identity) {
|
if (identity) {
|
||||||
@@ -322,23 +491,29 @@ export default class ProjectViewView extends Vue {
|
|||||||
const now = moment.now();
|
const now = moment.now();
|
||||||
this.timeSince = moment.utc(now).to(eventDate);
|
this.timeSince = moment.utc(now).to(eventDate);
|
||||||
}
|
}
|
||||||
|
this.agentDid = resp.data.claim?.agent?.identifier;
|
||||||
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)";
|
||||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||||
} else if (resp.status === 404) {
|
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
||||||
// actually, axios throws an error so we never get here
|
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
||||||
|
this.url = resp.data.claim?.url || "";
|
||||||
|
} else {
|
||||||
|
// actually, axios throws an error on 404 so we probably never get here
|
||||||
|
console.error("Error getting project:", resp);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "That project does not exist.",
|
text: "There was a problem getting that project. See logs for more info.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
console.error("Error retrieving project:", error);
|
||||||
const serverError = error as AxiosError;
|
const serverError = error as AxiosError;
|
||||||
if (serverError.response?.status === 404) {
|
if (serverError.response?.status === 404) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
@@ -360,14 +535,13 @@ export default class ProjectViewView extends Vue {
|
|||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
console.error("Error retrieving project:", serverError.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const givesInUrl =
|
const givesInUrl =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/givesForPlans?planIds=" +
|
"/api/v2/report/givesToPlans?planIds=" +
|
||||||
encodeURIComponent(JSON.stringify([this.projectId]));
|
encodeURIComponent(JSON.stringify([projectId]));
|
||||||
try {
|
try {
|
||||||
const resp = await this.axios.get(givesInUrl, { headers });
|
const resp = await this.axios.get(givesInUrl, { headers });
|
||||||
if (resp.status === 200 && resp.data.data) {
|
if (resp.status === 200 && resp.data.data) {
|
||||||
@@ -400,21 +574,21 @@ export default class ProjectViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const givesOutUrl =
|
const offersToUrl =
|
||||||
this.apiServer +
|
this.apiServer +
|
||||||
"/api/v2/report/givesProvidedBy?providerId=" +
|
"/api/v2/report/offersToPlans?planIds=" +
|
||||||
encodeURIComponent(this.projectId);
|
encodeURIComponent(JSON.stringify([projectId]));
|
||||||
try {
|
try {
|
||||||
const resp = await this.axios.get(givesOutUrl, { headers });
|
const resp = await this.axios.get(offersToUrl, { headers });
|
||||||
if (resp.status === 200 && resp.data.data) {
|
if (resp.status === 200 && resp.data.data) {
|
||||||
this.givesByThis = resp.data.data;
|
this.offersToThis = resp.data.data;
|
||||||
} else {
|
} else {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Failed to retrieve gives by this project.",
|
text: "Failed to retrieve offers to this project.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -426,116 +600,270 @@ export default class ProjectViewView extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Something went wrong retrieving gives by project.",
|
text: "Something went wrong retrieving offers to this project.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
"Error retrieving gives by this project:",
|
"Error retrieving offers to this project:",
|
||||||
serverError.message,
|
serverError.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
openDialog(contact) {
|
|
||||||
this.$refs.customDialog.open(contact);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDialogResult(result) {
|
|
||||||
if (result.action === "confirm") {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// action was not "confirm" so do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param giverDid may be null
|
|
||||||
* @param description may be an empty string
|
|
||||||
* @param hours may be 0
|
|
||||||
*/
|
|
||||||
async recordGive(giverDid, description, hours) {
|
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identity before you can record a give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description && !hours) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must enter a description or some number of hours.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const fulfilledByUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
||||||
|
encodeURIComponent(projectId);
|
||||||
try {
|
try {
|
||||||
const identity = await this.getIdentity(this.activeDid);
|
const resp = await this.axios.get(fulfilledByUrl, { headers });
|
||||||
const result = await createAndSubmitGive(
|
if (resp.status === 200) {
|
||||||
this.axios,
|
this.fulfilledByThis = resp.data.data;
|
||||||
this.apiServer,
|
} else {
|
||||||
identity,
|
|
||||||
giverDid,
|
|
||||||
this.activeDid,
|
|
||||||
description,
|
|
||||||
hours,
|
|
||||||
this.projectId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status !== 201 || result.data?.error) {
|
|
||||||
console.log("Error with give result:", result);
|
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text:
|
text: "Failed to retrieve plans fulfilled by this project.",
|
||||||
result.data?.error?.message ||
|
|
||||||
"There was an error recording the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "That gift was recorded.",
|
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error: unknown) {
|
||||||
console.log("Error with give caught:", e);
|
const serverError = error as AxiosError;
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text:
|
text: "Something went wrong retrieving plans fulfilled by this project.",
|
||||||
e.userMessage ||
|
|
||||||
e.response?.data?.error?.message ||
|
|
||||||
"There was an error recording the give.",
|
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
|
console.error(
|
||||||
|
"Error retrieving plans fulfilled by this project:",
|
||||||
|
serverError.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfillersToUrl =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
||||||
|
encodeURIComponent(projectId);
|
||||||
|
try {
|
||||||
|
const resp = await this.axios.get(fulfillersToUrl, { headers });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
this.fulfillersToThis = resp.data.data;
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Failed to retrieve plan fulfillers 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 plan fulfillers to this project.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Error retrieving plan fulfillers to this project:",
|
||||||
|
serverError.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle clicking on a project entry found in the list
|
||||||
|
* @param id of the project
|
||||||
|
**/
|
||||||
|
async onClickLoadProject(projectId: string) {
|
||||||
|
localStorage.setItem("projectId", projectId);
|
||||||
|
const route = {
|
||||||
|
path: "/project/" + encodeURIComponent(projectId),
|
||||||
|
};
|
||||||
|
this.$router.push(route);
|
||||||
|
this.loadProject(projectId, await this.getIdentity(this.activeDid));
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenStreetMapUrl() {
|
||||||
|
// Google URL is https://maps.google.com/?q=LAT,LONG
|
||||||
|
return (
|
||||||
|
"https://www.openstreetmap.org/?mlat=" +
|
||||||
|
this.latitude +
|
||||||
|
"&mlon=" +
|
||||||
|
this.longitude +
|
||||||
|
"#map=15/" +
|
||||||
|
this.latitude +
|
||||||
|
"/" +
|
||||||
|
this.longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
openGiftDialog(contact?: GiverInputInfo) {
|
||||||
|
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
openOfferDialog() {
|
||||||
|
(this.$refs.customOfferDialog as OfferDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickAllContactsGifting() {
|
||||||
|
localStorage.setItem("projectId", this.projectId);
|
||||||
|
const route = {
|
||||||
|
name: "contact-gives",
|
||||||
|
};
|
||||||
|
this.$router.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickLoadClaim(jwtId: string) {
|
||||||
|
const route = {
|
||||||
|
path: "/claim/" + encodeURIComponent(jwtId),
|
||||||
|
};
|
||||||
|
this.$router.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIsFulfillable(offer: OfferServerRecord) {
|
||||||
|
const offerRecord: GenericServerRecord = {
|
||||||
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
|
claim: offer.fullClaim,
|
||||||
|
claimType: "Offer",
|
||||||
|
issuer: offer.offeredByDid,
|
||||||
|
};
|
||||||
|
return libsUtil.canFulfillOffer(offerRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickFulfillGiveToOffer(offer: OfferServerRecord) {
|
||||||
|
const offerRecord: GenericServerRecord = {
|
||||||
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
|
claim: offer.fullClaim,
|
||||||
|
issuer: offer.offeredByDid,
|
||||||
|
};
|
||||||
|
const giver: GiverInputInfo = {
|
||||||
|
did: libsUtil.offerGiverDid(offerRecord),
|
||||||
|
};
|
||||||
|
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
UNIT_CODES: Record<string, Record<string, string>> = {
|
||||||
|
BTC: {
|
||||||
|
name: "Bitcoin",
|
||||||
|
faIcon: "bitcoin-sign",
|
||||||
|
},
|
||||||
|
HUR: {
|
||||||
|
name: "hours",
|
||||||
|
faIcon: "clock",
|
||||||
|
},
|
||||||
|
USD: {
|
||||||
|
name: "US Dollars",
|
||||||
|
faIcon: "dollar",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
iconForUnitCode(unitCode: string) {
|
||||||
|
return this.UNIT_CODES[unitCode]?.faIcon || "question";
|
||||||
|
}
|
||||||
|
|
||||||
|
// return an HTTPS URL if it's not a global URL
|
||||||
|
addScheme(url: string) {
|
||||||
|
if (!libsUtil.isGlobalUri(url)) {
|
||||||
|
return "https://" + url;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return just the domain for display, if possible
|
||||||
|
domainForWebsite(url: string) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
if (!hostname) {
|
||||||
|
// happens for non-http URLs
|
||||||
|
return url;
|
||||||
|
} else if (url.endsWith(hostname)) {
|
||||||
|
// it's just the domain
|
||||||
|
return hostname;
|
||||||
|
} else {
|
||||||
|
// there's more, but don't bother displaying the whole thing
|
||||||
|
return hostname + "...";
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// must not be a valid URL
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIsConfirmable(give: GiveServerRecord) {
|
||||||
|
const giveDetails: GenericServerRecord = {
|
||||||
|
...BLANK_GENERIC_SERVER_RECORD,
|
||||||
|
claim: give.fullClaim,
|
||||||
|
claimType: "GiveAction",
|
||||||
|
issuer: give.agentDid,
|
||||||
|
};
|
||||||
|
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// similar code is found in ClaimView
|
||||||
|
async confirmClaim(give: GiveServerRecord) {
|
||||||
|
if (confirm("Do you personally confirm that this is true?")) {
|
||||||
|
// similar logic is found in endorser-mobile
|
||||||
|
const goodClaim = serverUtil.removeSchemaContext(
|
||||||
|
serverUtil.removeVisibleToDids(
|
||||||
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||||
|
give.fullClaim,
|
||||||
|
give.jwtId,
|
||||||
|
give.handleId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential & {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
object: any;
|
||||||
|
} = {
|
||||||
|
"@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,
|
||||||
|
);
|
||||||
|
} 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Projects"></QuickNav>
|
<QuickNav selected="Projects"></QuickNav>
|
||||||
<section id="Content" class="p-6 pb-24">
|
<TopMessage />
|
||||||
|
|
||||||
|
<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">
|
||||||
Your Plans
|
Your Ideas
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Quick Search -->
|
<!-- Quick Search -->
|
||||||
@@ -50,11 +52,11 @@
|
|||||||
class="block py-4 flex gap-4"
|
class="block py-4 flex gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex-none w-12">
|
<div class="flex-none w-12">
|
||||||
<EntityIcon
|
<ProjectIcon
|
||||||
:entityId="project.handleId"
|
:entityId="project.handleId"
|
||||||
: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>
|
></ProjectIcon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow overflow-hidden">
|
<div class="grow overflow-hidden">
|
||||||
@@ -67,39 +69,80 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</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, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } 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 InfiniteScroll from "@/components/InfiniteScroll";
|
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||||
import EntityIcon from "@/components/EntityIcon";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import { ProjectData } from "@/libs/endorserServer";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { InfiniteScroll, AlertMessage, QuickNav, EntityIcon },
|
components: { InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
|
||||||
})
|
})
|
||||||
export default class ProjectsView extends Vue {
|
export default class ProjectsView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
projects: ProjectData[] = [];
|
projects: ProjectData[] = [];
|
||||||
current: IIdentifier;
|
current: IIdentifier;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
alertTitle = "";
|
|
||||||
alertMessage = "";
|
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
|
||||||
async beforeCreate() {
|
/**
|
||||||
await accountsDB.open();
|
* 'created' hook runs when the Vue instance is first created
|
||||||
this.numAccounts = await accountsDB.accounts.count();
|
**/
|
||||||
|
async created() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
const activeDid = settings?.activeDid || "";
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
|
||||||
|
await accountsDB.open();
|
||||||
|
this.numAccounts = await accountsDB.accounts.count();
|
||||||
|
if (this.numAccounts === 0) {
|
||||||
|
console.error("No accounts found.");
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "You need an identifier to load your projects.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const identity = await this.getIdentity(activeDid);
|
||||||
|
this.current = identity;
|
||||||
|
this.loadProjects(identity);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error initializing:", err);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Something went wrong loading your projects.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,21 +162,30 @@ export default class ProjectsView extends Vue {
|
|||||||
if (resp.status === 200 || !resp.data.data) {
|
if (resp.status === 200 || !resp.data.data) {
|
||||||
const plans: ProjectData[] = resp.data.data;
|
const plans: ProjectData[] = resp.data.data;
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { name, description, handleId = plan.fullIri, rowid } = plan;
|
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||||
this.projects.push({ name, description, handleId, rowid });
|
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("Bad server response & data:", resp.status, resp.data);
|
console.log("Bad server response & data:", resp.status, resp.data);
|
||||||
throw Error("Failed to get projects from the server.");
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Failed to get projects from the server. Try again later.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
console.error("Got error loading projects:", error.message);
|
} catch (error: any) {
|
||||||
|
console.error("Got error loading projects:", error.message || error);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: "Got an error loading projects: " + error.message,
|
text: "Got an error loading projects.",
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -162,7 +214,7 @@ export default class ProjectsView extends Vue {
|
|||||||
onClickLoadProject(id: string) {
|
onClickLoadProject(id: string) {
|
||||||
localStorage.setItem("projectId", id);
|
localStorage.setItem("projectId", id);
|
||||||
const route = {
|
const route = {
|
||||||
name: "project",
|
path: "/project/" + encodeURIComponent(id),
|
||||||
};
|
};
|
||||||
this.$router.push(route);
|
this.$router.push(route);
|
||||||
}
|
}
|
||||||
@@ -171,13 +223,13 @@ export default class ProjectsView extends Vue {
|
|||||||
* Load projects initially
|
* Load projects initially
|
||||||
* @param identity of the user
|
* @param identity of the user
|
||||||
**/
|
**/
|
||||||
async LoadProjects(identity: IIdentifier) {
|
async loadProjects(identity: IIdentifier) {
|
||||||
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
|
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
|
||||||
const token: string = await accessToken(identity);
|
const token: string = await accessToken(identity);
|
||||||
await this.dataLoader(url, token);
|
await this.dataLoader(url, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIdentity(activeDid) {
|
public async getIdentity(activeDid: string) {
|
||||||
await accountsDB.open();
|
await accountsDB.open();
|
||||||
const account = await accountsDB.accounts
|
const account = await accountsDB.accounts
|
||||||
.where("did")
|
.where("did")
|
||||||
@@ -187,52 +239,12 @@ export default class ProjectsView extends Vue {
|
|||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Attempted to load project records with no identity available.",
|
"Attempted to load project records with no identifier available.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 'created' hook runs when the Vue instance is first created
|
|
||||||
**/
|
|
||||||
async created() {
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
const activeDid = settings?.activeDid || "";
|
|
||||||
this.apiServer = settings?.apiServer || "";
|
|
||||||
|
|
||||||
if (this.numAccounts === 0) {
|
|
||||||
console.error("No accounts found.");
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You need an identity to load your projects.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const identity = await this.getIdentity(activeDid);
|
|
||||||
this.current = identity;
|
|
||||||
this.LoadProjects(identity);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Error initializing:", err);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Something went wrong loading your projects.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handling clicking on the new project button
|
* Handling clicking on the new project button
|
||||||
**/
|
**/
|
||||||
|
|||||||
286
src/views/SearchAreaView.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- 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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Area for Nearby Search
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-2 py-4">
|
||||||
|
This location is only stored on your device. It is used to show you more
|
||||||
|
appropriate projects but is not stored on any servers.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
||||||
|
Click to Choose a Location for Nearby Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isNewMarkerSet"
|
||||||
|
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
|
@click="storeSearchBox"
|
||||||
|
>
|
||||||
|
Store This Location for Nearby Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="searchBox"
|
||||||
|
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
|
@click="forgetSearchBox"
|
||||||
|
>
|
||||||
|
Delete Stored Location
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="searchBox"
|
||||||
|
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
|
@click="resetLatLong"
|
||||||
|
>
|
||||||
|
Reset Marker
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isNewMarkerSet"
|
||||||
|
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||||
|
@click="isNewMarkerSet = false"
|
||||||
|
>
|
||||||
|
Erase Marker
|
||||||
|
</button>
|
||||||
|
<div v-if="isNewMarkerSet">
|
||||||
|
Click on the pin to erase it. Click anywhere else to set a different
|
||||||
|
different corner.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 600px; width: 800px">
|
||||||
|
<l-map
|
||||||
|
ref="map"
|
||||||
|
:center="[localCenterLat, localCenterLong]"
|
||||||
|
v-model:zoom="localZoom"
|
||||||
|
@click="setMapPoint"
|
||||||
|
>
|
||||||
|
<l-tile-layer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
layer-type="base"
|
||||||
|
name="OpenStreetMap"
|
||||||
|
/>
|
||||||
|
<l-marker
|
||||||
|
v-if="isNewMarkerSet"
|
||||||
|
:lat-lng="[localCenterLat, localCenterLong]"
|
||||||
|
@click="isNewMarkerSet = false"
|
||||||
|
/>
|
||||||
|
<l-rectangle
|
||||||
|
v-if="isNewMarkerSet"
|
||||||
|
:bounds="[
|
||||||
|
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
||||||
|
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
||||||
|
]"
|
||||||
|
:weight="1"
|
||||||
|
/>
|
||||||
|
</l-map>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import {
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LRectangle,
|
||||||
|
LTileLayer,
|
||||||
|
} from "@vue-leaflet/vue-leaflet";
|
||||||
|
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
|
||||||
|
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
||||||
|
const WORLD_ZOOM = 2;
|
||||||
|
const DEFAULT_ZOOM = 2;
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
QuickNav,
|
||||||
|
LRectangle,
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LTileLayer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class DiscoverView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
isChoosingSearchBox = false;
|
||||||
|
isNewMarkerSet = false;
|
||||||
|
|
||||||
|
// "local" vars are for the currently selected map box
|
||||||
|
localCenterLat = 0;
|
||||||
|
localCenterLong = 0;
|
||||||
|
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
localZoom = DEFAULT_ZOOM;
|
||||||
|
|
||||||
|
// searchBox reflects what is stored in the database
|
||||||
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await db.open();
|
||||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||||
|
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||||
|
this.resetLatLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapPoint(event: LeafletMouseEvent) {
|
||||||
|
if (this.isNewMarkerSet) {
|
||||||
|
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
||||||
|
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
||||||
|
} else {
|
||||||
|
// marker is not set
|
||||||
|
this.localCenterLat = event.latlng.lat;
|
||||||
|
this.localCenterLong = event.latlng.lng;
|
||||||
|
|
||||||
|
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
// Guess at a size for the bounding box.
|
||||||
|
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
|
||||||
|
const bounds = event.target.boxZoom?._map?.getBounds();
|
||||||
|
if (bounds) {
|
||||||
|
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
|
||||||
|
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
|
||||||
|
}
|
||||||
|
this.localLatDiff = latDiff;
|
||||||
|
this.localLongDiff = longDiff;
|
||||||
|
this.isNewMarkerSet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetLatLong() {
|
||||||
|
if (this.searchBox?.bbox) {
|
||||||
|
const bbox = this.searchBox.bbox;
|
||||||
|
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||||
|
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
||||||
|
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
||||||
|
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
||||||
|
this.localZoom = WORLD_ZOOM;
|
||||||
|
this.isNewMarkerSet = true;
|
||||||
|
} else {
|
||||||
|
this.isNewMarkerSet = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async storeSearchBox() {
|
||||||
|
if (this.localCenterLong || this.localCenterLat) {
|
||||||
|
try {
|
||||||
|
const newSearchBox = {
|
||||||
|
name: "Local",
|
||||||
|
bbox: {
|
||||||
|
eastLong: this.localCenterLong + this.localLongDiff,
|
||||||
|
maxLat: this.localCenterLat + this.localLatDiff,
|
||||||
|
minLat: this.localCenterLat - this.localLatDiff,
|
||||||
|
westLong: this.localCenterLong - this.localLongDiff,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
searchBoxes: [newSearchBox],
|
||||||
|
});
|
||||||
|
this.searchBox = newSearchBox;
|
||||||
|
this.isChoosingSearchBox = false;
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Saved",
|
||||||
|
text: "That has been saved in your preferences.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
this.$router.back();
|
||||||
|
} catch (err) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Updating Search Settings",
|
||||||
|
text: "Try going to a different page and then coming back.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Telling user to retry the location search setting because:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "No Location Selected",
|
||||||
|
text: "Select a location on the map.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async forgetSearchBox() {
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||||
|
searchBoxes: [],
|
||||||
|
});
|
||||||
|
this.searchBox = null;
|
||||||
|
this.localCenterLat = 0;
|
||||||
|
this.localCenterLong = 0;
|
||||||
|
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||||
|
this.localZoom = DEFAULT_ZOOM;
|
||||||
|
this.isChoosingSearchBox = false;
|
||||||
|
this.isNewMarkerSet = false;
|
||||||
|
} catch (err) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error Updating Search Settings",
|
||||||
|
text: "Try going to a different page and then coming back.",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Telling user to retry the location search setting because:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelSearchBoxSelect() {
|
||||||
|
this.isChoosingSearchBox = false;
|
||||||
|
this.localZoom = WORLD_ZOOM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav selected="Profile"></QuickNav>
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<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-8">
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
Seed Backup
|
Seed Backup
|
||||||
@@ -29,10 +39,10 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="numAccounts > 1">
|
<p v-if="numAccounts > 1">
|
||||||
<b class="text-orange-600">Note:</b> You have more than one identity
|
<b class="text-orange-600">Note:</b> You have more than one identifier
|
||||||
stored in this browser. If they are all based on the same seed as the
|
stored in this browser. If they are all based on the same seed as the
|
||||||
current identity, this one backup is sufficient; however, if you have
|
current identifier, this one backup is sufficient; however, if you have
|
||||||
different seeds for other identities, you will have to back them up
|
different seeds for other identifiers, you will have to back them up
|
||||||
separately.
|
separately.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -49,29 +59,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>You do not have an active identity.</div>
|
<div v-else>You do not have an active identifier.</div>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</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, db } from "@/db";
|
import { accountsDB, db } from "@/db/index";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
|
||||||
|
|
||||||
@Component({ components: { AlertMessage, QuickNav } })
|
interface Account {
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ components: { QuickNav } })
|
||||||
export default class SeedBackupView extends Vue {
|
export default class SeedBackupView extends Vue {
|
||||||
activeAccount = null;
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
|
activeAccount: Account | null | undefined = null;
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
showSeed = false;
|
showSeed = false;
|
||||||
alertMessage = "";
|
|
||||||
alertTitle = "";
|
|
||||||
|
|
||||||
// 'created' hook runs when the Vue instance is first created
|
// 'created' hook runs when the Vue instance is first created
|
||||||
async created() {
|
async created() {
|
||||||
@@ -84,8 +100,8 @@ export default class SeedBackupView extends Vue {
|
|||||||
const accounts = await accountsDB.accounts.toArray();
|
const accounts = await accountsDB.accounts.toArray();
|
||||||
this.numAccounts = accounts.length;
|
this.numAccounts = accounts.length;
|
||||||
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
|
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error("Got an error loading an identity:", err);
|
console.error("Got an error loading an identifier:", err);
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
|
|||||||
@@ -3,33 +3,55 @@
|
|||||||
id="Content"
|
id="Content"
|
||||||
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
|
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
|
||||||
>
|
>
|
||||||
<!-- Heading -->
|
<!-- Breadcrumb -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
<div>
|
||||||
Start Here
|
<!-- Back -->
|
||||||
</h1>
|
<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>
|
||||||
|
|
||||||
<div class="mt-8">
|
<!-- Heading -->
|
||||||
<p class="text-center text-xl mb-4 font-light">
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
Do you have an identity to import?
|
Start Here
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- id used by puppeteer test script -->
|
||||||
|
<div id="start-question" class="mt-8">
|
||||||
|
<p class="text-center text-xl font-light">
|
||||||
|
Do you want a new identifier of your own?
|
||||||
|
</p>
|
||||||
|
<p class="text-center font-light">
|
||||||
|
If you haven't used this before, click "Yes" to generate a new
|
||||||
|
identifier.
|
||||||
|
</p>
|
||||||
|
<p class="text-center mb-4 font-light">
|
||||||
|
Only click "No" if you have a seed of 12 or 24 words generated
|
||||||
|
elsewhere.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
@click="onClickYes()"
|
@click="onClickYes()"
|
||||||
class="block w-full text-center text-lg uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
class="block w-full text-center text-lg uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
No
|
Yes
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@click="onClickNo()"
|
@click="onClickNo()"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||||
>
|
>
|
||||||
Yes
|
No, I have a seed
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-if="numAccounts > 0"
|
v-if="numAccounts > 0"
|
||||||
@click="onClickDerive()"
|
@click="onClickDerive()"
|
||||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||||
>
|
>
|
||||||
Derive New Address from Seed Imported Previously
|
Derive new address from existing seed
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -37,7 +59,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { accountsDB } from "@/db";
|
import { accountsDB } from "@/db/index";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {},
|
components: {},
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<QuickNav selected="Profile"></QuickNav>
|
<QuickNav />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<!-- Heading -->
|
<!-- Breadcrumb -->
|
||||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
<div class="mb-8">
|
||||||
Achievements & Statistics
|
<!-- Back -->
|
||||||
</h1>
|
<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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Achievements & Statistics
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Here is a view of the activity you can see.
|
Here is a view of the activity you can see.
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc outside ml-4">
|
||||||
<li>Each identity and claim has a unique position.</li>
|
<li>Each identity and claim has a unique position.</li>
|
||||||
<!-- eslint-disable prettier/prettier --><!-- If we format prettier then there is extra space at the start of the line. -->
|
<!-- eslint-disable prettier/prettier --><!-- If we format prettier then there is extra space at the start of the line. -->
|
||||||
<li>Each will show at their time of appearance relative to all others.</li>
|
<li>Each will show at their time of appearance relative to all others.</li>
|
||||||
@@ -32,27 +46,37 @@
|
|||||||
{{ worldProperties.animationDurationSeconds }} seconds
|
{{ worldProperties.animationDurationSeconds }} seconds
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="float-right" @click="captureGraphics()">Screenshot</button>
|
<button class="float-right text-blue-600" @click="captureGraphics()">Screenshot</button>
|
||||||
<div id="scene-container" class="h-screen"></div>
|
<div id="scene-container" class="h-screen"></div>
|
||||||
<AlertMessage
|
|
||||||
:alertTitle="alertTitle"
|
|
||||||
:alertMessage="alertMessage"
|
|
||||||
></AlertMessage>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";
|
import { SVGRenderer } from "three/examples/jsm/renderers/SVGRenderer.js";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { World } from "@/components/World/World.js";
|
import { World } from "@/components/World/World.js";
|
||||||
import AlertMessage from "@/components/AlertMessage";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import QuickNav from "@/components/QuickNav";
|
|
||||||
|
|
||||||
@Component({ components: { AlertMessage, World, QuickNav } })
|
interface RendererSVGType {
|
||||||
|
domElement: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dictionary<T> {
|
||||||
|
[key: string]: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ components: { World, QuickNav } })
|
||||||
export default class StatisticsView extends Vue {
|
export default class StatisticsView extends Vue {
|
||||||
|
$notify!: (notification: Notification, timeout?: number) => void;
|
||||||
|
|
||||||
world: World;
|
world: World;
|
||||||
worldProperties: WorldProperties = {};
|
worldProperties: Dictionary<number> = {};
|
||||||
alertTitle = "";
|
|
||||||
alertMessage = "";
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
try {
|
try {
|
||||||
@@ -60,14 +84,14 @@ export default class StatisticsView extends Vue {
|
|||||||
const newWorld = new World(container, this);
|
const newWorld = new World(container, this);
|
||||||
newWorld.start();
|
newWorld.start();
|
||||||
this.world = newWorld;
|
this.world = newWorld;
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
const error = err as Error;
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
type: "danger",
|
type: "danger",
|
||||||
title: "Mounting Error",
|
title: "Mounting Error",
|
||||||
text: err.message,
|
text: error.message,
|
||||||
},
|
},
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
@@ -85,12 +109,12 @@ export default class StatisticsView extends Vue {
|
|||||||
ExportToSVG(rendererSVG, "test.svg");
|
ExportToSVG(rendererSVG, "test.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
public setWorldProperty(propertyName, propertyValue) {
|
public setWorldProperty(propertyName: string, propertyValue: number) {
|
||||||
this.worldProperties[propertyName] = propertyValue;
|
this.worldProperties[propertyName] = propertyValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExportToSVG(rendererSVG, filename) {
|
function ExportToSVG(rendererSVG: RendererSVGType, filename: string) {
|
||||||
const XMLS = new XMLSerializer();
|
const XMLS = new XMLSerializer();
|
||||||
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
|
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
|
||||||
const svgData = svgfile;
|
const svgData = svgfile;
|
||||||
|
|||||||
165
src/views/TestView.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- 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 -->
|
||||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||||
|
Test
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'alert',
|
||||||
|
type: 'toast',
|
||||||
|
text: 'I\'m a toast. Without a timeout, I\'m stuck.',
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Toast
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'alert',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Information Alert',
|
||||||
|
text: 'Just wanted you to know.',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Info
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'alert',
|
||||||
|
type: 'success',
|
||||||
|
title: 'Success Alert',
|
||||||
|
text: 'Congratulations!',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Success
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'alert',
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Warning Alert',
|
||||||
|
text: 'You might wanna look at this.',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Warning
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'alert',
|
||||||
|
type: 'danger',
|
||||||
|
title: 'Danger Alert',
|
||||||
|
text: 'Something terrible has happened!',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Danger
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'modal',
|
||||||
|
type: 'notification-permission',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Notif ON
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'modal',
|
||||||
|
type: 'notification-mute',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Notif MUTE
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: 'modal',
|
||||||
|
type: 'notification-off',
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Notif OFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
130
sw_scripts/additional-scripts.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* eslint-env serviceworker */
|
||||||
|
/* global workbox */
|
||||||
|
importScripts(
|
||||||
|
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
function logConsoleAndDb(message, arg1, arg2) {
|
||||||
|
// in chrome://serviceworker-internals note that the arg1 and arg2 here will show as "[object Object]" in that page but will show as expandable objects in the console
|
||||||
|
console.log(`${new Date().toISOString()} ${message}`, arg1, arg2);
|
||||||
|
if (self.appendDailyLog) {
|
||||||
|
let fullMessage = `${new Date().toISOString()} ${message}`;
|
||||||
|
if (arg1) {
|
||||||
|
fullMessage += `\n${JSON.stringify(arg1)}`;
|
||||||
|
}
|
||||||
|
if (arg2) {
|
||||||
|
fullMessage += `\n${JSON.stringify(arg2)}`;
|
||||||
|
}
|
||||||
|
self.appendDailyLog(fullMessage);
|
||||||
|
} else {
|
||||||
|
// sometimes we get the error: "Uncaught TypeError: self.appendDailyLog is not a function"
|
||||||
|
console.log(
|
||||||
|
"Not logging to DB (often because self.appendDailyLog doesn't exist).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("install", async (event) => {
|
||||||
|
console.log("Service worker got install event. Importing scripts...", event);
|
||||||
|
await importScripts(
|
||||||
|
"safari-notifications.js",
|
||||||
|
"nacl.js",
|
||||||
|
"noble-curves.js",
|
||||||
|
"noble-hashes.js",
|
||||||
|
);
|
||||||
|
// this should now be available
|
||||||
|
logConsoleAndDb("Service worker imported all scripts.");
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
logConsoleAndDb("Service worker is activating...", event);
|
||||||
|
// see https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
|
||||||
|
// and https://web.dev/articles/service-worker-lifecycle#clientsclaim
|
||||||
|
event.waitUntil(clients.claim());
|
||||||
|
logConsoleAndDb("Service worker is activated.");
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", function (event) {
|
||||||
|
let text = null;
|
||||||
|
if (event.data) {
|
||||||
|
text = event.data.text();
|
||||||
|
}
|
||||||
|
logConsoleAndDb("Service worker received a push event.", text, event);
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
// don't use payload since it is not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a special value that tells the service worker to trigger its daily check.
|
||||||
|
// See https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/c1ed026662e754348a5f91542680bd4f57e5b81e/app.py#L217
|
||||||
|
const DAILY_UPDATE_TITLE = "DAILY_CHECK";
|
||||||
|
|
||||||
|
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
||||||
|
// This is shared with the notification-test code and should be a constant. Look for the same name in HelpNotificationsView.vue
|
||||||
|
// Make sure it is something other than the DAILY_UPDATE_TITLE.
|
||||||
|
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
||||||
|
|
||||||
|
let title;
|
||||||
|
let message = "Got some empty message.";
|
||||||
|
if (payload && payload.title == DIRECT_PUSH_TITLE) {
|
||||||
|
// skip any search logic and show the message directly
|
||||||
|
title = "Direct Notification";
|
||||||
|
message = payload.message || "No details were provided.";
|
||||||
|
} else {
|
||||||
|
// any other title will run through regular filtering logic
|
||||||
|
if (payload && payload.title === DAILY_UPDATE_TITLE) {
|
||||||
|
title = "Daily Update";
|
||||||
|
} else {
|
||||||
|
title = payload.title || "Update";
|
||||||
|
}
|
||||||
|
message = await self.getNotificationCount();
|
||||||
|
}
|
||||||
|
if (message) {
|
||||||
|
const options = {
|
||||||
|
body: message,
|
||||||
|
icon: payload ? payload.icon : "icon.png",
|
||||||
|
badge: payload ? payload.badge : "badge.png",
|
||||||
|
};
|
||||||
|
await self.registration.showNotification(title, options);
|
||||||
|
logConsoleAndDb("Notified user:", options);
|
||||||
|
} else {
|
||||||
|
logConsoleAndDb("No notification message.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb("Error with push event", event, error);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
logConsoleAndDb("Service worker got a message...", event);
|
||||||
|
if (event.data && event.data.type === "SEND_LOCAL_DATA") {
|
||||||
|
self.secret = event.data.data;
|
||||||
|
event.ports[0].postMessage({ success: true });
|
||||||
|
}
|
||||||
|
logConsoleAndDb("Service worker posted a message.");
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
logConsoleAndDb("Service worker got fetch event.", event);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("error", (event) => {
|
||||||
|
logConsoleAndDb("Service worker error", event);
|
||||||
|
console.error("Full Error:", event);
|
||||||
|
console.error("Message:", event.message);
|
||||||
|
console.error("File:", event.filename);
|
||||||
|
console.error("Line:", event.lineno);
|
||||||
|
console.error("Column:", event.colno);
|
||||||
|
console.error("Error Object:", event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||||
1051
sw_scripts/nacl.js
Normal file
5248
sw_scripts/noble-curves.js
Normal file
3068
sw_scripts/noble-hashes.js
Normal file
567
sw_scripts/safari-notifications.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
function bufferFromBase64(base64) {
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const length = binaryString.length;
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromString(str, encoding = "utf8") {
|
||||||
|
if (encoding === "utf8") {
|
||||||
|
return new TextEncoder().encode(str);
|
||||||
|
} else if (encoding === "base16") {
|
||||||
|
if (str.length % 2 !== 0) {
|
||||||
|
throw new Error("Invalid hex string length.");
|
||||||
|
}
|
||||||
|
let bytes = new Uint8Array(str.length / 2);
|
||||||
|
for (let i = 0; i < str.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
} else if (encoding === "base64url") {
|
||||||
|
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
while (str.length % 4) {
|
||||||
|
str += "=";
|
||||||
|
}
|
||||||
|
return new Uint8Array(bufferFromBase64(str));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported encoding "${encoding}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Uint8Array to a string with the given encoding.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} byteArray - The Uint8Array to convert.
|
||||||
|
* @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url').
|
||||||
|
* @returns {string} - The encoded string.
|
||||||
|
* @throws {Error} - Throws an error if the encoding is unsupported.
|
||||||
|
*/
|
||||||
|
function toString(byteArray, encoding = "utf8") {
|
||||||
|
switch (encoding) {
|
||||||
|
case "utf8":
|
||||||
|
return decodeUTF8(byteArray);
|
||||||
|
case "base16":
|
||||||
|
return toBase16(byteArray);
|
||||||
|
case "base64url":
|
||||||
|
return toBase64Url(byteArray);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported encoding "${encoding}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a Uint8Array as a UTF-8 string.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} byteArray
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function decodeUTF8(byteArray) {
|
||||||
|
return new TextDecoder().decode(byteArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Uint8Array to a base16 (hex) encoded string.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} byteArray
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toBase16(byteArray) {
|
||||||
|
return Array.from(byteArray)
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Uint8Array to a base64url encoded string.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} byteArray
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toBase64Url(byteArray) {
|
||||||
|
let uint8Array = new Uint8Array(byteArray);
|
||||||
|
let binaryString = "";
|
||||||
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
|
binaryString += String.fromCharCode(uint8Array[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
let base64 = btoa(binaryString);
|
||||||
|
|
||||||
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const u8a = { toString, fromString };
|
||||||
|
|
||||||
|
function sha256(payload) {
|
||||||
|
const data = typeof payload === "string" ? u8a.fromString(payload) : payload;
|
||||||
|
return nobleHashes.sha256(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessToken(identifier) {
|
||||||
|
const did = identifier["did"];
|
||||||
|
const privateKeyHex = identifier["keys"][0]["privateKeyHex"];
|
||||||
|
|
||||||
|
const signer = await SimpleSigner(privateKeyHex);
|
||||||
|
|
||||||
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||||
|
const endEpoch = nowEpoch + 60; // add one minute
|
||||||
|
|
||||||
|
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||||
|
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
|
||||||
|
const jwt = await createJWT(tokenPayload, {
|
||||||
|
alg,
|
||||||
|
issuer: did,
|
||||||
|
signer,
|
||||||
|
});
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJWT(payload, options, header = {}) {
|
||||||
|
const { issuer, signer, alg, expiresIn, canonicalize } = options;
|
||||||
|
|
||||||
|
if (!signer)
|
||||||
|
throw new Error(
|
||||||
|
"missing_signer: No Signer functionality has been configured",
|
||||||
|
);
|
||||||
|
if (!issuer)
|
||||||
|
throw new Error("missing_issuer: No issuing DID has been configured");
|
||||||
|
if (!header.typ) header.typ = "JWT";
|
||||||
|
if (!header.alg) header.alg = alg;
|
||||||
|
|
||||||
|
const timestamps = {
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
exp: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (expiresIn) {
|
||||||
|
if (typeof expiresIn === "number") {
|
||||||
|
timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn);
|
||||||
|
} else {
|
||||||
|
throw new Error("invalid_argument: JWT expiresIn is not a number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPayload = { ...timestamps, ...payload, iss: issuer };
|
||||||
|
return createJWS(fullPayload, signer, header, { canonicalize });
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAlg = "ES256K";
|
||||||
|
|
||||||
|
async function createJWS(payload, signer, header = {}, options = {}) {
|
||||||
|
if (!header.alg) header.alg = defaultAlg;
|
||||||
|
const encodedPayload =
|
||||||
|
typeof payload === "string"
|
||||||
|
? payload
|
||||||
|
: encodeSection(payload, options.canonicalize);
|
||||||
|
const signingInput = [
|
||||||
|
encodeSection(header, options.canonicalize),
|
||||||
|
encodedPayload,
|
||||||
|
].join(".");
|
||||||
|
|
||||||
|
const jwtSigner = ES256KSignerAlg(false);
|
||||||
|
const signature = await jwtSigner(signingInput, signer);
|
||||||
|
|
||||||
|
// JWS Compact Serialization
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc7515#section-7.1
|
||||||
|
return [signingInput, signature].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeData(object) {
|
||||||
|
if (typeof object === "number" && isNaN(object)) {
|
||||||
|
throw new Error("NaN is not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof object === "number" && !isFinite(object)) {
|
||||||
|
throw new Error("Infinity is not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object === null || typeof object !== "object") {
|
||||||
|
return JSON.stringify(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.toJSON instanceof Function) {
|
||||||
|
return serialize(object.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
const values = object.reduce((t, cv, ci) => {
|
||||||
|
const comma = ci === 0 ? "" : ",";
|
||||||
|
const value = cv === undefined || typeof cv === "symbol" ? null : cv;
|
||||||
|
return `${t}${comma}${serialize(value)}`;
|
||||||
|
}, "");
|
||||||
|
return `[${values}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Object.keys(object)
|
||||||
|
.sort()
|
||||||
|
.reduce((t, cv) => {
|
||||||
|
if (object[cv] === undefined || typeof object[cv] === "symbol") {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const comma = t.length === 0 ? "" : ",";
|
||||||
|
return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`;
|
||||||
|
}, "");
|
||||||
|
return `{${values}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSection(data, shouldCanonicalize = false) {
|
||||||
|
if (shouldCanonicalize) {
|
||||||
|
return encodeBase64url(canonicalizeData(data));
|
||||||
|
} else {
|
||||||
|
return encodeBase64url(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64url(s) {
|
||||||
|
return bytesToBase64url(u8a.fromString(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function instanceOfEcdsaSignature(object) {
|
||||||
|
return typeof object === "object" && "r" in object && "s" in object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ES256KSignerAlg(recoverable) {
|
||||||
|
return async function sign(payload, signer) {
|
||||||
|
const signature = await signer(payload);
|
||||||
|
if (instanceOfEcdsaSignature(signature)) {
|
||||||
|
return toJose(signature, recoverable);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
recoverable &&
|
||||||
|
typeof fromJose(signature).recoveryParam === "undefined"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`not_supported: ES256K-R not supported when signer doesn't provide a recovery param`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function leftpad(data, size = 64) {
|
||||||
|
if (data.length === size) return data;
|
||||||
|
return "0".repeat(size - data.length) + data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function SimpleSigner(hexPrivateKey) {
|
||||||
|
const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true);
|
||||||
|
return async (data) => {
|
||||||
|
const signature = await signer(data);
|
||||||
|
return fromJose(signature);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(s, minLength) {
|
||||||
|
let input = s.startsWith("0x") ? s.substring(2) : s;
|
||||||
|
|
||||||
|
if (input.length % 2 !== 0) {
|
||||||
|
input = `0${input}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minLength) {
|
||||||
|
const paddedLength = Math.max(input.length, minLength * 2);
|
||||||
|
input = input.padStart(paddedLength, "00");
|
||||||
|
}
|
||||||
|
|
||||||
|
return u8a.fromString(input.toLowerCase(), "base16");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ES256KSigner(privateKey, recoverable = false) {
|
||||||
|
const privateKeyBytes = privateKey;
|
||||||
|
if (privateKeyBytes.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return async function (data) {
|
||||||
|
const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes);
|
||||||
|
return toJose(
|
||||||
|
{
|
||||||
|
r: leftpad(signature.r.toString(16)),
|
||||||
|
s: leftpad(signature.s.toString(16)),
|
||||||
|
recoveryParam: signature.recovery,
|
||||||
|
},
|
||||||
|
recoverable,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJose(signature, recoverable) {
|
||||||
|
const { r, s, recoveryParam } = signature;
|
||||||
|
const jose = new Uint8Array(recoverable ? 65 : 64);
|
||||||
|
jose.set(u8a.fromString(r, "base16"), 0);
|
||||||
|
jose.set(u8a.fromString(s, "base16"), 32);
|
||||||
|
|
||||||
|
if (recoverable) {
|
||||||
|
if (typeof recoveryParam === "undefined") {
|
||||||
|
throw new Error("Signer did not return a recoveryParam");
|
||||||
|
}
|
||||||
|
jose[64] = recoveryParam;
|
||||||
|
}
|
||||||
|
return bytesToBase64url(jose);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64url(b) {
|
||||||
|
return u8a.toString(b, "base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(s) {
|
||||||
|
const inputBase64Url = s
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
return u8a.fromString(inputBase64Url, "base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(b) {
|
||||||
|
return u8a.toString(b, "base16");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromJose(signature) {
|
||||||
|
const signatureBytes = base64ToBytes(signature);
|
||||||
|
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const r = bytesToHex(signatureBytes.slice(0, 32));
|
||||||
|
const s = bytesToHex(signatureBytes.slice(32, 64));
|
||||||
|
const recoveryParam =
|
||||||
|
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
|
||||||
|
|
||||||
|
return { r, s, recoveryParam };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBase64(s) {
|
||||||
|
if (
|
||||||
|
!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(
|
||||||
|
s,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new TypeError("invalid encoding");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(s) {
|
||||||
|
validateBase64(s);
|
||||||
|
var i,
|
||||||
|
d = atob(s),
|
||||||
|
b = new Uint8Array(d.length);
|
||||||
|
for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSettingById(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let openRequest = indexedDB.open("TimeSafari");
|
||||||
|
|
||||||
|
openRequest.onupgradeneeded = (event) => {
|
||||||
|
// Handle database setup if necessary
|
||||||
|
let db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains("settings")) {
|
||||||
|
db.createObjectStore("settings", { keyPath: "id" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openRequest.onsuccess = (event) => {
|
||||||
|
let db = event.target.result;
|
||||||
|
let transaction = db.transaction("settings", "readonly");
|
||||||
|
let objectStore = transaction.objectStore("settings");
|
||||||
|
let getRequest = objectStore.get(id);
|
||||||
|
|
||||||
|
getRequest.onsuccess = () => resolve(getRequest.result);
|
||||||
|
getRequest.onerror = () => reject(getRequest.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
openRequest.onerror = () => reject(openRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMostRecentNotified(id) {
|
||||||
|
try {
|
||||||
|
const db = await openIndexedDB("TimeSafari");
|
||||||
|
const transaction = db.transaction("settings", "readwrite");
|
||||||
|
const store = transaction.objectStore("settings");
|
||||||
|
const data = await getRecord(store, 1);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
data["lastNotifiedClaimId"] = id;
|
||||||
|
await updateRecord(store, data);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"safari-notifications setMostRecentNotified IndexedDB settings record not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"safari-notifications setMostRecentNotified IndexedDB error",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendDailyLog(message) {
|
||||||
|
try {
|
||||||
|
const db = await openIndexedDB("TimeSafari");
|
||||||
|
const transaction = db.transaction("logs", "readwrite");
|
||||||
|
const store = transaction.objectStore("logs");
|
||||||
|
// only keep one day's worth of logs
|
||||||
|
const todayKey = new Date().toDateString();
|
||||||
|
const previous = await getRecord(store, todayKey);
|
||||||
|
if (!previous) {
|
||||||
|
await store.clear(); // clear out everything previous when this is today's first log
|
||||||
|
}
|
||||||
|
let fullMessage = (previous && previous.message) || "";
|
||||||
|
if (fullMessage) {
|
||||||
|
fullMessage += "\n";
|
||||||
|
}
|
||||||
|
fullMessage += message;
|
||||||
|
await updateRecord(store, { date: todayKey, message: fullMessage });
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("safari-notifications logMessage IndexedDB error", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIndexedDB(dbName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(dbName);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecord(store, key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.get(key);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that this assumes there is only one record in the store.
|
||||||
|
function updateRecord(store, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.put(data);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllAccounts() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const openRequest = indexedDB.open("TimeSafariAccounts");
|
||||||
|
|
||||||
|
openRequest.onupgradeneeded = function (event) {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains("accounts")) {
|
||||||
|
db.createObjectStore("accounts", { keyPath: "id" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openRequest.onsuccess = function (event) {
|
||||||
|
const db = event.target.result;
|
||||||
|
const transaction = db.transaction("accounts", "readonly");
|
||||||
|
const objectStore = transaction.objectStore("accounts");
|
||||||
|
const getAllRequest = objectStore.getAll();
|
||||||
|
|
||||||
|
getAllRequest.onsuccess = function () {
|
||||||
|
resolve(getAllRequest.result);
|
||||||
|
};
|
||||||
|
getAllRequest.onerror = function () {
|
||||||
|
reject(getAllRequest.error);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openRequest.onerror = function () {
|
||||||
|
reject(openRequest.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNotificationCount() {
|
||||||
|
let accounts = [];
|
||||||
|
let result = null;
|
||||||
|
// 1 is our master settings ID; see MASTER_SETTINGS_KEY
|
||||||
|
const settings = await getSettingById(1);
|
||||||
|
let lastNotifiedClaimId = null;
|
||||||
|
if ("lastNotifiedClaimId" in settings) {
|
||||||
|
lastNotifiedClaimId = settings["lastNotifiedClaimId"];
|
||||||
|
}
|
||||||
|
const activeDid = settings["activeDid"];
|
||||||
|
accounts = await fetchAllAccounts();
|
||||||
|
let activeAccount = null;
|
||||||
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
|
if (accounts[i]["did"] == activeDid) {
|
||||||
|
activeAccount = accounts[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = activeAccount && activeAccount["identity"];
|
||||||
|
if (identity && "secret" in self) {
|
||||||
|
const secret = self.secret;
|
||||||
|
const secretUint8Array = self.decodeBase64(secret);
|
||||||
|
const messageWithNonceAsUint8Array = self.decodeBase64(identity);
|
||||||
|
const nonce = messageWithNonceAsUint8Array.slice(0, 24);
|
||||||
|
const message = messageWithNonceAsUint8Array.slice(24, identity.length);
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
const decrypted = self.secretbox.open(message, nonce, secretUint8Array);
|
||||||
|
const msg = decoder.decode(decrypted);
|
||||||
|
const identifier = JSON.parse(JSON.parse(msg));
|
||||||
|
|
||||||
|
headers["Authorization"] = "Bearer " + (await accessToken(identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
settings["apiServer"] + "/api/v2/report/claims",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status == 200) {
|
||||||
|
const json = await response.json();
|
||||||
|
const claims = json["data"];
|
||||||
|
let newClaims = 0;
|
||||||
|
for (let i = 0; i < claims.length; i++) {
|
||||||
|
const claim = claims[i];
|
||||||
|
if (claim["id"] === lastNotifiedClaimId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newClaims++;
|
||||||
|
}
|
||||||
|
if (newClaims > 0) {
|
||||||
|
result = `There are ${newClaims} new activities on Time Safari`;
|
||||||
|
}
|
||||||
|
const most_recent_notified = claims[0]["id"];
|
||||||
|
await setMostRecentNotified(most_recent_notified);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"safari-notifications getNotificationsCount got a bad response status when fetching claims",
|
||||||
|
response.status,
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.appendDailyLog = appendDailyLog;
|
||||||
|
self.getNotificationCount = getNotificationCount;
|
||||||
|
self.decodeBase64 = decodeBase64;
|
||||||
@@ -1,41 +1,47 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"allowJs": true,
|
||||||
"module": "esnext",
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"target": "esnext",
|
||||||
"jsx": "preserve",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"strict": true,
|
||||||
"experimentalDecorators": true,
|
"strictPropertyInitialization": false,
|
||||||
"skipLibCheck": true,
|
"jsx": "preserve",
|
||||||
"esModuleInterop": true,
|
"moduleResolution": "node",
|
||||||
"allowSyntheticDefaultImports": true,
|
"experimentalDecorators": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"skipLibCheck": true,
|
||||||
"useDefineForClassFields": true,
|
"esModuleInterop": true,
|
||||||
"sourceMap": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"baseUrl": ".",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": [
|
"useDefineForClassFields": true,
|
||||||
"webpack-env"
|
"sourceMap": true,
|
||||||
],
|
"baseUrl": "./src",
|
||||||
"paths": {
|
"types": [
|
||||||
"@/*": [
|
"webpack-env"
|
||||||
"src/*"
|
],
|
||||||
]
|
"paths": {
|
||||||
|
"@/components/*": ["components/*"],
|
||||||
|
"@/views/*": ["views/*"],
|
||||||
|
"@/db/*": ["db/*"],
|
||||||
|
"@/libs/*": ["libs/*"],
|
||||||
|
"@/constants/*": ["constants/*"],
|
||||||
|
"@/store/*": ["store/*"],
|
||||||
|
},
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const { defineConfig } = require("@vue/cli-service");
|
const { defineConfig } = require("@vue/cli-service");
|
||||||
|
const { gitDescribeSync } = require("git-describe");
|
||||||
|
|
||||||
|
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash;
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
transpileDependencies: true,
|
transpileDependencies: true,
|
||||||
configureWebpack: {
|
configureWebpack: {
|
||||||
@@ -11,5 +15,9 @@ module.exports = defineConfig({
|
|||||||
iconPaths: {
|
iconPaths: {
|
||||||
faviconSVG: "img/icons/safari-pinned-tab.svg",
|
faviconSVG: "img/icons/safari-pinned-tab.svg",
|
||||||
},
|
},
|
||||||
|
workboxPluginMode: "InjectManifest",
|
||||||
|
workboxOptions: {
|
||||||
|
swSrc: "./sw_scripts/additional-scripts.js",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
111
web-push.md
@@ -1,3 +1,6 @@
|
|||||||
|
|
||||||
|
# Overivew of Web Push
|
||||||
|
|
||||||
Web Push notifications is a web browser messaging protocol defined by the W3C.
|
Web Push notifications is a web browser messaging protocol defined by the W3C.
|
||||||
|
|
||||||
Discussions of this interesting technology are clouded because of a
|
Discussions of this interesting technology are clouded because of a
|
||||||
@@ -26,8 +29,8 @@ from the SERVICE.
|
|||||||
The SERVICE will provide context and obtain explicit permission before prompting
|
The SERVICE will provide context and obtain explicit permission before prompting
|
||||||
for notification permission:
|
for notification permission:
|
||||||
|
|
||||||
In order to provide this context and explict permission a two-step opt-in process
|
In order to provide this context and explicit permission, a two-step opt-in process
|
||||||
where the user is first presented with a pre-permission dialog box that explains
|
first presents the user with a pre-permission dialog box that explains
|
||||||
what the notifications are for and why they are useful. This may help reduce the
|
what the notifications are for and why they are useful. This may help reduce the
|
||||||
possibility of users clicking "don't allow".
|
possibility of users clicking "don't allow".
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@ The `sw.js` file contains the logic for what a service worker should do.
|
|||||||
It executes in a separate thread of execution from the web page but provides a
|
It executes in a separate thread of execution from the web page but provides a
|
||||||
means of communicating between itself and the web page via messages.
|
means of communicating between itself and the web page via messages.
|
||||||
|
|
||||||
Note that there is a scope can specify what network requests it may
|
Note that there is a scope that can specify what network requests it may
|
||||||
intercept.
|
intercept.
|
||||||
|
|
||||||
The Vue project already has its own service worker but it is possible to
|
The Vue project already has its own service worker but it is possible to
|
||||||
@@ -314,3 +317,105 @@ OneSignal) can perform in the role of such proxies.
|
|||||||
|
|
||||||
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
|
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
|
||||||
time on.
|
time on.
|
||||||
|
|
||||||
|
A BROWSER may also remove a subscription. In order to remove a subscription,
|
||||||
|
the registration record must be retrieved from the serviceWorker using
|
||||||
|
`navigator.serviceWorker.ready`. Within the `ready` property is the
|
||||||
|
`pushManager` which has a `getSubscription` method. Once you have the
|
||||||
|
subscription object, you may call the `unsubscribe` method. `unsubscribe` is
|
||||||
|
asynchronnous and returns a boolean true if it is successful in removing the
|
||||||
|
subscription and false if not.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
async function unsubscribeFromPush() {
|
||||||
|
// Check if the browser supports service workers
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
// Get the registration object for the service worker
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Get the existing subscription
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Unsubscribe
|
||||||
|
const successful = await subscription.unsubscribe();
|
||||||
|
if (successful) {
|
||||||
|
console.log("Successfully unsubscribed from push notifications.");
|
||||||
|
// You can also inform your server to remove this subscription
|
||||||
|
} else {
|
||||||
|
console.log("Failed to unsubscribe from push notifications.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No subscription was found.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Service workers are not supported by this browser.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
unsubscribeFromPush().catch((err) => {
|
||||||
|
console.error("An error occurred while unsubscribing from push notifications", err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
|
||||||
|
|
||||||
|
|
||||||
|
# NOTIFICATION DIALOG WORKFLOW
|
||||||
|
|
||||||
|
## ON APP FIRST-LAUNCH:
|
||||||
|
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
|
||||||
|
|
||||||
|
- "Turn on Notifications": triggers the browser's own notification permission prompt.
|
||||||
|
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
|
||||||
|
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
|
||||||
|
|
||||||
|
## IF THE USER CHOOSES "NEVER":
|
||||||
|
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
|
||||||
|
|
||||||
|
## TO TEMPORARILY MUTE NOTIFICATIONS:
|
||||||
|
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
|
||||||
|
|
||||||
|
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
|
||||||
|
- "Mute until I turn it back on" button to indefinitely mute notifications.
|
||||||
|
- "Cancel" to make no changes and dismiss the dialog.
|
||||||
|
|
||||||
|
## TO UNMUTE NOTIFICATIONS:
|
||||||
|
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
|
||||||
|
|
||||||
|
## TO TURN OFF NOTIFICATIONS:
|
||||||
|
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
|
||||||
|
|
||||||
|
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
|
||||||
|
- "Leave it On" to make no changes and dismiss the dialog.
|
||||||
|
|
||||||
|
# NOTIFICATION STATES
|
||||||
|
|
||||||
|
* Unpermissioned. Push server cannot send notifications to the user because it does not have permission.
|
||||||
|
This may be the same as when the user gave permission in the past but has since revoked it at the OS or browser
|
||||||
|
level, outside the app. (User can change to Permissioned when the user gives permission.)
|
||||||
|
* Permissioned. (User can change to Unpermissioned via the OS or browser settings.)
|
||||||
|
* Active. (User can change to Muted when the user mutes notifications.)
|
||||||
|
* Muted. (User can change to Active when the user toggles it.)
|
||||||
|
(Turning mute off automatically after some amount of time is not planned in version 1.)
|
||||||
|
|
||||||
|
|
||||||
|
# TROUBLESHOOTING
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
#### Firefox
|
||||||
|
|
||||||
|
Go to `about:debugging` and click on `Inspect` for the service worker.
|
||||||
|
|
||||||
|
#### Chrome
|
||||||
|
|
||||||
|
Go to `chrome://inspect/#service-workers` and click on `Inspect` for the service worker.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|
|||||||