Compare commits

...

287 Commits

Author SHA1 Message Date
1b9ae96006 fix linting that caused failures 2023-12-01 11:06:50 -07:00
Matthew Raymer
4dd5664462 Fix exit from loops 2023-12-01 07:12:13 -05:00
Matthew Raymer
7d6a45061d A few missing configurations 2023-12-01 06:50:17 -05:00
Matthew Raymer
3b32c2b156 Some updates after a quick test run 2023-12-01 05:02:17 -05:00
Matthew Aaron Raymer
1ee6203f4c Small package update 2023-12-01 17:14:17 +08:00
Matthew Aaron Raymer
d93299c352 Update worker dependencies 2023-12-01 17:04:14 +08:00
Matthew Aaron Raymer
9aea7a576d Merging the workflow 2023-12-01 17:03:19 +08:00
714bb169fa Merge pull request 'fix keyword search to work for both local and everywhere searches' (#86) from searching into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#86
2023-12-01 01:28:30 -05:00
606d9ec734 fix the result count when the search filters change 2023-11-30 20:30:00 -07:00
7a3bd069b8 fix keyword search to work for both local and everywhere searches 2023-11-30 20:15:54 -07:00
b1ac9e71cb Merge pull request 'copy contact JWT URL; add project ID to URL' (#85) from more-small2 into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#85
2023-11-27 20:39:12 -05:00
c1176fa24d clear search results on restarting search 2023-11-26 20:08:30 -07:00
1cf6660e6c move both the contributions to & from this project to the last column (for when they both show) 2023-11-26 18:49:48 -07:00
6957678474 include project ID in the URLs to the project-details page 2023-11-26 18:42:28 -07:00
889b6d5737 update tasks 2023-11-26 18:22:35 -07:00
1be10b1511 add ability to copy the QR code (even though we don't have a way to read it in yet) 2023-11-26 18:20:15 -07:00
85405317ee update name of the app, and update version to 0.1.4 2023-11-23 11:15:48 -07:00
072497a553 Merge pull request 'add checkbox for given-to-user since sometimes it's to someone else' (#84) from to-me-checkbox into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#84
2023-11-21 22:03:22 -05:00
8a33ccfdcf add checkbox for given-to-user since sometimes it's to someone else 2023-11-20 20:41:26 -07:00
7311d36726 bump to v 0.1.4 2023-11-20 19:28:36 -07:00
7e819ea4de fix location checkbox so that we can uncheck it 2023-11-20 19:23:22 -07:00
5670f23bf3 Merge pull request 'record & list offers on a project' (#83) from many-misc into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#83
2023-11-20 00:37:02 -05:00
08d9ca3a25 Merge branch 'master' into many-misc 2023-11-20 00:36:49 -05:00
607666a2f9 add display of offers on project page 2023-11-19 19:44:11 -07:00
0a618cc4ff add button to record an offer on a project 2023-11-19 17:08:42 -07:00
e387794db3 Merge pull request 'much miscellany for small features and documentation & testing' (#82) from many-misc into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#82
2023-11-18 20:48:49 -05:00
ab1a725c1b fix & doc: don't show number of projects until loaded; refactor task list 2023-11-18 15:18:55 -07:00
46d76013e8 make a back button at the top and the tabs at the bottom of each page 2023-11-18 14:49:38 -07:00
faf8f4f6a9 feat: default to remote project search if no search area filter is selected 2023-11-18 14:38:55 -07:00
154fcd98a5 doc: refactor registration message & tests 2023-11-18 14:13:19 -07:00
c391385500 feat & doc: automatically set visibility & alert about registration, alert to help onboard (and refine docs & tasks) 2023-11-18 13:43:01 -07:00
b64f35869e fix: update openssl script according to new info 2023-11-18 06:25:30 -07:00
45fbf7ade5 Merge pull request 'Update docs, for openssl & tasks' (#81) from openssl into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#81
2023-11-17 23:49:29 -05:00
92fcffdfc5 update the script commands for JWT signature generation & validation 2023-11-17 20:33:57 -07:00
5f5562f5e3 doc: update tasks 2023-11-17 17:47:41 -07:00
74ed025377 move search area selection out of discover onto it's own screen 2023-11-17 15:23:48 -07:00
f36ecfd8db misc look-and-feel tweaks 2023-11-17 13:54:32 -07:00
fc70a11bd8 Merge pull request 'allow access to a different project by link' (#79) from project-ids into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#79
2023-11-12 05:12:30 -05:00
73f890beac modify English verbiage for push-server instructions 2023-11-11 22:26:10 -07:00
67dce9e678 allow for a project ID in the URL 2023-11-11 22:25:06 -07:00
2b66ddfb83 update CHANGELOG 2023-11-11 13:25:05 -07:00
56fc2893a2 fix version in package-lock; modify a project task 2023-11-11 13:11:40 -07:00
Matthew Raymer
552ad5a267 Two static scripts for push notifications 2023-11-08 06:02:07 -05:00
Matthew Raymer
910f57ec7d Update version 2023-11-08 05:53:50 -05:00
Matthew Raymer
e813315dad Fixes and updates 2023-11-08 05:52:29 -05:00
aea9626c06 Merge pull request 'web-push-permissions' (#67) from web-push-permissions into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#67
2023-11-08 01:49:49 -05:00
Matthew Aaron Raymer
7f0f1b7fc8 Merging changes 2023-11-08 14:47:48 +08:00
Matthew Raymer
cfc4d0a947 Updates package file 2023-11-07 21:12:49 +08:00
Matthew Raymer
8684488def Stip out old approach 2023-11-07 08:04:26 -05:00
Matthew Raymer
a820a7b131 Minor formatting changes 2023-11-07 21:01:37 +08:00
30d45c0acf tweak some more tasks (so it's clear which ones require UI expertise) 2023-11-06 18:07:57 -07:00
221bb2a27c move other route into alphabetical order (no logic changes) 2023-11-06 18:04:35 -07:00
2961e29831 move routes into alphabetical order (no logic changes) 2023-11-06 09:09:34 -07:00
5ae5e110c2 refine task list 2023-11-06 09:08:56 -07:00
20c2954be1 remove unused properties 2023-11-06 08:59:31 -07:00
a848e1fa81 Merge pull request 'consolidate into GiftedDialog because the result was always the same' (#76) from more-smalls into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#76
2023-11-06 07:56:07 -05:00
85bd807bcc allow view of feed without an identifier 2023-11-05 21:46:46 -07:00
eeece8a1b4 refactor UI & remove unused code on account screen 2023-11-05 18:15:52 -07:00
bbfc1e1007 modify naming for unnamed gifter 2023-11-05 18:05:59 -07:00
433d0c023e refactor look of identity page 2023-11-05 17:55:03 -07:00
ac6376243b refactor name setting and other small messaging & types 2023-11-05 17:30:23 -07:00
a12f033b72 refactor type that was a duplication 2023-11-05 17:02:24 -07:00
42cd7d00de fix message to user 2023-11-05 17:02:05 -07:00
c388cc8cfe remove lastName and just have a single name field 2023-11-05 16:34:18 -07:00
6d4d4e40c3 Merge branch 'master' into more-smalls 2023-11-05 15:55:13 -07:00
3b39faf173 fix linting error 2023-11-05 15:51:47 -07:00
f43ecc98aa tweak notifications on errors and other responses 2023-11-05 08:29:36 -07:00
5b7ccf9ef0 fix where the project ID was not included; fix the pause when submitting give & show toast of aknowledgement; remove 'emit' 2023-11-05 07:46:16 -07:00
9bacd4da87 consolidate into GiftedDialog because the result was always the same 2023-11-04 18:48:31 -06:00
Matthew Raymer
ee28b18b14 Added message handler 2023-11-04 08:46:48 -04:00
7450d8d1c3 Merge pull request 'more miscellany' (#75) from misc2 into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#75
2023-11-04 00:14:20 -04:00
7490cfc557 Merge branch 'master' into misc2 2023-11-03 22:12:31 -06:00
95287e4dd0 Merge pull request 'add IDs for puppeteer test script' (#74) from ids-for-tests into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#74
2023-11-03 21:50:08 -04:00
679d1a70e8 Merge pull request 'allow edit of a contact's name' (#73) from contact-edit into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#73
2023-11-03 21:49:40 -04:00
047fb263dd Merge pull request 'a couple of fixes' (#72) from fixes into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#72
2023-11-03 21:49:12 -04:00
b76cf28bc2 Merge pull request 'edit text on the help page' (#70) from misc into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#70
2023-11-03 21:47:45 -04:00
58c091cdaa add test view to hold testing functionality 2023-11-03 16:48:34 -06:00
0df5a975f3 add bottom navigation to edit-plan page 2023-11-03 16:17:25 -06:00
94051e6ba9 revert changes that are contained in other PRs 2023-11-03 13:27:50 -06:00
8e60f53f0b fix name of class to match the file name (and avoid clash with existing class) 2023-11-03 13:23:11 -06:00
afc48a5434 add IDs for puppeteer test script 2023-11-03 13:19:42 -06:00
6eb3381a98 enhancements to contact name edit 2023-11-03 10:24:48 -06:00
2bec218cc5 allow edit of a contact's name 2023-11-02 20:50:33 -06:00
327c655fb3 add a couple more tasks 2023-11-02 09:15:21 -06:00
866aad069f add multiple new tasks based on board meeting feedback 2023-11-02 09:04:42 -06:00
7f6c938029 fix display of given items; bump version to 0.1.2 2023-11-01 09:09:20 -06:00
6d2df4a50c fix the error message on a successful result 2023-11-01 08:56:22 -06:00
7305606546 improve give descriptions, bump to v 0.1.1 2023-10-31 20:53:39 -06:00
2a9ff8aa77 add some instructions and other tasks 2023-10-31 20:19:21 -06:00
829994491c improve messages for feed summaries 2023-10-31 19:58:37 -06:00
ce06e8f0fa update derivation path to the one for time 2023-10-31 19:58:07 -06:00
1ee751eea8 Merge pull request 'add QR scanner for adding a contact' (#69) from qr-reader-rebased into master 2023-10-31 10:34:59 -04:00
Matthew Raymer
2d38183dce Some updates and nudging toward notification ui 2023-10-16 07:43:10 -04:00
Matthew Raymer
082a6eae1f Refactor db setup a bit 2023-10-16 18:49:18 +08:00
Matthew Raymer
d07fb47721 Debugging 2023-10-04 22:16:58 -04:00
Matthew Raymer
ccb6160bca Updates to additional scripts 2023-10-02 04:41:50 -04:00
116b239616 change derivation path, add task to test app updating 2023-09-15 15:00:33 -06:00
Matthew Raymer
2eaa4203aa Debugging and ironing out the flow 2023-09-12 08:03:03 -04:00
Matthew Raymer
f27a18c712 Fix db path; add new pathing for web-push; load VAPID at boot 2023-09-11 07:51:59 -04:00
f47346cc35 edit text on the help page 2023-09-09 08:13:55 -06:00
Matthew Raymer
2c4a920c3c Updates for web push workflow 2023-09-08 21:04:50 +08:00
0e02268950 update tasks 2023-09-07 19:36:49 -06:00
94d9c425ad add QR scanner for adding a contact 2023-09-07 18:59:19 -06:00
Matthew Raymer
ed91cadd9d Added permission step; UI workflow documentation 2023-09-07 20:42:15 +08:00
Matthew Raymer
a6de282aec Another upgrade of package.json 2023-09-07 18:31:55 +08:00
2db662c125 Merge pull request 'New branch for cleanup and web push' (#65) from new-web-push into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#65
2023-09-07 06:13:45 -04:00
b7892f4dfa Merge branch 'master' into new-web-push 2023-09-07 06:12:56 -04:00
Matthew Raymer
3bbb138299 Merge branch 'new-web-push' of ssh://173.199.124.46:222/trent_larson/kick-starter-for-time-pwa into new-web-push 2023-09-07 18:09:50 +08:00
Matthew Raymer
5b5c631001 Fix typing errors from the refactoring 2023-09-07 18:07:53 +08:00
Jose Olarte III
e60b56a0b0 Added notification dialog workflow description 2023-09-07 18:04:00 +08:00
Jose Olarte III
d3e025c293 Dialog test suite additions 2023-09-07 18:03:46 +08:00
Jose Olarte III
6f4027f614 Notification UI changes to accommodate mute 2023-09-07 18:03:33 +08:00
249811efe3 Merge branch 'master' into new-web-push 2023-09-07 02:35:20 -04:00
bd2455458f Merge pull request 'mostly removing the "Anonymous" word and associated graphic' (#66) from miscellany into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#66
2023-09-07 02:35:09 -04:00
a053c48819 make the Anonymous icon the same everywhere, and tweak more in the task list 2023-09-06 20:06:11 -06:00
9486142b2a make the Anonymous icon to be blank, plus some other renaming & task cleanup 2023-09-06 19:48:47 -06:00
Matthew Raymer
2fba7f2a55 Merge branch 'new-web-push' of ssh://173.199.124.46:222/trent_larson/kick-starter-for-time-pwa into new-web-push 2023-09-06 20:49:23 +08:00
Matthew Raymer
31d13b9143 Refactoring for cleanup 2023-09-06 20:46:16 +08:00
Matthew Raymer
852bd93f3f New branch for cleanup and web push 2023-09-05 21:25:04 +08:00
b707bfce40 Merge branch 'master' into new-web-push 2023-09-05 08:25:28 -04:00
bdb8e2e32a Merge pull request 'assign boundaries for local search' (#63) from search-bbox into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#63
2023-09-05 07:45:23 -04:00
06b173e861 fix bad prompt if they choose to confirm, and use the right phrasing for singular "hour" 2023-09-04 19:55:58 -06:00
6a8b9d36a7 move limit checks out of "advanced" section, and always allow registration (for tests) 2023-09-04 19:03:45 -06:00
52a6451a2d remove technical details from user-facing messages 2023-09-04 15:37:39 -06:00
4b9cbd0e9f fix all the lint warnings 2023-09-04 15:17:32 -06:00
a5e0c847b1 fix the last of the type annotations (still have to fix no-explicit-any warnings) 2023-09-04 08:03:43 -06:00
Matthew Raymer
fd43da93a5 A whole lot of cleaning going on 2023-09-04 20:44:00 +08:00
b59bcf249a fix many more typescript errors 2023-09-03 21:40:40 -06:00
b05b602acd fix many, many more type errors 2023-09-03 10:02:17 -06:00
b8aaffbf8d commit the recreated package-lock.json file 2023-09-03 10:01:51 -06:00
Matthew Raymer
5501ac1a2f Many fixes -- especially and endorserServer 2023-09-03 21:08:30 +08:00
Matthew Aaron Raymer
b514d64068 Type fixes 2023-09-02 18:15:30 +08:00
Matthew Aaron Raymer
c4537420b4 Merge branch 'search-bbox' of ssh://173.199.124.46:222/trent_larson/kick-starter-for-time-pwa into search-bbox 2023-09-02 17:09:58 +08:00
Matthew Aaron Raymer
5f50338dd0 We have to remove the decorator in package -- it breaks install 2023-09-02 17:09:41 +08:00
308386d829 Merge branch 'master' into search-bbox 2023-09-02 04:08:40 -04:00
999d7abc04 fix linting 2023-09-01 13:14:40 -06:00
f7f947bfdd finish selection of a location on the nearby search -- it all works :-) 2023-08-31 20:55:51 -06:00
26d9b134c7 refactor map selection, and now location selection & cancellation works (but not saving yet) 2023-08-31 18:58:34 -06:00
43f942c905 Merge pull request 'move ID switcher to advanced section' (#62) from move-id-switch into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#62
2023-08-30 02:43:57 -04:00
8ee610c1bc start the assignment of boundaries for a local search 2023-08-29 20:47:22 -06:00
8d15b7bfb8 Merge branch 'master' into move-id-switch 2023-08-28 08:20:33 -06:00
5c57ee3e72 move ID switcher to advanced section 2023-08-28 08:18:02 -06:00
Matthew Raymer
3f7bcbfd76 Added unsubscribe and mute 2023-08-28 19:57:47 +08:00
ef0988c9ec Merge pull request 'add link to map on projects which have a location' (#61) from project-map-link into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#61
2023-08-28 01:30:25 -04:00
22de6113e9 Merge branch 'master' into project-map-link 2023-08-28 01:27:30 -04:00
87139f203c Merge pull request 'fix some formatting of web-push doc' (#60) from plan-loc into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#60
2023-08-28 01:26:45 -04:00
c8de13d376 add link to map on projects which have a location 2023-08-27 20:20:16 -06:00
2ccfb283b4 Merge branch 'master' into plan-loc 2023-08-27 13:12:57 -06:00
552fce3281 update the test URL (to remove the port) 2023-08-27 13:12:12 -06:00
12de3dec4f take a stab at the meaning of the "options" in the web-push doc 2023-08-27 11:58:02 -06:00
b171e1ae13 Merge branch 'master' into plan-loc 2023-08-27 11:51:31 -06:00
dc54006fca fix some formatting of web-push doc 2023-08-27 11:47:43 -06:00
9b4db018f5 Merge pull request 'add a location marker to a new plan' (#59) from plan-loc into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#59
2023-08-27 01:07:57 -04:00
519f320a2e add a location marker to a new plan 2023-08-26 21:12:46 -06:00
Matthew Aaron Raymer
f1b3094026 Added a bit more of the workflow on the client side 2023-08-26 16:27:43 +08:00
Matthew Raymer
e5ad87f4d5 Updates to web push guide 2023-08-25 21:45:55 +08:00
Matthew Raymer
7de6171911 Notes on service worker for development of web push 2023-08-24 21:21:15 +08:00
Matthew Raymer
bb6bacac97 Some notes on web-push nuts and bolts 2023-08-24 21:01:31 +08:00
Matthew Raymer
40fc6a29a4 Small fix 2023-08-24 18:49:06 +08:00
Matthew Raymer
9ec19fa4ee Added a quick fix to console signing. Need to edit text later 2023-08-24 18:46:33 +08:00
28b20f86ea Add 'web-push.md'
Document Describing Web Push Workflow
2023-08-24 05:14:17 -04:00
502109de4b Merge pull request 'log bug with "Share Your Info" button' (#58) from increment-derived into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#58
2023-08-24 05:10:47 -04:00
97274a701d update derivation verbiage 2023-08-21 08:42:18 -06:00
81a6d73f2f update task for alert fixes 2023-08-21 07:31:28 -06:00
5804f692b7 update to new alerting -- old alerts were broken 2023-08-21 07:29:26 -06:00
257aa8d49e Merge branch 'master' into increment-derived 2023-08-21 07:27:33 -04:00
34806b514b log bug with "Share Your Info" button 2023-08-21 06:05:08 -06:00
0024238ca8 Merge pull request 'Added modal dialog for notification permission setting' (#57) from notification-request-permission-dialog into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#57
2023-08-21 03:02:23 -04:00
0af05b4b0d Merge branch 'master' into notification-request-permission-dialog 2023-08-21 03:02:03 -04:00
b9d59eb642 Merge pull request 'allow use of custom derivation path, and add way to increment derivation for existing' (#56) from increment-derived into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#56
2023-08-21 02:59:35 -04:00
0c05505c46 allow use of custom derivation path, and add way to increment derivation for existing 2023-08-20 19:46:12 -06:00
98c093f655 add potential tasks for multiple derivation paths 2023-08-20 06:29:29 -06:00
88112e0629 add note for deployment 2023-08-19 19:10:32 -06:00
Jose Olarte III
6ab92a83bd Added modal dialog for notification permission setting 2023-08-18 21:38:26 +08:00
Jose Olarte III
bfc52151c0 Restored anonymous item in home share section 2023-08-10 18:44:27 +08:00
Jose Olarte III
868b5413de Added jdenticon to project view 2023-08-10 18:41:17 +08:00
Jose Olarte III
50005a0dc3 Removed duplicate item heading 2023-08-10 18:21:15 +08:00
Jose Olarte III
9247b6ed1f Changed ID to name 2023-08-10 18:20:38 +08:00
Jose Olarte III
75f26ccf2d eslint fixes 2023-08-10 18:16:20 +08:00
Matthew Aaron Raymer
bfd2498906 Merge branch 'master' of ssh://173.199.124.46:222/trent_larson/kick-starter-for-time-pwa 2023-08-07 16:33:47 +08:00
Matthew Aaron Raymer
4933017e9c Merge remote-tracking branch 'origin/cleanup-and-qr-code' 2023-08-07 16:33:37 +08:00
Matthew Aaron Raymer
18c23451bb Merge remote-tracking branch 'origin/contact-amounts-ui-improvements' 2023-08-07 16:10:34 +08:00
Matthew Aaron Raymer
304985f88d Merge remote-tracking branch 'origin/polish-ui-project-view' 2023-08-07 16:08:48 +08:00
Matthew Aaron Raymer
9a41aff8f0 Merge remote-tracking branch 'origin/jdenticon-entity-photos' 2023-08-07 15:59:47 +08:00
Matthew Aaron Raymer
e19cd980b4 Merging with corrections 2023-08-07 15:54:06 +08:00
6d1756b4a5 Merge pull request 'update URL for API server' (#55) from update-api-server into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#55
2023-08-07 03:13:40 -04:00
ac4c92d8e8 Merge branch 'master' into update-api-server 2023-08-07 03:13:26 -04:00
937a3cb6c6 Merge pull request 'Minor cleanup' (#54) from seed-backup-view-improvements into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#54
2023-08-07 03:11:23 -04:00
194f741984 Merge branch 'master' into seed-backup-view-improvements 2023-08-07 03:11:07 -04:00
b31c0d975c Merge pull request 'fix display of gives on contact screen; adjust give UI for project' (#49) from ui-fix into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#49
2023-08-07 02:42:30 -04:00
f09684d7cd Merge branch 'master' into ui-fix 2023-08-07 02:41:30 -04:00
1767a48a7f Merge pull request 'New notification system + test' (#48) from notiwind-alert into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#48
2023-08-07 02:41:05 -04:00
d2e2fc707e Merge branch 'master' into notiwind-alert 2023-08-07 02:34:22 -04:00
bf6830a1a8 update URL for API server 2023-08-05 18:20:03 -06:00
Jose Olarte III
fe09f5180d Minor cleanup 2023-08-03 20:12:47 +08:00
Jose Olarte III
5addc3c206 Visual fixes 2023-07-31 21:36:05 +08:00
Jose Olarte III
69f2f3cfd2 Converted to tabular structure
For more adaptive widths
2023-07-31 21:32:19 +08:00
Jose Olarte III
4de66b1609 Cleaned up Project view UI 2023-07-28 20:22:03 +08:00
Jose Olarte III
4b87692231 Added jdenticon to more views 2023-07-25 18:41:23 +08:00
Jose Olarte III
503bb1bd93 Added jdenticon to home and contact gives views 2023-07-24 19:32:30 +08:00
Jose Olarte III
9fa3b8be0b Built jdenticon component 2023-07-24 19:32:11 +08:00
3b1a9b9c5b change description since issuer isn't necessarily giver, and add a task 2023-07-23 11:35:03 -06:00
Jose Olarte III
f55e50067f Replaced all alertMessage calls with notiwind 2023-07-21 20:26:00 +08:00
7f48149d6f add some detail for the map pin, plus other refactors 2023-07-20 20:59:53 -06:00
c5b4921583 fix failure to retrieve all totals for gives from me 2023-07-20 20:33:55 -06:00
b28689ad06 walk back edits that were forward-looking but broken 2023-07-20 20:15:17 -06:00
0444b5be32 fix so that contact recipient is also recorded 2023-07-20 20:12:13 -06:00
4866416aae fix didInfo logic, and add to project lists 2023-07-20 19:02:18 -06:00
e48a4ed05b fix to use the real project name, and add creator 2023-07-20 18:35:27 -06:00
87cfead094 fix display of gives on contact screen; adjust give UI for project 2023-07-20 18:22:45 -06:00
179a5cd9f8 add task for publicly-accessible project 2023-07-20 08:32:43 -06:00
Jose Olarte III
eff67c2a4a Replaced alertMessage calls with Notiwind 2023-07-20 21:51:23 +08:00
Jose Olarte III
db22d559b7 User-friendly error messages 2023-07-20 18:04:08 +08:00
Matthew Raymer
c4443f2ed1 Added error handling and new alerts in DiscoverView 2023-07-20 16:36:33 +08:00
be348461f1 Update 'project.task.yaml' 2023-07-19 22:07:02 -04:00
6e2c596030 proposed move to jdenticon 2023-07-19 22:06:30 -04:00
Jose Olarte III
05a7758c65 New notification system + test
Set of buttons added to home view for preview. Comes in toast (self-dismiss) and context alert (info, warning, danger) variants.
2023-07-19 19:48:22 +08:00
Matthew Raymer
c502869c5f Add button with gift icon for future dialog 2023-07-19 18:41:12 +08:00
Matthew Raymer
b7aacd63e6 Add and edit project tasks list 2023-07-19 18:26:59 +08:00
Matthew Raymer
5bc0e27b30 Use a DID instead of a name ... this may need some better design on the dialog @jose 2023-07-19 18:25:58 +08:00
Matthew Raymer
a4fe94f081 Add a back arrow 2023-07-19 18:25:03 +08:00
Matthew Raymer
8de95566df Cleaning up this page to switch to GiftedDialog 2023-07-19 18:23:55 +08:00
Matthew Raymer
97569697f6 * show DID if no name
* hide no contacts when there are no contacts
* replace contact property with giver (? can you have another contact give you something ?)
2023-07-19 18:22:35 +08:00
Matthew Raymer
b9ed9d748b Only project owner may see edit button of a project 2023-07-19 18:19:29 +08:00
Matthew Raymer
790d44db81 Remove the stub context menu causing vertical ellipsis 2023-07-19 16:30:56 +08:00
e2bf469dc1 set assignees on several tasks. 2023-07-19 02:26:44 -04:00
d0ec7930e1 Merge pull request 'UI improvements' (#46) from contacts-view-improvements into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#46
2023-07-19 02:18:01 -04:00
3e2cd1291c Merge branch 'master' into contacts-view-improvements 2023-07-19 02:17:55 -04:00
8d42fe905d Merge pull request 'Fixes to search bar and list top' (#45) from projects-view-improvements into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#45
2023-07-19 02:17:44 -04:00
592ffacebc possible image uploader 2023-07-19 02:16:28 -04:00
b706e65598 Remove completed tasks. 2023-07-19 02:11:18 -04:00
Matthew Raymer
6e3066ae92 Stub update of project task list 2023-07-18 21:04:48 +08:00
a7e98c8f1a Merge branch 'master' into projects-view-improvements 2023-07-18 06:56:01 -04:00
f07c804b24 Merge branch 'master' into contacts-view-improvements 2023-07-18 06:55:02 -04:00
Matthew Raymer
e8eae544f3 Merge remote-tracking branch 'origin/no-accounts-in-memory' 2023-07-18 18:50:55 +08:00
Jose Olarte III
7c77578f79 Fixes to search bar and list top 2023-07-18 17:33:49 +08:00
Jose Olarte III
34636d6047 Fixed alert message visibility 2023-07-18 17:14:03 +08:00
Jose Olarte III
5134e2f562 UI improvements
- Consistent button styling for interactive elements
- Tooltips relegated to title attribute to avoid blocking other buttons
2023-07-18 16:40:16 +08:00
91b46eaaee Update 'project.task.yaml' 2023-07-18 02:15:04 -04:00
31d1a449ae Update 'project.task.yaml' 2023-07-18 02:12:40 -04:00
1248132076 Update 'project.task.yaml' 2023-07-18 02:12:29 -04:00
015704c94e Merge pull request 'home-gifting-improvements' (#43) from home-gifting-improvements into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#43
2023-07-18 01:35:40 -04:00
540ef916c2 Merge pull request 'Remove form tags' (#44) from form-tag-cleanup into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#44
2023-07-18 01:35:22 -04:00
bee7c87a8f Merge branch 'master' into no-accounts-in-memory 2023-07-17 16:16:44 -04:00
6bbc88f86c avoid console errors when no identity exists, and add to tasks 2023-07-14 20:54:26 -06:00
624abbb179 correct an error with unseen internal methods 2023-07-14 20:53:41 -06:00
110ed009b2 remove unused reference, call out another verbiage fix to do 2023-07-14 20:30:40 -06:00
a5892238d5 on tasks: prioritize notifications, add fixes 2023-07-14 20:25:15 -06:00
8eb80a9ede remove unnecessary async (that also happens to break display) 2023-07-14 20:11:04 -06:00
Jose Olarte III
32125133f0 No-contacts state 2023-07-14 21:33:56 +08:00
Matthew Raymer
47ade49e31 Cleanup after some testing 2023-07-14 20:29:38 +08:00
Jose Olarte III
47ce91cca1 Implemented design of Contact Gifting List view 2023-07-14 19:42:13 +08:00
Jose Olarte III
3e52b504b0 Polished gifted dialog UI 2023-07-14 18:27:43 +08:00
Matthew Raymer
4ecea1ab0e Add a special page for seeing all contacts to gift 2023-07-14 18:12:39 +08:00
Matthew Raymer
b9fdc920ea Remove the old identity management. Update project tasks 2023-07-13 18:13:41 +08:00
Matthew Aaron Raymer
0907d59a6a Remove form tags 2023-07-13 16:06:09 +08:00
59ce15c744 Merge pull request 'Adding Identity Management stubs' (#42) from identity-switcher into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#42
2023-07-12 22:49:58 -04:00
Jose Olarte III
9960a96a20 Design tweaks to Latest Activity 2023-07-12 23:17:28 +08:00
Jose Olarte III
098c6c0fa0 Compacted Quick Action section 2023-07-12 22:52:58 +08:00
Jose Olarte III
ead37ede74 Added back button 2023-07-12 19:41:05 +08:00
Matthew Raymer
f428199228 Redirect to account tab after switching identity 2023-07-12 19:12:31 +08:00
Matthew Raymer
1405b88323 Functional Identity Management 2023-07-12 18:47:21 +08:00
Jose Olarte III
44fc2850dd UL-based identity list + markup fixes 2023-07-12 16:48:30 +08:00
Matthew Raymer
52d411470e Send back to Jose for some list magic 2023-07-12 16:11:35 +08:00
Jose Olarte III
ab678a900a Added static HTML to Account Switcher view 2023-07-12 15:45:10 +08:00
Jose Olarte III
efa59e170f Tweaked router link styles 2023-07-12 15:27:59 +08:00
Matthew Raymer
7a4ceaa455 Adding Identity Management stubs 2023-07-12 15:16:07 +08:00
d7a9fb6d54 Merge branch 'master' into no-accounts-in-memory 2023-07-11 22:57:20 -04:00
78b98bab5e Merge pull request 'fix local & remote search to return the same results, fix beforeId check, fix handleId reference' (#41) from similarify-search into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#41
2023-07-11 22:36:56 -04:00
2493f2ad39 fix local & remote search to work, fix beforeId check, fix handleId setting 2023-07-11 21:16:57 -06:00
cf2b80b1f5 Merge branch 'master' into no-accounts-in-memory 2023-07-11 21:28:51 -04:00
00954693b5 Merge pull request 'Integrating InfiniteScroll to the Discovery view' (#39) from discover-infinite-scroll into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#39
2023-07-11 21:19:49 -04:00
2dd77f898f Merge branch 'master' into discover-infinite-scroll 2023-07-11 21:19:20 -04:00
c1f218c2f3 Update 'project.task.yaml' 2023-07-11 21:10:33 -04:00
b5e78e5dc8 Update 'project.task.yaml' 2023-07-11 21:03:39 -04:00
b86323ec83 adjust didInfo so we can use DIDs and not identities, removing last of identities in memory 2023-07-11 15:30:51 -06:00
8add6448fb remove code that keeps the private key (account) data in memory 2023-07-11 09:10:25 -06:00
47442655cb update tasks 2023-07-11 08:40:44 -06:00
Matthew Raymer
1d362c314b Beginning of integration. Seems the data coming back from local and remote are different? 2023-07-11 18:31:58 +08:00
3eda246e85 Merge pull request 'DiscoverView searches almost done.' (#38) from discover-view-etc into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#38
2023-07-11 01:36:19 -04:00
Matthew Raymer
3f13d3ea33 CLeanup header creation 2023-07-10 19:03:20 +08:00
Matthew Raymer
cef346e487 Move almost all interfaces to endorserServer.ts 2023-07-10 18:45:50 +08:00
Matthew Raymer
fed23a61ee Remove HelloWorld and do sweeping 2023-07-10 18:20:13 +08:00
Matthew Raymer
b6b7c56157 DiscoverView searches almost done.
Contact fixes
ContactAmounts fix quick-nav
Cleaning up ProjectView still more to do
Hide advanced by default on StatisticsView
project.task updated
2023-07-10 18:03:51 +08:00
3f8be3b4de Merge pull request 'Show the gives to & from a project (plan)' (#37) from project-gives into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#37
2023-07-10 00:39:01 -04:00
21af37c2c2 Merge pull request 'move QR for contact up, right under header' (#36) from move-qr-up into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#36
2023-07-10 00:38:51 -04:00
ed7d37c649 move QR for contact up, right under header 2023-07-09 08:15:51 -06:00
58 changed files with 22627 additions and 8298 deletions

6
.gitignore vendored
View File

@@ -1,13 +1,17 @@
.DS_Store
node_modules
/dist
signature.bin
*.pem
verified.txt
myenv
*~
# local env files
.env.local
.env.*.local
# Log files
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
npm-debug.log*
yarn-debug.log*
yarn-error.log*

23
CHANGELOG.md Normal file
View File

@@ -0,0 +1,23 @@
# 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.1.3] - 2023.11
### 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.

175
README.md
View File

@@ -1,6 +1,9 @@
# kickstart-for-time-pwa
# TimeSafari.app - Crowd-Funder for Time - PWA
## Project setup
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
```
npm install
```
@@ -11,6 +14,9 @@ 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
```
@@ -20,30 +26,18 @@ npm run build
npm run lint
```
### Test key contents
See [this page](openssl_signing_console.rst)
## Tests
### 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
playing one of two ways:
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing 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`
- Import the keys for the test User `did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` 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`
(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:
@@ -54,14 +48,35 @@ playing one of two ways:
### 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
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 'vapid' URL in App.vue, and install apps on the same domain.
### Manual walk-through
- Clear the browser cache for localhost for a new user.
- See that it's using the test API.
- On each page, verify the messaging.
- On the home page, see the feed without names, and see a message prompting to generate an ID.
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
- As User #0 in another browser on the test API, add a give & a project. (See User #0 details above.)
- 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.
- Generate an ID.
- On the home page, check that it now prompts them to get registered.
- On the account page, check that they see messages on limits.
- Register the ID from User #0.
- As the new user on the home page, check that they can now record a gift.
- On the contacts page, check that they cannot register someone else yet.
- Walk through the functions on each page.
### Customize Vue configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## Scenarios
@@ -78,128 +93,22 @@ See [Configuration Reference](https://cli.vuejs.org/config/).
### Clear data & restart
Clear cache for localhost, then go to http://localhost:8080/start
(because it'll generate a new one automatically if you start on the `/account` page).
Clear the browser cache for localhost.
## Dependencies
See https://tea.xyz
| Project | Version |
| ---------- | --------- |
| nodejs.org | ^16.0.0 |
| npmjs.com | ^8.0.0 |
## Other
### Reference Material
```
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
// Import an existing ID
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
* [Customize Vue configuration](https://cli.vuejs.org/config/).
// just to get rid of variability that might cause an error
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)
**/
/**
// 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
### Kudos
Gifts make the world go 'round!

View File

@@ -1,4 +1,11 @@
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:
JWT Creation & Verification
To run this in a script, see ./openssl_signing_console.sh
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:
@@ -11,20 +18,22 @@ openssl ec -in private.pem -pubout -out public.pem
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"}'
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')
payload_b64=$(echo -n "$payload" | 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' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
Concatenate the encoded header, payload, and a secret to create the signing input:
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)
@@ -39,7 +48,7 @@ Authorization: Bearer $jwt
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"
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.
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 give an error response and output "Verification failure".

39
openssl_signing_console.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/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 ec -in private.pem -pubout -out public.pem
# Use test data
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
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' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
signing_input="$header_b64.$payload_b64"
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem | openssl base64 -e)
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
# Read binary signature and encode it to Base64 URL-Safe format
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
# Construct the JWT
jwt="$signing_input.$signature_b64"
echo Resulting JWT: $jwt

12461
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kickstart-for-time-pwa",
"version": "0.1.0",
"name": "crowd-funder-for-time-pwa",
"version": "0.1.4",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -9,56 +9,60 @@
},
"dependencies": {
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@tweenjs/tween.js": "^21.0.0",
"@veramo/core": "^5.2.0",
"@veramo/credential-w3c": "^5.2.0",
"@veramo/data-store": "^5.2.0",
"@veramo/did-manager": "^5.1.2",
"@veramo/did-provider-ethr": "^5.1.2",
"@veramo/did-resolver": "^5.2.0",
"@veramo/key-manager": "^5.1.2",
"@vueuse/core": "^10.2.1",
"@veramo/core": "^5.4.1",
"@veramo/credential-w3c": "^5.4.1",
"@veramo/data-store": "^5.4.1",
"@veramo/did-manager": "^5.4.1",
"@veramo/did-provider-ethr": "^5.4.1",
"@veramo/did-resolver": "^5.4.1",
"@veramo/key-manager": "^5.4.1",
"@vueuse/core": "^10.4.1",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.4.0",
"axios": "^1.5.0",
"buffer": "^6.0.3",
"class-transformer": "^0.5.1",
"core-js": "^3.31.1",
"core-js": "^3.32.1",
"dexie": "^3.2.4",
"dexie-export-import": "^4.0.7",
"did-jwt": "^7.2.4",
"ethereum-cryptography": "^2.0.0",
"did-jwt": "^7.2.7",
"ethereum-cryptography": "^2.1.2",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.0.0",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"localstorage-slim": "^2.4.0",
"luxon": "^3.3.0",
"localstorage-slim": "^2.5.0",
"luxon": "^3.4.3",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"qr-code-generator-vue3": "^1.4.21",
"ramda": "^0.29.0",
"readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"three": "^0.154.0",
"three": "^0.156.1",
"vue": "^3.3.4",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^2.1.20",
"vue-property-decorator": "^9.1.2",
"vue-router": "^4.2.3",
"vue-facing-decorator": "^3.0.2",
"vue-qrcode-reader": "^5.4.1",
"vue-router": "^4.2.4",
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/ramda": "^0.29.3",
"@types/three": "^0.152.1",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@types/three": "^0.155.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-pwa": "~5.0.8",
@@ -67,14 +71,15 @@
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0-alpha.1",
"eslint-plugin-vue": "^9.15.1",
"postcss": "^8.4.24",
"prettier": "^3.0.0",
"tailwindcss": "^3.3.2",
"typescript": "~5.1.6"
"autoprefixer": "^10.4.15",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.29",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.3",
"typescript": "~5.2.2"
}
}

View File

@@ -1,73 +1,73 @@
tasks:
- .5 audit all console.log calls
- 01 design ideas for simple gives on the Home page
- 01 add list of 'give' records for a project on ProjectView UI
- 02 Discover page - display results (currently in console.log) & link, spin when searching
- 08 search by location, endpoint, etc assignee:trent
- 01 add a location for a project via map pin (see API by clicking on "Nearby")
- 01 remove all the "form" fields (or at least investigate to see if that page refresh is desired)
- .2 change "errorMessage" to "alertMessage" on ProjectViewView.vue
- 08 Scan QR code to import into contacts.
- contacts v1 :
- 01 Import contact info a la QR code.
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 move all "identity" references to temporary account access assignee:trent
- contacts v+ :
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- refactor UI :
- .5 Alerts show at the top and can be missed, eg. account data download
- .5 Fix how icons show on top of bottom bar on ContactAmounts page
- .2 Hide "Advanced" section in Account page by default
- show pop-up confirming that settings & contacts have been downloaded
- Ensure each action sent to the server has a confirmation - registration
- Home Feed & Quick Give screen :
- 01 save the feed-viewed status in settings storage ("afterQuery")
- 01 quick action - send action, maybe choose via canvas tool https://github.com/konvajs/vue-konva
- .5 customize favicon
- 04 allow user to download claims, mine + ones I can see about me from others
- 24 Move to Vite
- don't show "Give" & "Offer" on project screen if they don't have an identifier
- allow some gives even if they aren't registered
- in endorser-push-server - mount folder for persistent sqlite DB outside of container
- 40 notifications :
- push
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
- .5 allow to manage their notifications even without an identity
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s)
- .3 fix the Project-location-selection map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
- .5 Add infinite scroll to gifts on the home page
- .5 bug - search for "Safari" does not find the project, but if already on the "Anywhere" tab it shows all
- .2 figure out why endorser-mobile search doesn't find recently created PlanAction
- .1 when creating a plan, select location and then make sure you can deselect on Android
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164 assignee:trent
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- fix cert generation (since it didn't happen automatically for Nov 30)
- Discuss whether the remaining tasks are worthwhile before MVP release.
- 01 fix images on project page, on discovery page
- .2 fix "Rotary" and static icon to the right on project page
- .1 Make give description text box into something that expands as they type?
- 04 allow user to download claims, mine + ones I can see about me from others
- .5 customize favicon assignee-group:ui
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker
- .5 make a VC details page, or link to endorser.ch
- .1 Add units or different icon to the coins (to distinguish $, BTC, hours, etc)
- .5 include the hash of the latest commit on help page next to version (maybe Trent's git-hash branch)
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- allow download of each VC (to show that they can actually own their data)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- Do we want split first name & last name?
- Release Minimum Viable Product :
- .5 deploy endorser.ch server above Dec 1 (to get plan searches by names as well as descriptions)
- 08 thorough testing for errors & edge cases
- 01 ensure ability to recover server remotely, and add redundant access
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Add disclaimers.
- Rename DB to TimeSafari.
- Switch default server to the public server.
- Deploy to a server.
- Ensure public server has limits that work for group adoption.
- Test PWA features on Android and iOS.
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- 40 notifications v+ :
- pull, w/ scheduled runs
- .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
- 24 Move to Vite
- 32 accept images for projects
- 32 accept images for contacts
- linking between projects or plans :
- show total time given to & from a project
@@ -75,6 +75,10 @@ tasks:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 add "back" button to all screens that aren't part of the bottom tray
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
@@ -91,12 +95,18 @@ tasks:
- 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
- Do we want split first name & last name?
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
- 40 notifications v+ :
- pull, w/ scheduled runs
- 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.
log:
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29

View File

@@ -1,7 +1,505 @@
<template>
<router-view />
<!-- https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert">
<div
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
>
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
enter-from="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4"
enter-to="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
move="transition duration-500"
move-delay="delay-300"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<div
v-if="notification.type === 'toast'"
class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md"
>
<div class="w-full px-4 py-3">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
</div>
</div>
<div
v-if="notification.type === 'info'"
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-100 rounded-lg shadow-md"
>
<div
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
>
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
>
<fa icon="xmark" class="fa-fw"></fa>
</button>
</div>
</div>
<div
v-if="notification.type === 'success'"
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-emerald-100 rounded-lg shadow-md"
>
<div
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
>
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
>
<fa icon="xmark" class="fa-fw"></fa>
</button>
</div>
</div>
<div
v-if="notification.type === 'warning'"
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-amber-100 rounded-lg shadow-md"
>
<div
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
>
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
>
<fa icon="xmark" class="fa-fw"></fa>
</button>
</div>
</div>
<div
v-if="notification.type === 'danger'"
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-rose-100 rounded-lg shadow-md"
>
<div
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
>
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
>
<fa icon="xmark" class="fa-fw"></fa>
</button>
</div>
</div>
</div>
</Notification>
</div>
</NotificationGroup>
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
leave="transition ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
move="transition duration-500"
move-delay="delay-300"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<div
v-if="notification.type === 'notification-permission'"
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 on</b> notifications for this app?
</p>
<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"
@click="
close(notification.id);
turnOnNotifications();
"
>
Turn on Notifications
</button>
<div class="grid grid-cols-2 gap-2">
<button
@click="maybeLater(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
@click="never(notification.id)"
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
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
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 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
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>
</div>
</div>
</div>
</div>
</Notification>
</div>
</NotificationGroup>
</template>
<style></style>
<script lang="ts"></script>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import axios, { AxiosError } 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;
};
}
@Component
export default class App extends Vue {
b64 = "";
mounted() {
axios
.get("https://timesafari-pwa.anomalistlabs.com/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data.vapidKey;
console.log(this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
});
})
.catch((error: AxiosError) => {
console.error("API error", error);
});
}
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> {
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("We need notification permission to provide certain features.");
throw new Error("We weren't granted permission.");
}
return permission;
});
}
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.");
// Assuming the subscription object is available
return navigator.serviceWorker.ready;
})
.then((registration) => {
// Fetch the existing subscription object from the registration
return registration.pushManager.getSubscription();
})
.then((subscription) => {
if (subscription) {
console.log(subscription);
return this.sendSubscriptionToServer(subscription);
} else {
throw new Error("Subscription object is not available.");
}
})
.then(() => {
console.log("Subscription data sent to server.");
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
);
});
})
.catch((error) => {
console.error("An error occurred:", error);
// Handle error appropriately here
});
}
// Function to convert URL base64 to Uint8Array
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(
"Subscription or server communication 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(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.");
});
}
never(ID: string) {
alert(ID);
}
maybeLater(ID: string) {
alert(ID);
}
}
</script>

View File

@@ -1,47 +0,0 @@
<template>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 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-slate-100": 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>

View File

@@ -0,0 +1,32 @@
<template>
<div v-html="generateIdenticon()" class="w-fit"></div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { toSvg } from "jdenticon";
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 EntityIcon 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>

View File

@@ -1,64 +1,127 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-lg text-center">
{{ message }} {{ giver?.name || "somebody not specified" }}
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giver?.name || "somebody not named" }}
</h1>
<input
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"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row">
<span class="py-4">Hours</span>
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
>Hours</span
>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="text"
class="block w-8 rounded border border-slate-400 ml-4 text-center"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="hours"
/>
<div class="flex flex-col px-1">
<div>
<fa icon="square-caret-up" size="2xl" @click="increment()" />
</div>
<div>
<fa icon="square-caret-down" size="2xl" @click="decrement()" />
</div>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<p class="text-right">Sign & Send to publish to the world</p>
<div class="text-right">
<button class="rounded border border-slate-400" @click="confirm">
<span class="m-2">Sign & Send</span>
</button>
&nbsp;
<button class="rounded border border-slate-400" @click="cancel">
<span class="m-2">Cancel</span>
</button>
<div v-if="showGivenToUser" class="mt-2 text-right">
<input type="checkbox" class="mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</div>
<p class="text-center mb-2 mt-6 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 &amp; 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, Emit } from "vue-facing-decorator";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { createAndSubmitGive, GiverInputInfo } from "@/libs/endorserServer";
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 GiftedDialog extends Vue {
@Prop message = "";
$notify!: (notification: Notification, timeout?: number) => void;
giver = null;
@Prop message = "";
@Prop projectId = "";
@Prop showGivenToUser = false;
activeDid = "";
apiServer = "";
giver?: GiverInputInfo; // undefined means no identified giver agent
description = "";
givenToUser = false;
hours = "0";
visible = false;
open(giver) {
// giver: GiverInputInfo
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 || "";
// 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 the latest sweet, sweet action.",
},
-1,
);
}
}
open(giver: GiverInputInfo) {
this.description = "";
this.giver = giver;
// if we show "given to user" selection, default checkbox to true
this.givenToUser = this.showGivenToUser;
this.hours = "0";
this.visible = true;
}
close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false;
}
@@ -70,27 +133,172 @@ export default class GiftedDialog extends Vue {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
}
@Emit("dialog-result")
confirm() {
const result = {
action: "confirm",
giver: this.giver,
hours: parseFloat(this.hours),
description: this.description,
};
cancel() {
this.close();
this.description = "";
this.giver = null;
this.hours = "0";
return result;
this.eraseValues();
}
@Emit("dialog-result")
cancel() {
const result = { action: "cancel" };
eraseValues() {
this.description = "";
this.giver = undefined;
this.givenToUser = this.showGivenToUser;
this.hours = "0";
}
async confirm() {
this.close();
return result;
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 | undefined,
this.description,
parseFloat(this.hours),
).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 identity was found",
);
}
return identity;
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
public async recordGive(
giverDid?: string,
description?: string,
hours?: number,
) {
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.givenToUser ? this.activeDid : undefined,
description,
hours,
this.projectId,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.log("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 gift was recorded.",
},
7000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("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>
@@ -106,12 +314,14 @@ export default class GiftedDialog extends Vue {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 50%;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -1,146 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br />
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
>vue-cli documentation</a
>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
target="_blank"
rel="noopener"
>babel</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa"
target="_blank"
rel="noopener"
>pwa</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
target="_blank"
rel="noopener"
>router</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
target="_blank"
rel="noopener"
>vuex</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
target="_blank"
rel="noopener"
>eslint</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
target="_blank"
rel="noopener"
>typescript</a
>
</li>
</ul>
<h3>Essential Links</h3>
<ul>
<li>
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
</li>
<li>
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
>Forum</a
>
</li>
<li>
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
>Community Chat</a
>
</li>
<li>
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
>Twitter</a
>
</li>
<li>
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
</li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li>
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
>vue-router</a
>
</li>
<li>
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
</li>
<li>
<a
href="https://github.com/vuejs/vue-devtools#vue-devtools"
target="_blank"
rel="noopener"
>vue-devtools</a
>
</li>
<li>
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
>vue-loader</a
>
</li>
<li>
<a
href="https://github.com/vuejs/awesome-vue"
target="_blank"
rel="noopener"
>awesome-vue</a
>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
@Component
export default class HelloWorld extends Vue {
@Prop msg!: string;
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@@ -0,0 +1,317 @@
<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 mb-6">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Hours
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
>
<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="hours"
/>
<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 mb-6">
<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 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 &amp; 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 { 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 = "";
description = "";
expirationDateInput = "";
hours = "0";
visible = false;
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 || "";
// 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 the latest sweet, sweet action.",
},
-1,
);
}
}
open() {
this.visible = true;
}
close() {
this.visible = false;
}
increment() {
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
}
decrement() {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
}
cancel() {
this.close();
this.description = "";
this.hours = "0";
}
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.hours),
this.expirationDateInput,
).then(() => {
this.description = "";
this.hours = "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 identity was found",
);
}
return identity;
}
/**
*
* @param description may be an empty string
* @param hours may be 0
*/
public async recordOffer(
description?: string,
hours?: number,
expirationDateInput?: string,
) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record an offer.",
},
-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 createAndSubmitOffer(
this.axios,
this.apiServer,
identity,
description,
hours,
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>

View File

@@ -1,12 +1,24 @@
/**
* Generic strings that could be used throughout the app.
*
* See also ../libs/veramo/setup.ts
*/
export enum AppString {
APP_NAME = "Kick-Start with Time",
APP_NAME = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://endorser.ch:3000",
TEST_ENDORSER_API_SERVER = "https://test.endorser.ch:8000",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
}
/**
* See notiwind package
*/
export interface NotificationIface {
group: string;
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
}

View File

@@ -9,60 +9,39 @@ import {
} from "./tables/settings";
import { AppString } 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 = {
contacts: Table<Contact>;
settings: Table<Settings>;
};
/**
* 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
*/
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export const accountsDB = new BaseDexie("KickStartAccounts") as SensitiveDexie;
const SensitiveSchemas = Object.assign({}, AccountsSchema);
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
export const db = new BaseDexie("KickStart") as NonsensitiveDexie;
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
/**
* 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
*/
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
/**
* 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.
*/
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = { ...ContactsSchema, ...SettingsSchema };
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
if (localStorage.getItem("secret") == null) {
localStorage.setItem("secret", secret);
}
//console.log("IndexedDB Encryption Secret:", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(SensitiveSchemas);
// Define the schema for our databases
accountsDB.version(1).stores(SensitiveSchemas);
db.version(1).stores(NonsensitiveSchemas);
// initialize, a la https://dexie.org/docs/Tutorial/Design#the-populate-event
db.on("populate", function () {
// ensure there's an initial entry for settings
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY,
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,

View File

@@ -1,17 +1,50 @@
/**
* Represents an account stored in the database.
*/
export type Account = {
id?: number; // auto-generated by Dexie
/**
* Auto-generated ID by Dexie.
*/
id?: number;
/**
* The date the account was created.
*/
dateCreated: string;
/**
* The derivation path for the account.
*/
derivationPath: string;
/**
* Decentralized Identifier (DID) for the account.
*/
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;
/**
* The public key in hexadecimal format.
*/
publicKeyHex: string;
/**
* The mnemonic passphrase for the account.
*/
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 = {
accounts:
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",

View File

@@ -7,5 +7,5 @@ export interface Contact {
}
export const ContactsSchema = {
contacts: "++did, name, publicKeyBase64, registered, seesMe",
contacts: "&did, name, publicKeyBase64, registered, seesMe",
};

View File

@@ -1,17 +1,47 @@
// a singleton
export type Settings = {
id: number; // there's only one entry: MASTER_SETTINGS_KEY
activeDid?: string;
apiServer?: string;
firstName?: string;
lastName?: string;
lastViewedClaimId?: string;
showContactGivesInline?: boolean;
/**
* BoundingBox type describes the geographical bounding box coordinates.
*/
export type BoundingBox = {
eastLong: number; // Eastern longitude
maxLat: number; // Maximum (Northernmost) latitude
minLat: number; // Minimum (Southernmost) latitude
westLong: number; // Western longitude
};
/**
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
id: number; // Only one entry using MASTER_SETTINGS_KEY
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
firstName?: string; // User's first name
lastName?: string; // User's last name
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string; // Last notified claim ID
isRegistered?: boolean;
// 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
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
};
/**
* Schema for the Settings table in the database.
*/
export const SettingsSchema = {
settings: "id",
};
/**
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1;

View File

@@ -1,5 +1,4 @@
import { IIdentifier } from "@veramo/core";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
@@ -7,6 +6,11 @@ import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
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'";
/**
*
*
@@ -47,17 +51,17 @@ export const newIdentifier = (
*/
export const deriveAddress = (
mnemonic: string,
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
): [string, string, string, string] => {
const UPORT_ROOT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
mnemonic = mnemonic.trim().toLowerCase();
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic);
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH);
const rootNode: HDNode = hdnode.derivePath(derivationPath);
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x'
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x'
const address = rootNode.address;
return [address, privateHex, publicHex, UPORT_ROOT_DERIVATION_PATH];
return [address, privateHex, publicHex, derivationPath];
};
/**
@@ -148,3 +152,24 @@ export function fromJose(signature: string): {
export function bytesToHex(b: Uint8Array): string {
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;
};

View File

@@ -6,7 +6,12 @@ import { Axios, AxiosResponse } from "axios";
import { Contact } from "@/db/tables/contacts";
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
// 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=";
export interface AgreeVerifiableCredential {
"@context": string;
@@ -21,6 +26,13 @@ export interface GiverInputInfo {
name?: string;
}
export interface GiverOutputInfo {
action: string;
giver?: GiverInputInfo;
description?: string;
hours?: number;
}
export interface ClaimResult {
success: { claimId: string; handleId: string };
error: { code: string; message: string };
@@ -47,15 +59,61 @@ export interface GiveServerRecord {
unit: string;
}
export interface OfferServerRecord {
amount: number;
amountGiven: number;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
unit: string;
validThrough: string;
}
export interface GiveVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier: string };
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
identifier?: string;
object?: { amountOfThisGood: number; unitCode: string };
recipient: { identifier: string };
recipient?: { identifier: string };
}
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;
}
export interface PlanVerifiableCredential {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
description: string;
identifier?: string;
location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
}
export interface PlanServerRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well
description: string;
endTime?: string;
issuerDid: string;
handleId: string;
locLat?: number;
locLon?: number;
startTime?: string;
url?: string;
}
export interface RegisterVerifiableCredential {
@@ -63,7 +121,7 @@ export interface RegisterVerifiableCredential {
"@type": string;
agent: { identifier: string };
object: string;
recipient: { identifier: string };
participant: { identifier: string };
}
export interface InternalError {
@@ -75,36 +133,52 @@ export interface InternalError {
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";
export function isHiddenDid(did) {
export function isHiddenDid(did: string) {
return did === HIDDEN_DID;
}
/**
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
Similar logic is found in endorser-mobile.
**/
export function didInfo(did, activeDid, identifiers, contacts) {
const myId: IIdentifier | undefined = R.find(
(i) => i.did === did,
identifiers,
);
if (myId) {
return "You" + (myId.did !== activeDid ? " (Alt ID)" : "");
} else {
const contact: Contact | undefined = R.find((c) => c.did === did, contacts);
if (contact) {
return contact.name || "Someone Unnamed in Contacts";
} else if (!did) {
return "Unspecified Person";
} else if (isHiddenDid(did)) {
return "Someone Not In Network";
} else {
return "Someone Not In Contacts";
}
}
export function didInfo(
did: string,
activeDid: string,
allMyDids: string[],
contacts: Contact[],
): string {
if (!did) return "Someone Anonymous";
const myId = R.find(R.equals(did), allMyDids);
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
const contact = R.find((c) => c.did === did, contacts);
return contact
? contact.name || "Contact With No Name"
: isHiddenDid(did)
? "Someone Not In Network"
: "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://endorser.ch:3000/api-docs/#/claims/post_api_v2_claim
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param fromDid may be null
@@ -116,76 +190,140 @@ export async function createAndSubmitGive(
axios: Axios,
apiServer: string,
identity: IIdentifier,
fromDid: string,
toDid: string,
description: string,
hours: number,
fromDid?: string,
toDid?: string,
description?: string,
hours?: number,
fulfillsProjectHandleId?: string,
): Promise<AxiosResponse<ClaimResult> | InternalError> {
// Make a claim
): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org",
"@type": "GiveAction",
recipient: { identifier: toDid },
recipient: toDid ? { identifier: toDid } : undefined,
agent: fromDid ? { identifier: fromDid } : undefined,
description: description || undefined,
object: hours ? { amountOfThisGood: hours, unitCode: "HUR" } : undefined,
fulfills: fulfillsProjectHandleId
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
: undefined,
};
if (fromDid) {
vcClaim.agent = { identifier: fromDid };
return createAndSubmitClaim(
vcClaim as GenericClaim,
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 hours
* @param hours 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,
hours?: number,
expirationDate?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = {
"@context": "https://schema.org",
"@type": "Offer",
offeredBy: { identifier: identity.did },
validThrough: expirationDate || undefined,
};
if (hours) {
vcClaim.includesObject = {
amountOfThisGood: hours,
unitCode: "HUR",
};
}
if (description) {
vcClaim.description = description;
}
if (hours) {
vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" };
vcClaim.itemOffered = { description };
}
if (fulfillsProjectHandleId) {
vcClaim.fulfills = {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
if (identity.keys[0].privateKeyHex == null) {
return new Promise<InternalError>((resolve, reject) => {
reject({
return createAndSubmitClaim(
vcClaim as GenericClaim,
identity,
apiServer,
axios,
);
}
export async function createAndSubmitClaim(
vcClaim: GenericClaim,
identity: IIdentifier,
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",
message:
"Your identifier " +
identity.did +
" is not configured correctly. Use a different identifier.",
});
message: `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.log("Error creating claim:", error);
const errorMessage: string =
error.response?.data?.error?.message || error.message || "Unknown error";
return {
type: "error",
error: {
error: errorMessage,
userMessage: "Failed to create and submit the claim.",
},
};
}
// 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
@@ -197,3 +335,53 @@ export function isNumeric(str: string): boolean {
export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0;
}
export interface ErrorResponse {
error?: {
message?: string;
};
}
export interface RateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
/**
* Represents data about a project
**/
export interface ProjectData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
/**
* The Identier of the project
**/
rowid: string;
}
export interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
}
export interface WorldProperties {
startTime?: string;
endTime?: string;
}

View File

@@ -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) {
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
}
//const NETWORK_NAMES = ["mainnet", "rinkeby"];
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 })
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");

View File

@@ -5,16 +5,22 @@ import "./registerServiceWorker";
import router from "./router";
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowLeft,
faArrowRight,
faBan,
faBurst,
faCalendar,
faChevronLeft,
faChevronRight,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
@@ -30,6 +36,7 @@ import {
faGift,
faHand,
faHouseChimney,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
@@ -44,17 +51,23 @@ import {
faSquareCaretDown,
faSquareCaretUp,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowLeft,
faArrowRight,
faBan,
faBurst,
faCalendar,
faChevronLeft,
faChevronRight,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
@@ -70,6 +83,7 @@ library.add(
faGift,
faHand,
faHouseChimney,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
@@ -84,6 +98,7 @@ library.add(
faSquareCaretDown,
faSquareCaretUp,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
@@ -96,4 +111,5 @@ createApp(App)
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.use(Notifications)
.mount("#app");

View File

@@ -3,7 +3,7 @@
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, {
register("/additional-scripts.js", {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +

View File

@@ -1,5 +1,11 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { accountsDB } from "@/db";
import {
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 next :NavigationGuardNext
*/
const enterOrStart = async (to, from, next) => {
const enterOrStart = async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
) => {
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
@@ -23,7 +33,6 @@ const routes: Array<RouteRecordRaw> = [
name: "home",
component: () =>
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/account",
@@ -48,6 +57,14 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
),
},
{
path: "/contact-gives",
name: "contact-gives",
component: () =>
import(
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
),
},
{
path: "/contact-qr",
name: "contact-qr",
@@ -61,15 +78,6 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts",
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
},
{
path: "/discover",
@@ -83,6 +91,14 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
},
{
path: "/import-account",
name: "import-account",
@@ -91,6 +107,14 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
),
},
{
path: "/import-derive",
name: "import-derive",
component: () =>
import(
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
),
},
{
path: "/new-edit-account",
name: "new-edit-account",
@@ -124,7 +148,7 @@ const routes: Array<RouteRecordRaw> = [
),
},
{
path: "/project",
path: "/project/:id?",
name: "project",
component: () =>
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
@@ -136,6 +160,22 @@ const routes: Array<RouteRecordRaw> = [
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
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",
name: "seed-backup",
@@ -158,6 +198,12 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
),
},
{
path: "/test",
name: "test",
component: () =>
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
},
];
/** @type {*} */
@@ -166,4 +212,18 @@ const router = createRouter({
routes,
});
const errorHandler = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
to: RouteLocationNormalized,
from: RouteLocationNormalized,
) => {
// Handle the error here
console.error("Caught in top level error handler:", error, to, from);
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
};
router.onError(errorHandler); // Assign the error handler to the router instance
export default router;

View File

@@ -2,7 +2,7 @@ import axios from "axios";
import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app";
import { db } from "../db";
import { SERVICE_ID } from "../libs/veramo/setup";
import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";

File diff suppressed because it is too large Load Diff

View File

@@ -15,35 +15,33 @@
</h1>
</div>
<form>
<p class="text-center text-xl mb-4 font-light">
Would you like to add <i>Firstname</i> to your network?
</p>
<p class="text-center text-xl mb-4 font-light">
Would you like to add <i>Firstname</i> to your network?
</p>
<!-- Account Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
<!-- Account Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
<div class="text-slate-500 text-sm font-bold">ID</div>
<div class="text-sm text-slate-500 mb-1">
<span><code>did:peer:kl45kj41lk451kl3</code></span>
</div>
<div class="text-slate-500 text-sm font-bold">ID</div>
<div class="text-sm text-slate-500 mb-1">
<span><code>did:peer:kl45kj41lk451kl3</code></span>
</div>
</div>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Add Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</form>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Add Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</section>
</template>

View File

@@ -1,118 +1,99 @@
<template>
<!-- QUICK NAV -->
<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">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div class="mb-8">
<h1
id="ViewBreadcrumb"
class="text-lg text-center font-light relative px-7"
>
<!-- Back -->
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
</h1>
<section id="Content" class="p-6 pb-24">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Given with {{ contact?.name }}
</h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Given with {{ contact?.name }}
</h1>
</div>
<div class="flex justify-around">
<span />
<span class="justify-around">(Only 50 most recent)</span>
<span />
</div>
<!-- Results List -->
<div>
<div class="border-b border-slate-300 flex">
<div class="w-1/4"></div>
<div class="w-1/4">from them</div>
<div class="w-1/4"></div>
<div class="w-1/4">to them</div>
</div>
<div
class="border-b border-slate-300 flex"
v-for="record in giveRecords"
:key="record.id"
>
<div class="w-1/4">
{{ new Date(record.issuedAt).toLocaleString() }}
</div>
<div class="w-1/4">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="confirm(record)">
<fa icon="circle" class="text-blue-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
<div class="w-1/8">
<span v-if="record.agentDid == contact.did">
<fa icon="long-arrow-alt-left" class="text-slate-900 fa-fw ml-1" />
</span>
<span v-else>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<fa icon="long-arrow-alt-right" class="text-slate-900 fa-fw ml-1" />
</span>
</div>
<div class="w-1/4">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="cannotConfirmMessage()">
<fa icon="circle" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
</div>
</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
<table
class="table-auto w-full border-t border-slate-300 text-sm sm:text-base text-center"
>
<thead class="bg-slate-100">
<tr class="border-b border-slate-300">
<th></th>
<th class="px-1 py-2">From Them</th>
<th></th>
<th class="px-1 py-2">To Them</th>
</tr>
</thead>
<tbody>
<tr
v-for="record in giveRecords"
:key="record.id"
class="border-b border-slate-300"
>
<td class="p-1 text-xs sm:text-sm text-left text-slate-500">
{{ new Date(record.issuedAt).toLocaleString() }}
</td>
<td class="p-1">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
<button v-else @click="confirm(record)" title="Unconfirmed">
<fa icon="circle" class="text-blue-600 fa-fw" />
</button>
</div>
<div class="italic text-xs sm:text-sm text-slate-500">
{{ record.description }}
</div>
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid == contact.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
</span>
<span v-else>
<fa icon="arrow-right" class="text-slate-400 fa-fw" />
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
<button
v-else
@click="cannotConfirmMessage()"
title="Unconfirmed"
>
<fa icon="circle" class="text-slate-600 fa-fw" />
</button>
</div>
<div class="italic text-xs sm:text-sm text-slate-500">
{{ record.description }}
</div>
</span>
</td>
</tr>
</tbody>
</table>
</section>
</template>
@@ -120,8 +101,7 @@
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { AccountsSchema } from "@/db/tables/accounts";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
@@ -133,26 +113,32 @@ import {
} from "@/libs/endorserServer";
import * as didJwt from "did-jwt";
import { AxiosError } from "axios";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav.vue";
import { IIdentifier } from "@veramo/core";
@Component({ components: { AlertMessage } })
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class ContactsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
apiServer = "";
contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = [];
alertTitle = "";
alertMessage = "";
accounts: AccountsSchema;
numAccounts = 0;
async beforeCreate() {
accountsDB.open();
this.accounts = accountsDB.accounts;
this.numAccounts = await this.accounts.count();
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
@@ -168,7 +154,7 @@ export default class ContactsView extends Vue {
return identity;
}
public async getHeaders(identity) {
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
@@ -190,29 +176,33 @@ export default class ContactsView extends Vue {
if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact);
}
} catch (err) {
this.alertTitle = "Error";
this.alertMessage =
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.",
},
-1,
);
}
}
async loadGives(activeDid: string, contact: Contact) {
try {
const identity = await this.getIdentity(this.activeDid);
let result = [];
let result: Array<GiveServerRecord> = [];
const url =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did) +
"&recipientDid=" +
encodeURIComponent(contact.did);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
result = resp.data.data;
@@ -222,9 +212,15 @@ export default class ContactsView extends Vue {
resp.status,
resp.data,
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your given time from the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
);
}
const url2 =
@@ -233,11 +229,7 @@ export default class ContactsView extends Vue {
encodeURIComponent(contact.did) +
"&recipientDid=" +
encodeURIComponent(identity.did);
const token2 = await accessToken(identity);
const headers2 = {
"Content-Type": "application/json",
Authorization: "Bearer " + token2,
};
const headers2 = await this.getHeaders(identity);
const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) {
result = R.concat(result, resp2.data.data);
@@ -247,9 +239,15 @@ export default class ContactsView extends Vue {
resp2.status,
resp2.data,
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your given time from the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
);
}
const sortedResult: Array<GiveServerRecord> = R.sort(
@@ -259,8 +257,15 @@ export default class ContactsView extends Vue {
);
this.giveRecords = sortedResult;
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: error as string,
},
-1,
);
}
}
@@ -313,7 +318,6 @@ export default class ContactsView extends Vue {
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
if (resp.data?.success) {
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
}
@@ -330,21 +334,38 @@ export default class ContactsView extends Vue {
userMessage = error as string;
}
// Now set that error for the user to see.
this.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
}
}
}
cannotConfirmMessage() {
this.alertTitle = "Not Allowed";
this.alertMessage = "Only the recipient can confirm final receipt.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Not Allowed",
text: "Only the recipient can confirm final receipt.",
},
-1,
);
}
}
</script>
<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 {
position: relative;

View File

@@ -0,0 +1,166 @@
<template>
<QuickNav selected="Home"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<router-link
:to="{ name: 'home' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
Give to Contacts
</h1>
</div>
<!-- Results List -->
<ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center">
<span class="grow italic text-slate-500"
><EntityIcon
:entityId="null"
:iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
></EntityIcon>
Anonymous
</span>
<span class="text-right">
<button
type="button"
@click="openDialog()"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
</span>
</h2>
</li>
<li
v-for="contact in allContacts"
:key="contact.did"
class="border-b border-slate-300 py-3"
>
<h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold"
><EntityIcon
:entityId="contact.did"
:iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
></EntityIcon>
{{ contact.name || "(no name)" }}
</span>
<span class="text-right">
<button
type="button"
@click="openDialog(contact)"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
</span>
</h2>
</li>
</ul>
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverInputInfo } from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { IIdentifier } from "@veramo/core";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class ContactGiftingView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
apiServer = "";
accounts: typeof AccountsSchema;
numAccounts = 0;
async beforeCreate() {
accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
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 with no identity available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
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();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.message ||
"There was an error retrieving the latest sweet, sweet action.",
},
-1,
);
}
}
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
}
</script>

View File

@@ -2,58 +2,91 @@
<QuickNav selected="Profile"></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">
Your Contact Info
</h1>
<!-- 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>
<!--
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"
/>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
</div>
<div @click="onCopyToClipboard()">
<!--
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"
/>
</div>
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
</section>
</template>
<script lang="ts">
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
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 * as R from "ramda";
import { SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
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
const Buffer = require("buffer/").Buffer;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: {
QrcodeStream,
QRCodeVue3,
AlertMessage,
QuickNav,
},
})
export default class ContactQRScanShow extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
apiServer = "";
qrValue = "";
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -64,15 +97,6 @@ export default class ContactQRScanShow extends Vue {
return identity;
}
public async getHeaders(identity) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
@@ -83,7 +107,15 @@ export default class ContactQRScanShow extends Vue {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (!account) {
this.alertMessage = "You have no identity yet.";
this.$notify(
{
group: "alert",
type: "warning",
title: "",
text: "You have no identity yet.",
},
-1,
);
} else {
const identity = await this.getIdentity(this.activeDid);
const publicKeyHex = identity.keys[0].publicKeyHex;
@@ -92,7 +124,9 @@ export default class ContactQRScanShow extends Vue {
iat: Date.now(),
iss: this.activeDid,
own: {
name: (settings?.firstName || "") + " " + (settings?.lastName || ""),
name:
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
},
};
@@ -106,13 +140,64 @@ export default class ContactQRScanShow extends Vue {
issuer: identity.did,
signer: signer,
});
const viewPrefix = "https://endorser.ch/contact?jwt=";
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt;
}
}
// This same popup code is in many files.
alertTitle = "";
alertMessage = "";
/**
*
* @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>

View File

@@ -14,75 +14,69 @@
</h1>
</div>
<form>
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code</h3>
<div class="bg-black rounded overflow-hidden relative mb-4">
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code</h3>
<div class="bg-black rounded overflow-hidden relative mb-4">
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
<!-- Darken overlay -->
<!-- Top -->
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Reft -->
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
<!-- Right -->
<div
class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"
></div>
<!-- Bottom -->
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Darken overlay -->
<!-- Top -->
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Reft -->
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
<!-- Right -->
<div class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"></div>
<!-- Bottom -->
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Reticle overlay -->
<!-- Top-left -->
<div
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
></div>
<!-- Top-right -->
<div
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
></div>
<!-- Bottom-left -->
<div
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
></div>
<!-- Bottom-right -->
<div
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
></div>
</div>
<!-- Reticle overlay -->
<!-- Top-left -->
<div
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
></div>
<!-- Top-right -->
<div
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
></div>
<!-- Bottom-left -->
<div
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
></div>
<!-- Bottom-right -->
<div
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
></div>
</div>
<h3 class="text-sm uppercase font-semibold mb-2">
or Enter Contact Data
</h3>
<h3 class="text-sm uppercase font-semibold mb-2">or Enter Contact Data</h3>
<input
type="text"
placeholder="Name (optional)"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
/>
<input
type="text"
placeholder="ID"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
/>
<input
type="text"
placeholder="Public Key (optional)"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="mt-8">
<input
type="text"
placeholder="Name (optional)"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Look Up Contact"
/>
<input
type="text"
placeholder="ID"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
/>
<input
type="text"
placeholder="Public Key (optional)"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Look Up Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</form>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</section>
</template>

View File

@@ -20,6 +20,11 @@
<!-- New Contact -->
<div class="mb-4 flex">
<span class="self-center bg-slate-500 text-white px-1.5 py-1 rounded-md">
<router-link :to="{ name: 'contact-qr' }">
<fa icon="qrcode" class="fa-fw" />
</router-link>
</span>
<input
type="text"
placeholder="DID, Name, Public Key"
@@ -66,89 +71,125 @@
: "Unconfirmed"
}}
</button>
<br />
(Only hours shown)
<br />
(Only recent shown)
</div>
</div>
<!-- Results List -->
<ul v-if="contacts.length > 0">
<ul v-if="contacts.length > 0" class="border-t border-slate-300">
<li
class="border-b border-slate-300"
class="border-b border-slate-300 pt-2.5 pb-4"
v-for="contact in contacts"
:key="contact.did"
>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
<EntityIcon
:entityId="contact.did"
:iconSize="24"
class="inline-block align-text-bottom border border-slate-300 rounded"
></EntityIcon>
{{ contact.name || "(no name)" }}
<button
class="text-sm uppercase bg-slate-500 text-white px-1 rounded-md"
@click="
contactEdit = contact;
contactNewName = contact.name;
"
title="Edit"
>
<fa icon="pen" class="fa-fw" />
</button>
</h2>
<div class="text-sm truncate">{{ contact.did }}</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }}
</div>
<button
v-if="contact.seesMe"
class="tooltip"
@click="setVisibility(contact, false)"
>
<fa icon="eye" class="text-slate-900 fa-fw ml-1" />
<span class="tooltiptext">They can see you</span>
</button>
<button v-else class="tooltip" @click="setVisibility(contact, true)">
<span class="tooltiptext">They cannot see you</span>
<fa icon="eye-slash" class="text-slate-900 fa-fw ml-1" />
</button>
<div id="ContactActions" class="flex gap-1.5 mt-2">
<button
v-if="contact.seesMe"
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
@click="setVisibility(contact, false, true)"
title="They can see you"
>
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
@click="setVisibility(contact, true, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<button class="tooltip" @click="checkVisibility(contact)">
<span class="tooltiptext">Check Visibility</span>
<fa icon="rotate" class="text-slate-900 fa-fw ml-1" />
</button>
<button
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)"
title="Check Visibility"
>
<fa icon="rotate" class="fa-fw" />
</button>
<button v-if="contact.registered" class="tooltip">
<span class="tooltiptext">Registered</span>
<fa icon="person-circle-check" class="text-slate-900 fa-fw ml-1" />
</button>
<button v-else @click="register(contact)" class="tooltip">
<span class="tooltiptext">Registration Unknown</span>
<fa
icon="person-circle-question"
class="text-slate-900 fa-fw ml-1"
/>
</button>
<button
@click="register(contact)"
class="text-sm uppercase bg-slate-500 text-white ml-6 px-2 py-1.5 rounded-md"
>
<fa
v-if="contact.registered"
icon="person-circle-check"
class="fa-fw"
title="Registered"
/>
<fa
v-else
icon="person-circle-question"
class="fa-fw"
title="Registration Unknown"
/>
</button>
<button @click="deleteContact(contact)" class="px-9 tooltip">
<span class="tooltiptext">Delete!</span>
<fa icon="trash-can" class="text-red-600 fa-fw ml-1" />
</button>
<button
@click="deleteContact(contact)"
class="text-sm uppercase bg-red-600 text-white ml-24 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
</button>
<div v-if="showGiveNumbers" class="float-right">
<div class="float-right">
<div class="tooltip">
to:
<div
v-if="showGiveNumbers && contact.did != activeDid"
class="ml-auto flex gap-1.5"
>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-l-md"
@click="onClickAddGive(activeDid, contact.did)"
title="givenByMeDescriptions[contact.did]"
>
To:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenByMeConfirmed[contact.did] || 0)
+ (givenByMeUnconfirmed[contact.did] || 0))
+ (givenByMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenByMeConfirmed[contact.did] || 0)
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenByMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenByMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(activeDid, contact.did)"
>
+
</button>
</div>
<div class="tooltip px-2">
from:
<fa icon="plus" />
</button>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-r-md -ml-1.5 border-l border-blue-400"
@click="onClickAddGive(contact.did, activeDid)"
title="givenToMeDescriptions[contact.did]"
>
From:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
@@ -159,39 +200,50 @@
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenToMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenToMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(contact.did, activeDid)"
>
+
</button>
</div>
<fa icon="plus" />
</button>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="tooltip"
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
title="See all given activity"
>
<fa icon="file-lines" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext-left">See All Given Activity</span>
<fa icon="file-lines" class="fa-fw" />
</router-link>
</div>
</div>
</div>
</li>
</ul>
<p v-else>This identity has no contacts.</p>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
<p v-else>There are no contacts.</p>
<div v-if="contactEdit !== null" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactEdit, contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</section>
</template>
@@ -199,31 +251,44 @@
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { NotificationIface } from "@/constants/app";
import { IIdentifier } from "@veramo/core";
import { accountsDB, db } from "@/db";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
accessToken,
getContactPayloadFromJwtUrl,
SimpleSigner,
} from "@/libs/crypto";
import {
GiveServerRecord,
GiveVerifiableCredential,
RegisterVerifiableCredential,
SERVICE_ID,
} from "@/libs/endorserServer";
import { Component, Vue } from "vue-facing-decorator";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
@Component({
components: { AlertMessage, QuickNav },
components: { QuickNav, EntityIcon },
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
contactEndorserUrl = localStorage.getItem("contactEndorserUrl") || "";
contactInput = "";
contactEdit: Contact | null = null;
contactNewName = "";
// { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
@@ -241,12 +306,10 @@ export default class ContactsView extends Vue {
showGiveNumbers = false;
showGiveTotals = true;
showGiveConfirmed = true;
alertTitle = "";
alertMessage = "";
async created() {
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.apiServer = settings?.apiServer || "";
@@ -259,12 +322,18 @@ export default class ContactsView extends Vue {
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
if (this.contactEndorserUrl) {
await this.newContactFromScan(this.contactEndorserUrl);
localStorage.removeItem("contactEndorserUrl");
this.contactEndorserUrl = "";
}
}
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const account = R.find((acc) => acc.did === activeDid, accounts) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -275,7 +344,7 @@ export default class ContactsView extends Vue {
return identity;
}
public async getHeaders(identity) {
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
@@ -284,7 +353,7 @@ export default class ContactsView extends Vue {
return headers;
}
public async getHeadersAndIdentity(activeDid) {
public async getHeadersAndIdentity(activeDid: string) {
const identity = await this.getIdentity(activeDid);
const headers = await this.getHeaders(identity);
@@ -292,20 +361,27 @@ export default class ContactsView extends Vue {
}
async loadGives() {
const handleResponse = (resp, descriptions, confirmed, unconfirmed) => {
const handleResponse = (
resp: { status: number; data: { data: GiveServerRecord[] } },
descriptions: Record<string, string>,
confirmed: Record<string, number>,
unconfirmed: Record<string, number>,
useRecipient: boolean,
) => {
if (resp.status === 200) {
const allData = resp.data.data;
for (const give of allData) {
const otherDid = useRecipient ? give.recipientDid : give.agentDid;
if (give.unit === "HUR") {
if (give.amountConfirmed) {
const prevAmount = confirmed[give.agentDid] || 0;
confirmed[give.agentDid] = prevAmount + give.amount;
const prevAmount = confirmed[otherDid] || 0;
confirmed[otherDid] = prevAmount + give.amount;
} else {
const prevAmount = unconfirmed[give.agentDid] || 0;
unconfirmed[give.agentDid] = prevAmount + give.amount;
const prevAmount = unconfirmed[otherDid] || 0;
unconfirmed[otherDid] = prevAmount + give.amount;
}
if (!descriptions[give.agentDid] && give.description) {
descriptions[give.agentDid] = give.description;
if (!descriptions[otherDid] && give.description) {
descriptions[otherDid] = give.description;
}
}
}
@@ -315,27 +391,31 @@ export default class ContactsView extends Vue {
resp.status,
resp.data,
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your " +
resp.config.url.includes("recipientDid")
? "received"
: "given" + " time from the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text:
"Got an error retrieving your " +
(useRecipient ? "given" : "received") +
" time from the server.",
},
-1,
);
}
};
try {
const { headers, identity } = await this.getHeadersAndIdentity(
this.activeDid,
);
const { headers } = await this.getHeadersAndIdentity(this.activeDid);
const givenByUrl =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did);
encodeURIComponent(this.activeDid);
const givenToUrl =
this.apiServer +
"/api/v2/report/gives?recipientDid=" +
encodeURIComponent(identity.did);
encodeURIComponent(this.activeDid);
const [givenByMeResp, givenToMeResp] = await Promise.all([
this.axios.get(givenByUrl, { headers }),
@@ -350,6 +430,7 @@ export default class ContactsView extends Vue {
givenByMeDescriptions,
givenByMeConfirmed,
givenByMeUnconfirmed,
true,
);
this.givenByMeDescriptions = givenByMeDescriptions;
this.givenByMeConfirmed = givenByMeConfirmed;
@@ -363,17 +444,37 @@ export default class ContactsView extends Vue {
givenToMeDescriptions,
givenToMeConfirmed,
givenToMeUnconfirmed,
false,
);
this.givenToMeDescriptions = givenToMeDescriptions;
this.givenToMeConfirmed = givenToMeConfirmed;
this.givenToMeUnconfirmed = givenToMeUnconfirmed;
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: error as string,
},
-1,
);
}
}
async onClickNewContact(): Promise<void> {
if (!this.contactInput) {
this.$notify(
{
group: "alert",
type: "warning",
title: "No Contact",
text: "There was no contact info to add.",
},
-1,
);
return;
}
let did = this.contactInput;
let name, publicKeyBase64;
const commaPos1 = this.contactInput.indexOf(",");
@@ -392,22 +493,103 @@ export default class ContactsView extends Vue {
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = { did, name, publicKeyBase64 };
await db.contacts.add(newContact);
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
return this.addContact(newContact);
}
async newContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
-1,
);
return;
} else {
return this.addContact({
did: payload.iss,
name: payload.own.name,
publicKeyBase64: payload.own.publicEncKey,
} as Contact);
}
}
async addContact(newContact: Contact) {
if (!newContact.did) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Incomplete Contact",
text: "Cannot add a contact without a DID.",
},
-1,
);
return;
}
newContact.seesMe = true; // since we will immediately set that on the server
return db.contacts
.add(newContact)
.then(() => {
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
this.setVisibility(newContact, true, false);
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text:
newContact.name +
" was added, and your activity is visible to them.",
},
-1,
);
// putting this last so that it shows on the top
this.$notify(
{
group: "alert",
type: "info",
title: "New User?",
text:
"If " +
newContact.name +
" is a new user, be sure to register them.",
},
-1,
);
})
.catch((err) => {
console.error("Error when adding contact to storage:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: "An error prevented this import.",
},
-1,
);
});
}
async deleteContact(contact: Contact) {
if (
confirm(
"Are you sure you want to delete " +
"You should first make sure that your activity is no longer visible to them." +
" Note that this only deletes them from your contacts on this device." +
" \n\nAre you sure you want to remove " +
this.nameForDid(this.contacts, contact.did) +
" with DID " +
contact.did +
" ?",
" from your contact list?",
)
) {
await db.open();
@@ -421,9 +603,22 @@ export default class ContactsView extends Vue {
confirm(
"Are you sure you want to use one of your registrations for " +
this.nameForDid(this.contacts, contact.did) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
)
) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
const identity = await this.getIdentity(this.activeDid);
const vcClaim: RegisterVerifiableCredential = {
@@ -457,28 +652,37 @@ export default class ContactsView extends Vue {
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const headers = await this.getHeaders(identity);
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
if (resp.data?.success?.embeddedRecordError) {
this.alertTitle = "Registration Still Unknown";
let message = "There was some problem with the registration.";
if (typeof resp.data.success.embeddedRecordError == "string") {
message += " " + resp.data.success.embeddedRecordError;
}
this.alertMessage = message;
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Still Unknown",
text: message,
},
-1,
);
} else if (resp.data?.success?.handleId) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.alertTitle = "Registration Success";
this.alertMessage = contact.name + " has been registered.";
this.$notify(
{
group: "alert",
type: "info",
title: "Registration Success",
text: contact.name + " has been registered.",
},
-1,
);
}
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
@@ -493,14 +697,25 @@ export default class ContactsView extends Vue {
userMessage = error as string;
}
// Now set that error for the user to see.
this.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: userMessage,
},
-1,
);
}
}
}
}
async setVisibility(contact: Contact, visibility: boolean) {
async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const url =
this.apiServer +
"/api/report/" +
@@ -512,20 +727,51 @@ export default class ContactsView extends Vue {
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.status === 200) {
if (showSuccessAlert) {
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
this.nameForDid(this.contacts, contact.did) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
-1,
);
}
contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility });
} else {
this.alertTitle = "Error With Server";
console.error("Bad response setting visibility: ", resp.data);
if (resp.data.error?.message) {
this.alertMessage = resp.data.error?.message;
} else {
this.alertMessage = "Bad server response of " + resp.status;
}
console.error(
"Got some bad server response when setting visibility: ",
resp,
);
const message =
resp.data.error?.message || "Bad server response of " + resp.status;
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: message,
},
-1,
);
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
console.error("Got some server error when setting visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: "Check connectivity and try again.",
},
-1,
);
}
}
@@ -534,6 +780,8 @@ export default class ContactsView extends Vue {
this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did);
const identity = await this.getIdentity(this.activeDid);
const headers = await this.getHeaders(identity);
try {
const resp = await this.axios.get(url, { headers });
@@ -542,23 +790,43 @@ export default class ContactsView extends Vue {
contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility });
this.alertTitle = "Refreshed";
this.alertMessage =
this.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.";
this.$notify(
{
group: "alert",
type: "info",
title: "Visibility Refreshed",
text:
this.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
-1,
);
} else {
this.alertTitle = "Error With Server";
if (resp.data.error?.message) {
this.alertMessage = resp.data.error?.message;
} else {
this.alertMessage = "Bad server response of " + resp.status;
}
console.log("Got bad server response when checking visibility: ", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: message,
},
-1,
);
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
console.log("Caught error from server request to check visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: "Check connectivity and try again.",
},
-1,
);
}
}
@@ -582,11 +850,17 @@ export default class ContactsView extends Vue {
// if they have unconfirmed amounts, ask to confirm those first
if (toDid == identity?.did && this.givenToMeUnconfirmed[fromDid] > 0) {
const isare = this.givenToMeUnconfirmed[fromDid] == 1 ? "is" : "are";
const hours = this.givenToMeUnconfirmed[fromDid] == 1 ? "hour" : "hours";
if (
confirm(
"There are " +
"There " +
isare +
" " +
this.givenToMeUnconfirmed[fromDid] +
" unconfirmed hours from them." +
" unconfirmed " +
hours +
" from them." +
" Would you like to confirm some of those hours?",
)
) {
@@ -594,18 +868,39 @@ export default class ContactsView extends Vue {
name: "contact-amounts",
query: { contactDid: fromDid },
});
return;
}
}
if (!this.isNumeric(this.hourInput)) {
this.alertTitle = "Input Error";
this.alertMessage =
"This is not a valid number of hours: " + this.hourInput;
this.$notify(
{
group: "alert",
type: "danger",
title: "Input Error",
text: "This is not a valid number of hours: " + this.hourInput,
},
-1,
);
} else if (!parseFloat(this.hourInput)) {
this.alertTitle = "Input Error";
this.alertMessage = "Giving 0 hours does nothing.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Input Error",
text: "Giving 0 hours does nothing.",
},
-1,
);
} else if (!identity) {
this.alertTitle = "Status Error";
this.alertMessage = "No identity is available.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Status Error",
text: "No identity is available.",
},
-1,
);
} else {
// ask to confirm amount
let toFrom;
@@ -624,7 +919,9 @@ export default class ContactsView extends Vue {
confirm(
"Are you sure you want to record " +
this.hourInput +
" hours " +
" hour" +
(this.hourInput == "1" ? "" : "s") +
" " +
toFrom +
description +
"?",
@@ -684,18 +981,20 @@ export default class ContactsView extends Vue {
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const headers = await this.getHeaders(identity);
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
if (resp.data?.success?.handleId) {
this.alertTitle = "Done";
this.alertMessage = "Successfully logged time to the server.";
this.$notify(
{
group: "alert",
type: "success",
title: "Done",
text: "Successfully logged time to the server.",
},
-1,
);
if (fromDid === identity.did) {
const newList = R.clone(this.givenByMeUnconfirmed);
@@ -720,12 +1019,31 @@ export default class ContactsView extends Vue {
userMessage = error as string;
}
// Now set that error for the user to see.
this.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: userMessage,
},
-1,
);
}
}
}
private async onClickCancelName() {
this.contactEdit = null;
this.contactNewName = "";
}
private async onClickSaveName(contact: Contact, newName: string) {
contact.name = newName;
return db.contacts
.update(contact.did, { name: newName })
.then(() => (this.contactEdit = null));
}
public toggleShowGiveTotals() {
if (this.showGiveTotals) {
this.showGiveTotals = false;
@@ -750,14 +1068,36 @@ export default class ContactsView extends Vue {
</script>
<style>
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
.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;
}
/*
Tooltip, generated on "title" attributes on "fa" icons
Kudos to https://www.w3schools.com/css/css_tooltip.asp
*/
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;

View File

@@ -9,7 +9,7 @@
</h1>
<!-- 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
type="text"
v-model="searchTerms"
@@ -17,7 +17,7 @@
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
/>
<button
@click="search()"
@click="searchSelected()"
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
@@ -30,144 +30,185 @@
<li>
<a
href="#"
@click="searchLocal()"
class="inline-block py-3 rounded-t-lg border-b-2 active text-blue-600 border-blue-600 font-semibold"
@click="
projects = [];
isLocalActive = true;
isRemoteActive = false;
searchLocal();
"
v-bind:class="computedLocalTabClassNames()"
>
Nearby
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
>20+</span
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
</a>
</li>
<li>
<a
href="#"
class="inline-block py-3 rounded-t-lg border-b-2 border-transparent hover:text-slate-600 hover:border-slate-300"
v-bind:class="computedRemoteTabClassNames()"
@click="
projects = [];
isRemoteActive = true;
isLocalActive = false;
searchAll();
"
>
Remote
Anywhere
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
>13</span
v-if="isRemoteActive"
>
{{ remoteCount > -1 ? remoteCount : "?" }}
</span>
</a>
</li>
</ul>
</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 -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<ul class="">
<li class="border-b border-slate-300">
<a
@click="
onClickLoadProject(
'https://endorser.ch/entity/01H3HER4DTNCR6ZC4VXA3VPWVQ',
)
"
class="block py-4 flex gap-4"
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
>
<div class="w-12">
<img
src="https://picsum.photos/200/200?random=1"
class="w-full rounded"
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">Canyon cleanup</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa> Rotary
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
>
<div class="w-12">
<EntityIcon
:entityId="project.handleId"
:iconSize="48"
class="block border border-slate-300 rounded-md"
></EntityIcon>
</div>
</div>
</a>
</li>
<li class="border-b border-slate-300">
<a
@click="
onClickLoadProject(
'https://endorser.ch/entity/01H3HER4DTNCR6ZC4VXA3VPWVQ',
)
"
class="block py-4 flex gap-4"
>
<div class="w-12">
<img
src="https://picsum.photos/200/200?random=2"
class="w-full rounded"
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">Potluck with neighbors</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa> Andrew A.
<div class="grow">
<h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
}}
</div>
</div>
</div>
</a>
</li>
<li class="border-b border-slate-300">
<a
@click="
onClickLoadProject(
'https://endorser.ch/entity/01H3HER4DTNCR6ZC4VXA3VPWVQ',
)
"
class="block py-4 flex gap-4"
>
<div class="w-12">
<img
src="https://picsum.photos/200/200?random=3"
class="w-full rounded"
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">Historical site</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400 mr-1"></fa>
<em>Unknown</em>
</div>
</div>
</a>
</li>
</ul>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</a>
</li>
</ul>
</InfiniteScroll>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import { didInfo, ProjectData } from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { AlertMessage, QuickNav },
components: {
QuickNav,
InfiniteScroll,
EntityIcon,
},
})
export default class DiscoverView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
searchTerms = "";
alertMessage = "";
alertTitle = "";
projects: ProjectData[] = [];
isLoading = false;
isLocalActive = true;
isRemoteActive = false;
localCount = -1;
remoteCount = -1;
searchBox: { name: string; bbox: BoundingBox } | null = null;
// make this function available to the Vue template
didInfo = didInfo;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.searchBox = settings?.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
if (this.searchBox) {
await this.searchLocal();
} else {
this.isLocalActive = false;
this.isRemoteActive = true;
await this.searchAll();
}
}
public async buildHeaders() {
const headers = { "Content-Type": "application/json" };
public resetCounts() {
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) {
await accountsDB.open();
@@ -188,15 +229,24 @@ export default class DiscoverView extends Vue {
return headers;
}
public async search() {
const claimContents =
"claimContents=" + encodeURIComponent(this.searchTerms);
const claimType = "claimType=PlanAction";
const queryParams = [claimContents, claimType].join("&");
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);
if (beforeId) {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/claims?" + queryParams,
this.apiServer + "/api/v2/report/plans?" + queryParams,
{
method: "GET",
headers: await this.buildHeaders(),
@@ -205,36 +255,79 @@ export default class DiscoverView extends Vue {
if (response.status !== 200) {
const details = await response.text();
console.log("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
},
-1,
);
throw details;
}
const results = await response.json();
if (results.data) {
console.log("Plans found in that search:", results.data);
const plans: ProjectData[] = results.data;
if (plans) {
for (const plan of plans) {
const { name, description, handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
}
this.remoteCount = this.projects.length;
} else {
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);
this.alertMessage =
e.userMessage || "There was an error retrieving projects.";
this.alertTitle = "Error";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
public async searchLocal() {
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 =
"claimContents=" + encodeURIComponent(this.searchTerms);
const queryParams = [
let queryParams = [
claimContents,
"minLocLat=40.901000",
"maxLocLat=40.904000",
"westLocLon=-111.914000",
"eastLocLon=-111.909000",
"minLocLat=" + this.searchBox.bbox.minLat,
"maxLocLat=" + this.searchBox.bbox.maxLat,
"westLocLon=" + this.searchBox.bbox.westLong,
"eastLocLon=" + this.searchBox.bbox.eastLong,
].join("&");
if (beforeId) {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{
@@ -244,21 +337,67 @@ export default class DiscoverView extends Vue {
);
if (response.status !== 200) {
const details = await response.text();
console.log("Problem with nearby search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem accessing the server. Try again later.",
},
-1,
);
throw await response.text();
}
const results = await response.json();
if (results.data) {
console.log("Plans found in that location:", results.data);
if (beforeId) {
const plans: ProjectData[] = results.data;
for (const plan of plans) {
const { name, description, handleId = plan.handleId, rowid } = plan;
if (beforeId !== plan["rowid"]) {
this.projects.push({ name, description, handleId, rowid });
}
}
} else {
this.projects = results.data;
}
this.localCount = this.projects.length;
} else {
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);
this.alertMessage =
e.userMessage || "There was an error retrieving projects.";
this.alertTitle = "Error";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive) {
this.searchLocal(latestProject["rowid"]);
} else if (this.isRemoteActive) {
this.searchAll(latestProject["rowid"]);
}
}
}
@@ -269,9 +408,41 @@ export default class DiscoverView extends Vue {
onClickLoadProject(id: string) {
localStorage.setItem("projectId", id);
const route = {
name: "project",
path: "/project/" + encodeURIComponent(id),
};
this.$router.push(route);
}
public computedLocalTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isLocalActive,
"text-blue-600": this.isLocalActive,
"border-blue-600": this.isLocalActive,
"font-semibold": this.isLocalActive,
"border-transparent": !this.isLocalActive,
"hover:text-slate-600": !this.isLocalActive,
"hover:border-slate-300": !this.isLocalActive,
};
}
public computedRemoteTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isRemoteActive,
"text-blue-600": this.isRemoteActive,
"border-blue-600": this.isRemoteActive,
"font-semibold": this.isRemoteActive,
"border-transparent": !this.isRemoteActive,
"hover:text-slate-600": !this.isRemoteActive,
"hover:border-slate-300": !this.isRemoteActive,
};
}
}
</script>

View File

@@ -1,11 +1,25 @@
<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>
<!-- 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>
<div>
<p>
@@ -15,7 +29,7 @@
<h2 class="text-xl font-semibold">What is the philosophy here?</h2>
<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
leaves a permanent record -- one that came from you, and the recipient
can prove it was for them. This is personally gratifying, but it extends
@@ -36,19 +50,37 @@
the control; this app gives you the control.
</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>
You need someone to register you -- usually the person who told you
about this app, on the Contacts
<fa icon="circle-user" class="fa-fw" /> page. After they register you,
and after you have contacts, you can select any contact on the home page
and record your appreciation for... whatever. That is a claim recorded
on a custom ledger. The day after being registered, you'll be able to
<fa icon="users" class="fa-fw" /> page. After they register you, you can
select any contact on the home page (or "anonymous") and record your
appreciation for... whatever. The main goal is to record what people
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.
</p>
<p>
Note that there are limits to how many each person can register, so you
may have to wait.
Note that there are limits to how many others each person can register,
so you may have to wait.
</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>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
@@ -112,20 +144,23 @@
How do I restore my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<li>Make sure you have your backup file (above), then contact us.</li>
<li>
Make sure you have your backup file (above), then contact us with
your interest. This is functionality that has to be written, and
your interest will help us prioritize it, but there are also manual
ways to restore your data.
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">
How do I add someone to my contacts?
</h2>
<h2 class="text-xl font-semibold">How do I create another identity?</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.
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">
create another identity here.
</router-link>
</p>
<h2 class="text-xl font-semibold">
@@ -140,18 +175,10 @@
<fa icon="eye-slash" class="fa-fw" />.
</p>
<p>
Sometimes the reason you don't see something is because the search time
is limited. Go to the bottom and make sure to load all the data on a
list. If you still don't see it, try a search or view on a different
page.
</p>
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p>
Go
<router-link to="start" class="text-blue-500">
create another identity here.
</router-link>
Sometimes the reason you don't see something is because the search
results are limited. Go to the bottom and make sure to load all the data
on a list. If you still don't see it, try a search or view on a
different page.
</p>
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
@@ -162,17 +189,26 @@
</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 }}
</p>
<h2 class="text-xl font-semibold">
For any other questions, including remove your data:
For any other questions, including removing your data:
</h2>
<p>
Contact us through
<a href="https://communitycred.org">CommunityCred.org</a>.
Contact us at
<a mailto="info@TimeSafari.app">info@TimeSafari.app</a>
</p>
</div>
</section>
@@ -181,10 +217,32 @@
<script lang="ts">
import * as Package from "../../package.json";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class Help extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
package = Package;
showOnboardInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Onboard Someone",
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, preferably).
text: "1) Check that they've entered their name. 2) Go to the scanning page via the Identity page and then the through the QR icon at the top, and then scan and register them. 3) Have them go to that page and scan you.",
},
-1,
);
}
}
</script>

View File

@@ -6,112 +6,170 @@
Time Safari
</h1>
<!-- show the actions for recognizing a give -->
<div class="mb-8">
<h1 class="text-2xl">Quick Action</h1>
<p>Choose a contact to whom to show appreciation:</p>
<!-- similar contact selection code is in multiple places -->
<div class="px-4">
<button
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
<div v-if="!activeDid">
To record others' giving,
<router-link :to="{ name: 'start' }" class="text-blue-500">
create your identifier.</router-link
>
{{ contact.name || "(no name)" }}
</button>
<span v-if="allContacts.length > 0">&nbsp;or&nbsp;</span>
<button @click="openDialog()" class="text-blue-500">
someone not specified
</button>
</div>
<div v-else-if="!isRegistered">
To record others' giving, someone must register your account, so show
them
<router-link :to="{ name: 'contact-qr' }" class="text-blue-500">
your identity info</router-link
>
and then
<router-link :to="{ name: 'account' }" class="text-blue-500">
check your limits.</router-link
>
</div>
<div v-else>
<!-- activeDid && isRegistered -->
<h2 class="text-xl font-bold">Record Something Given</h2>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()">
<EntityIcon
:entityId="null"
: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/Unnamed
</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&hellip;
</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>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
>
</GiftedDialog>
showGivenToUser="true"
/>
<div>
<h1 class="text-2xl">Latest Activity</h1>
<span :class="{ hidden: isHiddenSpinner }">
<fa icon="spinner" class="fa-spin-pulse"></fa>
Loading&hellip;
</span>
<ul>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
<div :class="{ hidden: isHiddenSpinner }">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
</p>
</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 py-2 mb-2 font-bold uppercase text-sm"
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
v-if="record.jwtId == feedLastViewedId"
>
You've seen all claims below:
</div>
<div class="flex">
<fa
icon="gift"
class="fa-fw flex-none pt-1 pr-2 text-slate-500"
></fa>
<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>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
import { Account } from "@/db/tables/accounts";
import {
didInfo,
GiverInputInfo,
GiveServerRecord,
} from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { IIdentifier } from "@veramo/core";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { GiftedDialog, AlertMessage, QuickNav },
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class HomeView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
allAccounts: Array<Account> = [];
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
feedAllLoaded = false;
feedData = [];
feedPreviousOldestId = null;
feedLastViewedId = null;
feedPreviousOldestId?: string;
feedLastViewedId?: string;
isHiddenSpinner = true;
alertTitle = "";
alertMessage = "";
accounts: AccountsSchema;
isRegistered = false;
numAccounts = 0;
async beforeCreate() {
accountsDB.open();
this.accounts = accountsDB.accounts;
this.numAccounts = await this.accounts.count();
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -122,7 +180,7 @@ export default class HomeView extends Vue {
return identity;
}
public async getHeaders(identity) {
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
@@ -134,29 +192,44 @@ export default class HomeView extends Vue {
async created() {
try {
await accountsDB.open();
this.allAccounts = await accountsDB.accounts.toArray();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
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.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedId = settings?.lastViewedClaimId;
this.isRegistered = !!settings?.isRegistered;
this.updateAllFeed();
} catch (err) {
this.alertTitle = "Error";
this.alertMessage =
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
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" };
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const account = allAccounts.find(
(acc) => acc.did === this.activeDid,
) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -174,7 +247,7 @@ export default class HomeView extends Vue {
public async updateAllFeed() {
this.isHiddenSpinner = false;
await this.retrieveClaims(this.apiServer, null, this.feedPreviousOldestId)
await this.retrieveClaims(this.apiServer, this.feedPreviousOldestId)
.then(async (results) => {
if (results.data.length > 0) {
this.feedData = this.feedData.concat(results.data);
@@ -195,15 +268,21 @@ export default class HomeView extends Vue {
})
.catch((e) => {
console.log("Error with feed load:", e);
this.alertMessage =
e.userMessage || "There was an error retrieving feed data.";
this.alertTitle = "Error";
this.$notify(
{
group: "alert",
type: "danger",
title: "Export Error",
text: e.userMessage || "There was an error retrieving feed data.",
},
-1,
);
});
this.isHiddenSpinner = true;
}
public async retrieveClaims(endorserApiServer, identifier, beforeId) {
public async retrieveClaims(endorserApiServer: string, beforeId?: string) {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const response = await fetch(
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
@@ -226,132 +305,58 @@ export default class HomeView extends Vue {
}
}
giveDescription(giveRecord) {
let claim = giveRecord.fullClaim;
if (claim.claim) {
claim = claim.claim;
}
giveDescription(giveRecord: GiveServerRecord) {
// similar code is in endorser-mobile utility.ts
// claim.claim happen for some claims wrapped in a Verifiable Credential
// 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
const giverDid =
claim.agent?.identifier || claim.agent?.did || giveRecord.issuer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const giverDid = claim.agent?.identifier || (claim.agent as any)?.did;
const giverInfo = didInfo(
giverDid,
this.activeDid,
this.allAccounts,
this.allMyDids,
this.allContacts,
);
const gaveAmount = claim.object?.amountOfThisGood
let gaveAmount = 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
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
? " to " +
didInfo(
gaveRecipientId,
this.activeDid,
this.allAccounts,
this.allMyDids,
this.allContacts,
)
: "";
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
}
displayAmount(code, amt) {
displayAmount(code: string, amt: number) {
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
}
currencyShortWordForCode(unitCode, single) {
currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
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.setAlert(
"Error",
"You must select an identity before you can record a give.",
);
return;
}
if (!description && !hours) {
this.setAlert(
"Error",
"You must enter a description or some number of hours.",
);
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.setAlert(
"Error",
errorMessage || "There was an error recording the give.",
);
} else {
this.setAlert("Success", "That gift was recorded.");
}
} catch (error) {
console.log("Error with give caught:", error);
this.setAlert(
"Error",
getGiveErrorMessage(error) || "There was an error recording the give.",
);
}
}
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;
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa>
</router-link>
Switch Identity
</h1>
</div>
<!-- Identity List -->
<!-- Current Identity - Display First! -->
<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>
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0">
{{ givenName }}
</h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ activeDid }}</code>
</div>
</span>
</div>
<!-- Other Identity/ies -->
<ul class="mb-4">
<li
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
v-for="ident in otherIdentities"
:key="ident.did"
@click="switchAccount(ident.did)"
>
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa>
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0"></h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code>
</div>
</span>
</li>
</ul>
<!-- Actions -->
<!-- id used by puppeteer test script -->
<router-link
id="start-link"
: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"
>
Add Another Identity&hellip;
</router-link>
<a
href="#"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
@click="switchAccount('0')"
>
No Identity
</a>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
Constants = AppString;
public accounts: typeof AccountsSchema;
public activeDid = "";
public apiServer = "";
public apiServerInput = "";
public givenName = "";
public otherIdentities: Array<{ did: string }> = [];
public showContactGives = false;
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse((account?.identity as string) || "null");
return identity;
}
async created() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
this.showContactGives = !!settings?.showContactGivesInline;
const identity = await this.getIdentity(this.activeDid);
if (identity) {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
}
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"];
if (did && this.activeDid !== did) {
this.otherIdentities.push({ did: did });
}
}
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Accounts",
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) {
// 0 means none
if (did === "0") {
did = undefined;
}
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
this.activeDid = did || "";
this.otherIdentities = [];
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"];
if (did && this.activeDid !== did) {
this.otherIdentities.push({ did: did });
}
}
this.$router.push({ name: "account" });
}
}
</script>

View File

@@ -17,13 +17,33 @@
<p class="text-center text-xl mb-4 font-light">
Enter your seed phrase below to import your identity on this device.
</p>
<!-- id used by puppeteer test script -->
<input
id="seed-input"
type="text"
placeholder="Seed Phrase"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="mnemonic"
/>
{{ mnemonic }}
<h3
class="text-sm uppercase font-semibold mb-3"
@click="showAdvanced = !showAdvanced"
>
Advanced
</h3>
<div v-if="showAdvanced">
Enter a custom derivation path
<input
type="text"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="derivationPath"
/>
For previous uPort or Endorser users,
<a @click="derivationPath = UPORT_DERIVATION_PATH" class="text-blue-500">
click here to use that value.
</a>
</div>
<div class="mt-8">
<button
@click="from_mnemonic()"
@@ -44,19 +64,26 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { accountsDB, db } from "@/db";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({
components: {},
})
export default class ImportAccountView extends Vue {
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
mnemonic = "";
address = "";
privateHex = "";
publicHex = "";
derivationPath = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
showAdvanced = false;
public onCancelClick() {
this.$router.back();
@@ -65,8 +92,10 @@ export default class ImportAccountView extends Vue {
public async from_mnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
if (this.mnemonic.trim().length > 0) {
[this.address, this.privateHex, this.publicHex, this.derivationPath] =
deriveAddress(mne);
[this.address, this.privateHex, this.publicHex] = deriveAddress(
mne,
this.derivationPath,
);
const newId = newIdentifier(
this.address,

View File

@@ -0,0 +1,163 @@
<template>
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left"></fa>
</button>
Derive from Existing Identity
</h1>
</div>
<!-- Import Account Form -->
<div>
<p class="text-center text-xl mb-4 font-light">
Will increment the maximum derivation path from the existing seed.
</p>
<p v-if="didArrays.length > 1">
Choose existing DIDs from same seed phrase to compute derivation.
</p>
<ul class="mb-4">
<li
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
v-for="dids in didArrays"
:key="dids[0]"
@click="switchAccount(dids[0])"
>
<fa
v-if="dids[0] == selectedArrayFirstDid"
icon="circle"
class="fa-fw text-blue-400 text-xl mr-3"
></fa>
<fa
v-else
icon="circle"
class="fa-fw text-slate-400 text-xl mr-3"
></fa>
<span class="overflow-hidden">
<div class="text-sm text-slate-500 truncate">
<code>{{ dids.join(",") }}</code>
</div>
</span>
</li>
</ul>
</div>
<div class="mt-8">
<button
@click="incrementDerivation()"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
Increment and Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({
components: {},
})
export default class ImportAccountView extends Vue {
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
didArrays: Array<Array<string>> = [];
selectedArrayFirstDid = "";
async mounted() {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const seedDids: Record<string, Array<string>> = {};
accounts.forEach((account) => {
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
seedDids[account.mnemonic] = prevDids.concat([account.did]);
});
this.didArrays = Object.values(seedDids);
this.selectedArrayFirstDid = this.didArrays[0][0];
}
public onCancelClick() {
this.$router.back();
}
public switchAccount(did: string) {
this.selectedArrayFirstDid = did;
}
public async incrementDerivation() {
await accountsDB.open();
// find the maximum derivation path for the selected DIDs
const selectedArray: Array<string> =
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
[];
const allMatchingAccounts = await accountsDB.accounts
.where("did")
.anyOf(...selectedArray)
.toArray();
const accountWithMaxDeriv = allMatchingAccounts[0];
allMatchingAccounts.slice(1).forEach((account) => {
if (account.derivationPath > accountWithMaxDeriv.derivationPath) {
accountWithMaxDeriv.derivationPath = account.derivationPath;
}
});
// increment the last number in that max derivation path
let lastStr = accountWithMaxDeriv.derivationPath.split("/").slice(-1)[0];
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 [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try {
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: newDerivPath,
did: newId.did,
identity: JSON.stringify(newId),
mnemonic: mne,
publicKeyHex: newId.keys[0].publicKeyHex,
});
// record that as the active DID
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
this.$router.push({ name: "account" });
} catch (err) {
console.error("Error saving mnemonic & updating settings:", err);
}
}
}
</script>

View File

@@ -10,77 +10,64 @@
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
[New/Edit] Identity
Edit Identity
</h1>
</div>
<form>
<input
type="text"
placeholder="First Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="firstName"
/>
<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">
<button
type="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="onClickSaveChanges()"
>
Save Changes
</button>
<!-- SHOW ME instead while processing saving changes -->
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</form>
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="givenName"
/>
<div class="mt-8">
<button
type="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="onClickSaveChanges()"
>
Save Changes
</button>
<!-- SHOW ME instead while processing saving changes -->
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@Component({
components: {},
})
export default class NewEditAccountView extends Vue {
firstName =
localStorage.getItem("firstName") === null
? "--"
: localStorage.getItem("firstName");
lastName =
localStorage.getItem("lastName") === null
? "--"
: localStorage.getItem("lastName");
givenName = "";
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.firstName = settings?.firstName || "";
this.lastName = settings?.lastName || "";
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
}
onClickSaveChanges() {
db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.firstName,
lastName: this.lastName,
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
localStorage.setItem("firstName", this.firstName as string);
localStorage.setItem("lastName", this.lastName as string);
localStorage.setItem("firstName", this.givenName as string);
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.push({ name: "account" });
}

View File

@@ -16,47 +16,44 @@
</div>
<!-- Project Details -->
<form>
<select
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
<select class="block w-full rounded border border-slate-400 mb-4 px-3 py-2">
<option disabled>Choose a commitment type</option>
<option selected>Time</option>
<option>Cryptocurrency</option>
<option>Money</option>
</select>
<!-- Time amount -->
<div class="mb-4 flex items-stretch">
<input
type="number"
placeholder="0.0"
class="block w-full rounded-l border border-slate-400 px-3 py-2"
/>
<span
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>hours</span
>
<option disabled>Choose a commitment type</option>
<option selected>Time</option>
<option>Cryptocurrency</option>
<option>Money</option>
</select>
</div>
<!-- Time amount -->
<div class="mb-4 flex items-stretch">
<input
type="number"
placeholder="0.0"
class="block w-full rounded-l border border-slate-400 px-3 py-2"
/>
<span
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>hours</span
>
</div>
<!-- Crypto amount -->
<!-- Crypto amount -->
<!-- Money amount -->
<!-- Money amount -->
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Commit"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Maybe Later
</button>
</div>
</form>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Commit"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Maybe Later
</button>
</div>
</section>
</template>

View File

@@ -1,4 +1,5 @@
<template>
<QuickNav selected="Projects"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
@@ -39,6 +40,45 @@
{{ description.length }}/500 max. characters
</div>
<div class="flex items-center mb-4">
<input
type="checkbox"
class="mr-2"
v-model="includeLocation"
@click="includeLocation = !includeLocation"
/>
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" style="height: 600px; width: 800px">
<div class="px-2 py-2">
For your security, we recommend you choose a location nearby but not
exactly at the place.
</div>
<l-map
ref="map"
v-model:zoom="zoom"
:center="[0, 0]"
@click="
(event) => {
latitude = event.latlng.lat;
longitude = event.latlng.lng;
}
"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="latitude || longitude"
:lat-lng="[latitude, longitude]"
@click="maybeEraseLatLong()"
/>
</l-map>
</div>
<div class="mt-8">
<button
:disabled="isHiddenSave"
@@ -63,53 +103,54 @@
Cancel
</button>
</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { accountsDB, db } from "@/db";
import { AccountsSchema } from "@/db/tables/accounts";
import QuickNav from "@/components/QuickNav.vue";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
import AlertMessage from "@/components/AlertMessage";
import { PlanVerifiableCredential } from "@/libs/endorserServer";
interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { AlertMessage },
components: { LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
apiServer = "";
projectName = "";
description = "";
errorMessage = "";
accounts: AccountsSchema;
includeLocation = false;
latitude = 0;
longitude = 0;
numAccounts = 0;
projectName = "";
zoom = 2;
async beforeCreate() {
accountsDB.open();
this.accounts = accountsDB.accounts;
this.numAccounts = await this.accounts.count();
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
@@ -125,7 +166,7 @@ export default class NewEditProjectView extends Vue {
return identity;
}
public async getHeaders(identity) {
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
@@ -184,7 +225,7 @@ export default class NewEditProjectView extends Vue {
private async SaveProject(identity: IIdentifier) {
// Make a claim
const vcClaim: VerifiableCredential = {
const vcClaim: PlanVerifiableCredential = {
"@context": "https://schema.org",
"@type": "PlanAction",
name: this.projectName,
@@ -194,6 +235,15 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) {
vcClaim.identifier = this.projectId;
}
if (this.includeLocation) {
vcClaim.location = {
geo: {
"@type": "GeoCoordinates",
latitude: this.latitude,
longitude: this.longitude,
},
};
}
// Make a payload for the claim
const vcPayload = {
vc: {
@@ -227,22 +277,18 @@ export default class NewEditProjectView extends Vue {
try {
const resp = await this.axios.post(url, payload, { headers });
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://endorser.ch:3000/api-docs/
// version shows up here: https://api.endorser.ch/api-docs/
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
this.errorMessage = "";
this.alertTitle = "";
this.alertMessage = "";
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://endorser.ch:3000/api-docs/
// version shows up here: https://api.endorser.ch/api-docs/
useAppStore().setProjectId(
resp.data.success.handleId || resp.data.success.fullIri,
);
setTimeout(
function (that: Vue) {
const route = {
name: "project",
};
that.$router.push(route);
function (that: NewEditProjectView) {
that.$router.push({ name: "project" });
},
2000,
this,
@@ -250,24 +296,47 @@ export default class NewEditProjectView extends Vue {
}
} catch (error) {
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 (Object.prototype.hasOwnProperty.call(serverError, "message")) {
console.log(serverError);
this.alertTitle = "User Message";
userMessage = serverError.response.data.error.message; // This is info for the user.
this.alertMessage = userMessage;
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
this.$notify(
{
group: "alert",
type: "danger",
title: "User Message",
text: userMessage,
},
-1,
);
} else {
this.alertTitle = "Server Message";
this.alertMessage = JSON.stringify(serverError.toJSON());
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Message",
text: JSON.stringify(serverError.toJSON()),
},
-1,
);
}
} else {
console.error(
"Here's the full error trying to save the claim:",
error,
);
this.alertTitle = "Claim Error";
this.alertMessage = error as string;
this.$notify(
{
group: "alert",
type: "danger",
title: "Claim Error",
text: error as string,
},
-1,
);
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
@@ -287,12 +356,16 @@ export default class NewEditProjectView extends Vue {
}
}
public maybeEraseLatLong() {
if (window.confirm("Are you sure you don't want to mark a location?")) {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
}
}
public onCancelClick() {
this.$router.back();
}
// This same popup code is in many files.
alertTitle = "";
alertMessage = "";
}
</script>

View File

@@ -1,11 +1,25 @@
<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">
Your Identity
</h1>
<!-- 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">
Your Identity
</h1>
</div>
<div class="flex justify-center py-12">
<span />
@@ -40,17 +54,16 @@
<script lang="ts">
import "dexie-export-import";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { accountsDB, db } from "@/db/index";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class AccountViewView extends Vue {
export default class NewIdentifierView extends Vue {
loading = true;
async mounted() {
await accountsDB.open();
const mnemonic = generateSeed();
// address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] =
@@ -58,6 +71,8 @@ export default class AccountViewView extends Vue {
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,

View File

@@ -12,31 +12,45 @@
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
<!-- Context Menu -->
<a
href=""
class="text-lg text-center px-2 py-1 absolute -right-2 -top-1"
><fa icon="ellipsis-vertical" class="fa-fw"></fa
></a>
View Plan
</h1>
</div>
<div class="text-red-500">
{{ errorMessage }}
</div>
<!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="flex justify-between gap-4 text-sm mb-3">
<span><fa icon="user" class="fa-fw text-slate-400"></fa> Rotary</span>
<span
><fa icon="calendar" class="fa-fw text-slate-400"></fa
>{{ timeSince }}
</span>
<div class="block pb-4 flex gap-4">
<div class="flex-none w-16 pt-1">
<EntityIcon
:entityId="projectId"
:iconSize="64"
class="block border border-slate-300 rounded-md"
></EntityIcon>
</div>
<div class="overflow-hidden">
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ issuer }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ timeSince }}
</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
</a>
</div>
</div>
</div>
</div>
<div class="text-sm text-slate-500">
@@ -57,6 +71,7 @@
</div>
</div>
<button
v-if="issuer == activeDid"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onEditClick()"
@@ -65,165 +80,263 @@
</button>
</div>
<div>
<div v-if="activeDid">
<div class="mb-4">
<div v-if="activeDid" class="text-center">
<button
@click="openDialog({ name: 'you', did: activeDid })"
class="text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
@click="openOfferDialog({ name: 'you', did: activeDid })"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
I gave...
I offer&hellip;
</button>
&horbar; or:
</div>
<!-- similar contact selection code is in multiple places -->
Record a gift from
<span v-for="contact in allContacts" :key="contact.did">
<button @click="openDialog(contact)" class="text-blue-500">
&nbsp;{{ contact.name }}</button
>,
</span>
<span v-if="allContacts.length > 0">&nbsp;or&nbsp;</span>
<button @click="openDialog()" class="text-blue-500">
someone not specified
</button>
</div>
<div>
<div v-if="activeDid" class="text-center">
<button
@click="openGiftDialog({ name: 'you', did: activeDid })"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
I gave&hellip;
</button>
<p class="mt-2 mb-4 text-center">Or, record a gift from:</p>
</div>
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openGiftDialog()">
<EntityIcon
:entityId="null"
: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/Unnamed
</h3>
</li>
<li
v-for="contact in allContacts"
:key="contact.did"
@click="openGiftDialog(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 || "(no name)" }}
</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&hellip;
</router-link>
</div>
<!-- Gifts to & from this -->
<div class="mt-8 flex justify-around">
<div>
<h1 class="text-xl">Given to this Project</h1>
</div>
<div>
<h1 class="text-xl">... and from this Project</h1>
</div>
</div>
<div class="flex justify-around">
<div class="w-1/2">
<div v-for="give in givesToThis" :key="give.id">
<div class="flex justify-between">
<div class="flex gap-3">
<div class="flex gap-2">
<fa icon="user" class="fa-fw text-slate-400"></fa>
<span>{{
didInfo(give.agentDid, activeDid, accounts, allContacts)
}}</span>
</div>
<div class="flex gap-2" v-if="give.amount">
<fa icon="coins" class="fa-fw text-slate-400"></fa>
<span>{{ give.amount }}</span>
</div>
<div class="flex gap-2" v-if="give.description">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
<span>{{ give.description }}</span>
</div>
</div>
</div>
<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">
<h3 class="text-sm uppercase font-semibold mb-3">
Offered To This Project
</h3>
<div v-if="offersToThis.length === 0">
(None yet. Record one above.)
</div>
</div>
<div class="w-1/2">
<div v-for="give in givesByThis" :key="give.id">
<div class="flex justify-between">
<div class="flex gap-3">
<div class="flex gap-2">
<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>
<span>{{
didInfo(give.agentDid, activeDid, accounts, allContacts)
}}</span>
</div>
<div class="flex gap-2" v-if="give.amount">
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
</span>
<span v-if="offer.amount">
<fa icon="coins" class="fa-fw text-slate-400"></fa>
<span>{{ give.amount }}</span>
</div>
<div class="flex gap-2">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
<span>{{ give.description }}</span>
</div>
{{ offer.amount }}
</span>
</div>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
{{ offer.objectDescription }}
</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 Project
</h3>
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
:key="give.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>
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
</span>
<span v-if="give.amount"
><fa icon="coins" class="fa-fw text-slate-400"></fa>
{{ give.amount }}
</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 class="grid items-start grid-cols-1 gap-4">
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions To This Project
</h3>
<ul>
<li v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
>
{{ plan.name }}
</button>
</li>
</ul>
</div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions By This Project
</h3>
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
</div>
</div>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
</OfferDialog>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as moment from "moment";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { accountsDB, db } from "@/db";
import { AccountsSchema } from "@/db/tables/accounts";
import OfferDialog from "@/components/OfferDialog.vue";
import { accountsDB, db } from "@/db/index";
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 {
createAndSubmitGive,
didInfo,
GiverInputInfo,
GiveServerRecord,
OfferServerRecord,
PlanServerRecord,
} from "@/libs/endorserServer";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { GiftedDialog, AlertMessage, QuickNav },
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
})
export default class ProjectViewView extends Vue {
accounts: AccountsSchema;
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
alertMessage = "";
alertTitle = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
description = "";
errorMessage = "";
expanded = false;
fulfilledByThis: PlanServerRecord | null = null;
fulfillersToThis: Array<PlanServerRecord> = [];
givesToThis: Array<GiveServerRecord> = [];
givesByThis: Array<GiveServerRecord> = [];
issuer = "";
latitude = 0;
longitude = 0;
name = "";
numAccounts = 0;
offersToThis: Array<OfferServerRecord> = [];
projectId = localStorage.getItem("projectId") || ""; // handle ID
timeSince = "";
truncatedDesc = "";
truncateLength = 40;
async beforeCreate() {
accountsDB.open();
this.accounts = accountsDB.accounts;
this.numAccounts = (await this.accounts?.count()) || 0;
}
async created() {
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.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.accounts = accountsDB.accounts;
const accountsArr = await this.accounts?.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);
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();
const account = await accountsDB.accounts
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -234,7 +347,7 @@ export default class ProjectViewView extends Vue {
return identity;
}
public async getHeaders(identity) {
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
@@ -252,8 +365,13 @@ export default class ProjectViewView extends Vue {
}
// Isn't there a better way to make this available to the template?
didInfo(did, activeDid, identities, contacts) {
return didInfo(did, activeDid, identities, contacts);
didInfo(
did: string,
activeDid: string,
dids: Array<string>,
contacts: Array<Contact>,
) {
return didInfo(did, activeDid, dids, contacts);
}
expandText() {
@@ -264,12 +382,12 @@ export default class ProjectViewView extends Vue {
this.expanded = false;
}
async LoadProject(identity: IIdentifier) {
async LoadProject(projectId: string, identity: IIdentifier) {
this.projectId = projectId;
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
const headers = {
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
@@ -280,137 +398,234 @@ export default class ProjectViewView extends Vue {
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
// feel free to remove this; I haven't yet because it's helpful
console.log('Loaded project: ', resp.data);
const startTime = resp.data.startTime;
if (startTime != null) {
const eventDate = new Date(startTime);
const now = moment.now();
this.timeSince = moment.utc(now).to(eventDate);
}
this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
} else if (resp.status === 404) {
// actually, axios throws an error so we never get here
this.errorMessage = "That project does not exist.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That project does not exist.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
if (serverError.response?.status === 404) {
this.errorMessage = "That project does not exist.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That project does not exist.",
},
-1,
);
} else {
this.errorMessage =
"Something went wrong retrieving that project." +
" See logs for more info.";
console.error("Error retrieving project:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.",
},
-1,
);
console.error("Error retrieving project:", serverError.message);
}
}
const givesInUrl =
this.apiServer +
"/api/v2/report/givesForPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = resp.data.data;
} else {
this.errorMessage = "Failed to retrieve gives to this project.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives to this project.",
},
-1,
);
}
} catch (error: unknown) {
console.error("Error retrieving gives to this project:", error);
const serverError = error as AxiosError;
this.errorMessage =
"Something went wrong retrieving gives to this project.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives to this project.",
},
-1,
);
console.error(
"Error retrieving gives to this project:",
serverError.message,
);
}
const givesOutUrl =
const offersToUrl =
this.apiServer +
"/api/v2/report/givesProvidedBy?providerId=" +
encodeURIComponent(this.projectId);
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesOutUrl, { headers });
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesByThis = resp.data.data;
this.offersToThis = resp.data.data;
} else {
this.errorMessage = "Failed to retrieve gives by this project.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve offers to this project.",
},
-1,
);
}
} catch (error: unknown) {
console.error("Error retrieving gives by this project:", error);
const serverError = error as AxiosError;
this.errorMessage =
"Something went wrong retrieving gives by project.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving offers to this project.",
},
-1,
);
console.error(
"Error retrieving offers to this project:",
serverError.message,
);
}
}
openDialog(contact) {
this.$refs.customDialog.open(contact);
}
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfilledByUrl, { headers });
if (resp.status === 200) {
this.fulfilledByThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
-1,
);
console.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
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
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,
);
}
}
/**
*
* @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.alertTitle = "Error";
this.alertMessage =
"You must select an identity before you can record a give.";
return;
}
* 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));
}
if (!description && !hours) {
this.alertTitle = "Error";
this.alertMessage =
"You must enter a description or some number of hours.";
return;
}
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
);
}
try {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
this.projectId,
);
openGiftDialog(contact: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
}
if (result.status !== 201 || result.data?.error) {
console.log("Error with give result:", result);
this.alertTitle = "Error";
this.alertMessage =
result.data?.error?.message ||
"There was an error recording the give.";
} else {
this.alertTitle = "Success";
this.alertMessage = "That gift was recorded.";
}
} catch (e) {
console.log("Error with give caught:", e);
this.alertTitle = "Error";
this.alertMessage =
e.userMessage ||
e.response?.data?.error?.message ||
"There was an error recording the give.";
}
openOfferDialog() {
(this.$refs.customOfferDialog as OfferDialog).open();
}
}
</script>

View File

@@ -7,7 +7,8 @@
</h1>
<!-- Quick Search -->
<form id="QuickSearch" class="mb-4 flex">
<div id="QuickSearch" class="mb-4 flex">
<input
type="text"
placeholder="Search…"
@@ -18,7 +19,7 @@
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</form>
</div>
<!-- New Project -->
<button
@@ -38,7 +39,7 @@
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="project in projects"
@@ -49,10 +50,11 @@
class="block py-4 flex gap-4"
>
<div class="flex-none w-12">
<img
src="https://picsum.photos/200/200?random=1"
class="w-full rounded"
/>
<EntityIcon
:entityId="project.handleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></EntityIcon>
</div>
<div class="grow overflow-hidden">
@@ -65,63 +67,42 @@
</li>
</ul>
</InfiniteScroll>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { AccountsSchema } from "@/db/tables/accounts";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core";
import InfiniteScroll from "@/components/InfiniteScroll";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { ProjectData } from "@/libs/endorserServer";
/**
* Represents data about a project
**/
interface ProjectData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
/**
* The Identier of the project
**/
rowid: string;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { InfiniteScroll, AlertMessage, QuickNav },
components: { InfiniteScroll, QuickNav, EntityIcon },
})
export default class ProjectsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
apiServer = "";
projects: ProjectData[] = [];
current: IIdentifier;
isLoading = false;
alertTitle = "";
alertMessage = "";
accounts: AccountsSchema;
numAccounts = 0;
async beforeCreate() {
accountsDB.open();
this.accounts = accountsDB.accounts;
this.numAccounts = await this.accounts.count();
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
/**
@@ -141,17 +122,33 @@ export default class ProjectsView extends Vue {
if (resp.status === 200 || !resp.data.data) {
const plans: ProjectData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId = plan.fullIri, rowid } = plan;
const { name, description, handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
}
} else {
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) {
console.error("Got error loading projects:", error.message);
this.alertTitle = "Error";
this.alertMessage = "Got an error loading projects:" + error.message;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading projects:", error.message || error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading projects.",
},
-1,
);
} finally {
this.isLoading = false;
}
@@ -177,7 +174,7 @@ export default class ProjectsView extends Vue {
onClickLoadProject(id: string) {
localStorage.setItem("projectId", id);
const route = {
name: "project",
path: "/project/" + encodeURIComponent(id),
};
this.$router.push(route);
}
@@ -192,7 +189,7 @@ export default class ProjectsView extends Vue {
await this.dataLoader(url, token);
}
public async getIdentity(activeDid) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
@@ -220,8 +217,15 @@ export default class ProjectsView extends Vue {
if (this.numAccounts === 0) {
console.error("No accounts found.");
this.alertTitle = "Error";
this.alertMessage = "You need an identity to load your projects.";
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;
@@ -229,8 +233,15 @@ export default class ProjectsView extends Vue {
}
} catch (err) {
console.log("Error initializing:", err);
this.alertTitle = "Error";
this.alertMessage = "Something went wrong loading your projects.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
}
}

View File

@@ -0,0 +1,286 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- 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>

View File

@@ -20,45 +20,64 @@
</div>
<div v-if="activeAccount">
<p>
BEWARE: Anyone who gets hold of this mnemonic seed phrase will be able
impersonate you and take over any digital holdings based on it. So only
reveal it when you are in a private place out of sight of other eyes,
and only record it in something private -- don't take a screenshot or
send it to any online service.
<p class="text-center mb-4">
<b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will
be able impersonate you and take over any digital holdings based on it.
Reveal it when you are somewhere only you can see your screen, and
record it somewhere only you have access.
<i>Don't take a screenshot or send it to any online service.</i>
</p>
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="showSeedPhrase"
>
Click here when you're ready to see it.
</button>
<p v-if="numAccounts > 1">
<b class="text-orange-600">Note:</b> You have more than one identity
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
different seeds for other identities, you will have to back them up
separately.
</p>
<p v-if="showSeed">{{ activeAccount.mnemonic }}</p>
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="showSeedPhrase"
>
Reveal my Seed Phrase
</button>
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
{{ activeAccount.mnemonic }}
</p>
</div>
</div>
<div v-else>You do not have an active identity.</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { accountsDB, db } from "@/db/index";
import * as R from "ramda";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
@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 {
activeAccount = null;
$notify!: (notification: Notification, timeout?: number) => void;
activeAccount: Account | null | undefined = null;
numAccounts = 0;
showSeed = false;
alertMessage = "";
alertTitle = "";
// 'created' hook runs when the Vue instance is first created
async created() {
@@ -69,11 +88,19 @@ export default class SeedBackupView extends Vue {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
this.numAccounts = accounts.length;
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
} catch (err) {
} catch (err: unknown) {
console.error("Got an error loading an identity:", err);
this.alertTitle = "Error Loading Account";
this.alertMessage = "Got an error loading your seed data.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Account",
text: "Got an error loading your seed data.",
},
-1,
);
}
}

View File

@@ -3,37 +3,67 @@
id="Content"
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Start Here
</h1>
<!-- 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>
<div class="mt-8">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Start Here
</h1>
</div>
<!-- id used by puppeteer test script -->
<div id="start-question" class="mt-8">
<p class="text-center text-xl mb-4 font-light">
Do you already have an identity to import?
Do you have an identity to import?
</p>
<a
@click="onClickYes()"
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 uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
No
</a>
<a
@click="onClickNo()"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>Yes</a
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
>
Yes
</a>
<a
v-if="numAccounts > 0"
@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"
>
Derive New Address from Seed Imported Previously
</a>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB } from "@/db/index";
@Component({
components: {},
})
export default class StartView extends Vue {
numAccounts = 0;
async mounted() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public onClickYes() {
this.$router.push({ name: "new-identifier" });
}
@@ -41,5 +71,9 @@ export default class StartView extends Vue {
public onClickNo() {
this.$router.push({ name: "import-account" });
}
public onClickDerive() {
this.$router.push({ name: "import-derive" });
}
}
</script>

View File

@@ -1,11 +1,25 @@
<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">
Achievements & Statistics
</h1>
<!-- 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">
Achievements & Statistics
</h1>
</div>
<div>
Here is a view of the activity you can see.
@@ -32,44 +46,55 @@
{{ worldProperties.animationDurationSeconds }} seconds
</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>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<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 { World } from "@/components/World/World.js";
import AlertMessage from "@/components/AlertMessage";
import QuickNav from "@/components/QuickNav";
import QuickNav from "@/components/QuickNav.vue";
interface WorldProperties {
startTime?: string;
endTime?: string;
interface RendererSVGType {
domElement: Element;
}
@Component({ components: { AlertMessage, World, QuickNav } })
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 {
$notify!: (notification: Notification, timeout?: number) => void;
world: World;
worldProperties: WorldProperties = {};
alertTitle = "";
alertMessage = "";
worldProperties: Dictionary<number> = {};
mounted() {
try {
const container = document.querySelector("#scene-container");
console.log(container);
const newWorld = new World(container, this);
newWorld.start();
this.world = newWorld;
} catch (err) {
console.log(err);
this.alertTitle = "Mounting error";
this.alertMessage = err.message;
} catch (err: unknown) {
const error = err as Error;
this.$notify(
{
group: "alert",
type: "danger",
title: "Mounting Error",
text: error.message,
},
-1,
);
}
}
@@ -84,12 +109,12 @@ export default class StatisticsView extends Vue {
ExportToSVG(rendererSVG, "test.svg");
}
public setWorldProperty(propertyName, propertyValue) {
public setWorldProperty(propertyName: string, propertyValue: number) {
this.worldProperties[propertyName] = propertyValue;
}
}
function ExportToSVG(rendererSVG, filename) {
function ExportToSVG(rendererSVG: RendererSVGType, filename: string) {
const XMLS = new XMLSerializer();
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
const svgData = svgfile;

165
src/views/TestView.vue Normal file
View File

@@ -0,0 +1,165 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- 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>

View File

@@ -0,0 +1,65 @@
/* eslint-env serviceworker */
/* global workbox */
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
);
self.addEventListener("install", (event) => {
console.error(event);
importScripts(
"safari-notifications.js",
"nacl.js",
"noble-curves.js",
"noble-hashes.js",
);
});
self.addEventListener("push", function (event) {
event.waitUntil(
(async () => {
try {
let payload;
if (event.data) {
payload = JSON.parse(event.data.text());
}
const message = await self.getNotificationCount();
console.error(message);
const title = payload ? payload.title : "Custom Title";
const options = {
body: message,
icon: payload ? payload.icon : "icon.png",
badge: payload ? payload.badge : "badge.png",
};
await self.registration.showNotification(title, options);
} catch (error) {
console.error("Error in processing the push event:", error);
}
})(),
);
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SEND_LOCAL_DATA") {
self.secret = event.data.data;
event.ports[0].postMessage({ success: true });
}
});
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim());
console.log("Service worker activated", event);
});
self.addEventListener("fetch", (event) => {
console.log(event.request);
});
self.addEventListener("error", (event) => {
console.error("Error in Service Worker:", 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

File diff suppressed because it is too large Load Diff

5248
sw_scripts/noble-curves.js Normal file

File diff suppressed because it is too large Load Diff

3068
sw_scripts/noble-hashes.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
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("Record not found");
}
transaction.oncomplete = () => db.close();
} catch (error) {
console.error("Database error: " + error.message);
}
}
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);
});
}
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) => {
let openRequest = indexedDB.open("TimeSafariAccounts");
openRequest.onupgradeneeded = function (event) {
let db = event.target.result;
if (!db.objectStoreNames.contains("accounts")) {
db.createObjectStore("accounts", { keyPath: "id" });
}
};
openRequest.onsuccess = function (event) {
let db = event.target.result;
let transaction = db.transaction("accounts", "readonly");
let objectStore = transaction.objectStore("accounts");
let 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 secret = null;
let accounts = [];
let result = null;
if ("secret" in self) {
secret = self.secret;
const secretUint8Array = self.decodeBase64(secret);
const settings = await getSettingById(1);
let lastNotifiedClaimId = null;
if ("lastNotifiedClaimId" in settings) {
lastNotifiedClaimId = settings["lastNotifiedClaimId"];
}
const activeDid = settings["activeDid"];
accounts = await fetchAllAccounts();
let did = null;
for (var i = 0; i < accounts.length; i++) {
let account = accounts[i];
let did = account["did"];
if (did == activeDid) {
let publicKeyHex = account["publicKeyHex"];
let identity = account["identity"];
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));
const headers = {
"Content-Type": "application/json",
};
headers["Authorization"] = "Bearer " + (await accessToken(identifier));
let response = await fetch(
"https://test-api.endorser.ch/api/v2/report/claims",
{
method: "GET",
headers: headers,
},
);
if (response.status == 200) {
let json = await response.json();
let claims = json["data"];
let newClaims = 0;
for (var i = 0; i < claims.length; i++) {
let claim = claims[i];
if (claim["id"] === lastNotifiedClaimId) {
break;
}
newClaims++;
}
if (newClaims === 0) {
result = "You have no new claims today.";
} else {
result = `${newClaims} have been shared with you`;
}
const most_recent_notified = claims[0]["id"];
await setMostRecentNotified(most_recent_notified);
} else {
console.error(response.status);
}
break;
}
}
}
return result;
}
self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64;

View File

@@ -1,41 +1,47 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env"
],
"paths": {
"@/*": [
"src/*"
]
"compilerOptions": {
"allowJs": true,
"resolveJsonModule": true,
"target": "esnext",
"module": "esnext",
"strict": true,
"strictPropertyInitialization": false,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": "./src",
"types": [
"webpack-env"
],
"paths": {
"@/components/*": ["components/*"],
"@/views/*": ["views/*"],
"@/db/*": ["db/*"],
"@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"],
"@/store/*": ["store/*"],
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -11,5 +11,9 @@ module.exports = defineConfig({
iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg",
},
workboxPluginMode: "InjectManifest",
workboxOptions: {
swSrc: "./sw_scripts/additional-scripts.js",
},
},
});

392
web-push.md Normal file
View File

@@ -0,0 +1,392 @@
# Overivew of Web Push
Web Push notifications is a web browser messaging protocol defined by the W3C.
Discussions of this interesting technology are clouded because of a
terminological morass.
To understand how Web Push operates, we need to observe that are three (and
potentially four) parties involved. These are:
1) The user's web browser. Let's call that BROWSER
2) The Web Push Service Provider which is operated by the organization
controlling the web browser's source code. Here named PROVIDER. An example of a
PROVIDER is FCM (Firebase Cloud Messaging) which is owned by Google.
3) The Web Application that a user is visiting from their web browser. Let's
call this the SERVICE (short for Web Push application service)
4) A Custom Web Push Intermediary Service, either third party or self-hosted.
Called INTERMEDIARY here. FCM also may fit in this category if the SERVICE
has an API key from FCM.]
The workflow works like this:
BROWSER visits a website which hosts a SERVICE.
The SERVICE asks BROWSER for its permission to subscribe to messages coming
from the SERVICE.
The SERVICE will provide context and obtain explicit permission before prompting
for notification permission:
In order to provide this context and explicit permission, a two-step opt-in process
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
possibility of users clicking "don't allow".
Now, to explain what happens in Typescript, we can activate a browser's
permission dialogue in this manner:
```
function askPermission(): Promise<NotificationPermission> {
return new Promise(function(resolve, reject) {
const permissionResult = Notification.requestPermission(function(result) {
resolve(result);
});
if (permissionResult) {
permissionResult.then(resolve, reject);
}
}).then(function(permissionResult) {
if (permissionResult !== 'granted') {
throw new Error("We weren't granted permission.");
}
return permissionResult;
});
}
```
The Notification.permission property indicates the permission level for the
current session and returns one of the following string values:
'granted': The user has granted permission for notifications.
'denied': The user has denied permission for notifications.
'default': The user has not made a choice yet.
Once the user has granted permission, the client application registers a service
worker using the `ServiceWorkerRegistration` API.
The `ServiceWorkerRegistration` API is accessible via the browser's `navigator`
object and the `navigator.serviceWorker` child object and ultimately directly
accessible via the navigator.serviceWorker.register method which also creates
the service worker or the navigator.serviceWorker.getRegistration method.
Once you have a `ServiceWorkerRegistration` object, that object will provide a
child object named `pushManager` through which subscription and management of
subscriptions may be done.
Let's go through the `register` method first:
```
navigator.serviceWorker.register('sw.js', { scope: '/' })
.then(function(registration) {
console.log('Service worker registered successfully:', registration);
})
.catch(function(error) {
console.log('Service worker registration failed:', error);
});
```
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
means of communicating between itself and the web page via messages.
Note that there is a scope that can specify what network requests it may
intercept.
The Vue project already has its own service worker but it is possible to
create multiple service worker files by registering them on different scopes.
It is useful architecturally to specify a separate server worker file.
In the case of web push, the path of the scope only has reference to the domain
of the service worker and no relationship to the pathing for the web push
server. In order to specify more than one server workers each needs to be on
different scope paths!
Here's a version which can be used for testing locally. Note there can be
caching issues in your browser! Incognito is highly recommended.
sw-dev.ts
```
self.addEventListener('push', function(event: PushEvent) {
console.log('Received a push message', event);
const title = 'Push message';
const body = 'The message body';
const icon = '/images/icon-192x192.png';
const tag = 'simple-push-demo-notification-tag';
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag
})
);
});
```
vue.config.js
```
module.exports = {
pwa: {
workboxOptions: {
importScripts: ['sw-dev.ts']
}
}
}
```
Once we have the service worker registered and the ServiceWorkerRegistration is
returned, we then have access to a `pushManager` property object. This property
allows us to continue with the web push work flow.
In the next step, BROWSER requests a data structure from SERVICE called a VAPID
(Voluntary Application Server Identification) which is the public key from a
key-pair.
The VAPID is a specification used to identify the application server (i.e. the
SERVICE server) that is sending push messages through a push PROVIDER. It's an
authentication mechanism that allows the server to demonstrate its identity to
the push PROVIDER, by use of a public and private key pair. These keys are used
by the SERVICE in encrypting messages being sent to the BROWSER, as well as
being used by the BROWSER in decrypting the messages coming from the SERVICE.
The VAPID (Voluntary Application Server Identification) key provides more
security and authenticity for web push notifications in the following ways:
Identifying the Application Server:
The VAPID key is used to identify the application server that is sending
the push notifications. This ensures that the push notifications are
authentic and not sent by a malicious third party.
Encrypting the Messages:
The VAPID key is used to sign the push notifications sent by the
application server, ensuring that they are not tampered with during
transmission. This provides an additional layer of security and
authenticity for the push notifications.
Adding Contact Information:
The VAPID key allows a web application to add contact information to
the push messages sent to the browser push service. This enables the
push service to contact the application server in case of need or
provide additional debug information about the push messages.
Improving Delivery Rates:
Using the VAPID key can help improve the overall performance of web push
notifications, specifically improving delivery rates. By streamlining the
delivery process, the chance of delivery errors along the way is lessened.
If the BROWSER accepts and grants permission to subscribe to receiving from the
SERVICE Web Push messages, then the BROWSER makes a subscription request to
PROVIDER which creates and stores a special URL for that BROWSER.
Here's a bit of code describing the above process:
```
// b64 is the VAPID
b64 = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
const applicationServerKey = urlBase64ToUint8Array(b64);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey
};
registration.pushManager.subscribe(options)
.then(function(subscription) {
console.log('Push subscription successful:', subscription);
})
.catch(function(error) {
console.error('Push subscription failed:', error);
});
```
In this example, the `applicationServerKey` variable contains the VAPID public
key, which is converted to a `Uint8Array` using a function such as this:
```
export function toUint8Array(base64String: string, atobFn: typeof atob): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atobFn(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
```
The options object is of type `PushSubscriptionOptions`, which includes the
`userVisibleOnly` and `applicationServerKey` (ie VAPID public key) properties.
options: An object that contains the options used for creating the
subscription. This object itself has the following sub-properties:
applicationServerKey: A public key your push service uses for application
server identification. This is normally a Uint8Array.
userVisibleOnly: A boolean value indicating that the push messages that
are sent should be made visible to the user through a notification.
This is often set to true.
The subscribe() method returns a `Promise` that resolves to a `PushSubscription`
object containing details of the subscription, such as the endpoint URL and the
public key. The returned data would have a form like this:
{
"endpoint": "https://some.pushservice.com/some/unique/identifier",
"expirationTime": null,
"keys": {
"p256dh": "some_base64_encoded_string",
"auth": "some_other_base64_encoded_string"
}
}
endpoint: A string representing the endpoint URL for the push service. This
URL is essentially the push service address to which the push message would
be sent for this particular subscription.
expirationTime: A DOMHighResTimeStamp (which is basically a number or null)
representing the subscription's expiration time in milliseconds since
01 January, 1970 UTC. This can be null if the subscription never expires.
The BROWSER will, internally, then use that URL to check for incoming messages
by way of the service worker we described earlier. The BROWSER also sends this
URL back to SERVICE which will use that URL to send messages to the BROWSER via
the PROVIDER.
Ultimately, the actual internal process of receiving messages varies from BROWSER
to BROWSER. Approaches vary from long-polling HTTP connections to WebSockets. A
lot of handwaving and voodoo magic. The bottom line is that the BROWSER itself
manages the connection to the PROVIDER whilst the SERVICE must send messages
via the PROVIDER so that they reach the BROWSER service worker.
Just to remind us that in our service worker our code for receiving messages
will look something like this:
```
self.addEventListener('push', function(event: PushEvent) {
console.log('Received a push message', event);
const title = 'Push message';
const body = 'The message body';
const icon = '/images/icon-192x192.png';
const tag = 'simple-push-demo-notification-tag';
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag
})
);
});
```
Now to address the issue of receiving notification messages on mobile devices.
It should be noted that Web Push messages are only received when BROWSER is
open, except in the cases of Chrome and Firefox mobile BROWSERS. In iOS, the
mobile application (in our case a PWA) must be added to the Home Screen and
permissions must be explicitly granted that allow the application to receive
push notifications. Further, with an iOS device the user must enable wake on
notification to have their device light-up when it receives a notification
(https://support.apple.com/enus/HT208081).
So what about #4? - The INTERMEDIARY. Well, It is possible under very special
circumstances to create your own Web Push PROVIDER. The only case I've found so
far relates to making an Android Custom ROM. (An Android Custom ROM is a
customized version of the Android Operating System.) There are open source
IMTERMEDIARY products such as UnifiedPush (https://unifiedpush.org/) which can
fulfill this role. If you are using iOS you are not permitted to make or use
your own custom Web Push PROVIDER. Apple will never allow anyone to do that.
Apple has none of its own.
It is, however, possible to have a sort of proxy working between your SERVICE
and FCM (or iOS). Services that mash up various Push notification services (like
OneSignal) can perform in the role of such proxies.
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
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.