Compare commits

...

371 Commits

Author SHA1 Message Date
d4bf045049 Merge branch 'master' into passkey 2024-06-24 20:20:13 -06:00
1639e7cf25 move & resize the contact edit & info buttons 2024-06-22 12:34:30 -06:00
8f2bebe8ae bump version and add "-beta" 2024-06-22 12:23:57 -06:00
810f307442 bump to v 0.3.14 2024-06-22 12:23:10 -06:00
a4bdd2e922 fix checkbox verbiage when no project is chosen for a give 2024-06-22 12:06:55 -06:00
08e1ce6486 fix prompt for already-registered contacts (plus some verbiage) 2024-06-22 11:47:10 -06:00
e88eea7f36 add BX currency, add link for user's activity, tweak verbiage 2024-06-21 20:33:44 -06:00
ea156fac13 improve messaging when user has no offers or projects 2024-06-21 19:52:35 -06:00
a95d5db24a fix justification of checkboxes and text so they don't move 2024-06-21 19:25:46 -06:00
453256f874 give-detail page: add more-correct parameters from confirm-give page, and allow toggling of project & user-recipient 2024-06-21 19:13:19 -06:00
7bf488d4fe tweak UI for give-confirmation screen 2024-06-21 16:02:08 -06:00
230773a917 add Confirm Gift screen for simpler confirmation 2024-06-20 20:52:26 -06:00
8a64da2b5f modify test payload, add test to help with server verification 2024-06-16 19:42:55 -06:00
cf1137737a allow switching to did:peer ID, remove another unnecessary data point 2024-06-16 10:12:56 -06:00
82f51b6f93 save passkey DID in accounts, consolidate more data 2024-06-16 09:47:51 -06:00
83722e0057 remove duplicate data elements, including public key which is in peer DID 2024-06-15 22:31:21 -06:00
3bb2498e28 refactor, and make separate testing buttons for different approaches 2024-06-15 15:45:29 -06:00
80f05ba9e9 add signing by non-simplewebauthn code 2024-06-14 21:19:03 -06:00
bb555cd6ee finally get something other that simplewebauthn working 2024-06-12 20:26:26 -06:00
1dd7c6e3b1 fully split the signature creation from the verification 2024-06-09 19:04:43 -06:00
e8423b1a00 register with simple-lib browser (works until jwt signature validation) 2024-06-09 06:09:30 -06:00
b4a521c6d4 remove lingering pieces of tests from /start 2024-06-08 15:31:01 -06:00
a64c7c2848 move all passkey testing to /test 2024-06-08 15:29:16 -06:00
37907ee3ad attempt to simply verify something signed with the same library -- doesn't work 2024-06-08 14:19:42 -06:00
43da8586e5 use code from @simplewebauthn/server in hopes of more accurate parsing 2024-05-26 22:17:29 -06:00
94443c93bc gets past code errors and now gotta verify signature (note that had to add Buffer in asn1 lib) 2024-05-26 16:59:58 -06:00
2a675eca6a derive DID for issuer 2024-05-25 18:51:18 -06:00
94fb76cfdc retrieve the correct passkey just created (doesn't validate JWT) 2024-05-24 20:16:34 -06:00
7dde4d4d30 add prompt for a passkey and a start at creating a JWT out of it 2024-05-24 16:30:33 -06:00
79d93994c2 fix dependency vulnerabilities 2024-05-24 11:42:36 -06:00
bab4a62540 bump version and add -beta; enhance help 2024-05-24 10:21:08 -06:00
f84a2c2750 bump to verson 0.3.13 2024-05-24 10:19:17 -06:00
2321e1d6e8 allow link to the large version of a project image 2024-05-24 09:11:20 -06:00
af976ba838 add an image to projects (which shows on all ProjectIcons except for offers) 2024-05-23 20:51:40 -06:00
d08541fdae bump version and add -beta 2024-05-20 08:27:12 -06:00
fa92beed27 update CHANGELOG 2024-05-19 20:03:30 -06:00
9e1ae2abe5 bump to version 0.3.12 2024-05-19 19:57:02 -06:00
ad39ea05c2 fix the photo share_target, and tweak other verbiage 2024-05-19 19:56:25 -06:00
151c8154c4 bump version and add -beta 2024-05-19 19:32:38 -06:00
21a6348afc add a global error handler 2024-05-19 16:25:44 -06:00
210605c8e4 bump to version 0.3.11 (and enhance warning on profile deletion) 2024-05-19 08:39:18 -06:00
33a340326f set the correct active camera number when it starts 2024-05-17 20:24:33 -06:00
3f8596aacc bump version and add -beta 2024-05-17 12:16:23 -06:00
fd112bd447 allow any image URL for gifts & profiles 2024-05-12 21:43:18 -06:00
7d6b210ee1 allow file choice for gift, plus other UI fixes 2024-05-12 17:55:54 -06:00
6c28828c0a fix cropping problem where long images go off the screen 2024-05-12 12:39:16 -06:00
6af239378c bump to v 0.3.10, fix image upload on Chrome 2024-05-12 12:12:59 -06:00
4ff7d908d4 Merge pull request 'add a share_target for people to add a photo' (#115) from share-photo into master
Reviewed-on: #115
2024-05-11 20:03:33 -04:00
17c901b1de add file-chooser to the profile image selection 2024-05-11 12:30:10 -06:00
f7b5dbf4ce style the sharing screen (plus other fixes) 2024-05-11 07:09:48 -06:00
7f02ba29a3 add a share_target for people to add a photo 2024-05-10 13:17:20 -06:00
20c4613533 increment version and add "-beta" 2024-04-28 20:10:39 -06:00
a44fc1d6d0 bump to version 0.3.9 2024-04-28 20:09:56 -06:00
b86543b404 disallow new-project page if not registered 2024-04-28 19:16:29 -06:00
7d0007e4d9 remove verbiage on front page that's now extra 2024-04-28 19:04:44 -06:00
ddd32e7f44 show something to indicate claims were sent (mostly in BVC screens) 2024-04-28 18:36:06 -06:00
8a9bb100ea constantly recheck on home screen if not registered 2024-04-28 17:02:31 -06:00
c48b8246f9 add registration inside contact import, with flag to hide it 2024-04-28 16:18:30 -06:00
b32a3d85e9 add 'registered' flag in contact info 2024-04-28 13:12:26 -06:00
8571c78a53 for scan on QR code screen, import and keep on that screen 2024-04-27 20:33:10 -06:00
eba68e2aaa add tweaks to testing instructions 2024-04-27 14:59:23 -06:00
e2df848e96 add page to view all claims about a DID (which we'll have to restrict to visible people soon) 2024-04-26 20:13:44 -06:00
9acba28b85 fix problem with duplicates in feed, plus some other UI tweaks 2024-04-26 17:05:11 -06:00
bef56fce10 allow loading more gives & offers & plans when limits are hit on project view 2024-04-26 15:44:09 -06:00
fccc4edb63 remove some 'uppercase' CSS markers 2024-04-25 20:17:49 -06:00
0a42edf595 put button directly on contacts page to show the given totals 2024-04-24 20:38:34 -06:00
f4f5fc7730 change remainder of "confirm" calls to better UX 2024-04-24 20:11:38 -06:00
eeaacaf202 replace many of the javascript "confirm" calls with the nicer UX version 2024-04-24 19:52:33 -06:00
d9aebfebd3 remove 'moment' library that's no longer used 2024-04-24 18:56:09 -06:00
7078f7b9e6 add choice of a start date for a project 2024-04-23 20:48:38 -06:00
d316f4924b add note about confirming your own, plus other helpful verbiage, plus notify messages that don't linger 2024-04-23 09:13:57 -06:00
1df2d3ed05 remove message confusion, add project name during give-details 2024-04-21 20:31:57 -06:00
4e877c15f6 change the "give" action on contact page to use dialog box 2024-04-21 16:42:22 -06:00
ef95708d02 add 'offer' on contact screen 2024-04-21 07:38:59 -06:00
7cbdc7a099 add code to display profiles in feed, but deactivate it for now 2024-04-20 19:53:11 -06:00
c748869c44 increment version and add "-beta" 2024-04-20 08:14:53 -06:00
60e11e23d4 bump to v 0.3.8 2024-04-20 08:06:34 -06:00
883687f1c3 make so cropping isn't behind header; delete profile image from storage when deleted 2024-04-19 20:13:44 -06:00
4466ceed99 Merge pull request 'profile-pic' (#114) from profile-pic into master
Reviewed-on: #114
2024-04-19 17:36:53 -04:00
6d6e5266b4 make the home screen elements load more quickly 2024-04-19 15:37:10 -06:00
581a374b05 show contact's or user's icon in more places 2024-04-19 11:39:01 -06:00
1009574721 crop the image and store online and in settings 2024-04-18 20:27:43 -06:00
50cae65214 add photo to profile page (not yet saved) 2024-04-17 20:07:09 -06:00
48a46cf6f1 fix contact sorting to show those without names 2024-04-17 19:29:17 -06:00
60b2bf35fb update ClickUp link to a public link 2024-04-17 11:05:34 -06:00
cb5a7135ac remove tasks here in favor of ClickUp 2024-04-16 20:13:04 -06:00
a7a9e35766 note that tasks have moved 2024-04-11 20:43:52 -06:00
f029835e15 bump version and add "-beta" 2024-04-10 19:40:16 -06:00
017a172df3 bump to v 0.3.7 2024-04-10 19:32:46 -06:00
7837122a95 open the app when notification is clicked 2024-04-10 19:31:14 -06:00
0093255246 fix PWA creation & service-worker registration, plus some commentary tweaks 2024-04-09 20:29:21 -06:00
30bd53fb6f remove non-working interests, enhance error messages, update tasks & changelog 2024-04-09 17:54:17 -06:00
ca22930012 Merge pull request 'vitejs refactor' (#110) from jsnbuchanan/crowd-funder-for-time-pwa:feat/vitejs into master
Reviewed-on: #110
2024-04-09 19:49:48 -04:00
c7c5bda014 Merge pull request 'misc tweaks for new vite build' (#4) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent3 into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#4
2024-04-09 06:05:55 -04:00
19aa572c95 misc tweaks for new vite build 2024-04-07 18:12:33 -06:00
03fae5dd95 Merge pull request 'A couple small fixes, plus a merge from master' (#1) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#1
2024-04-07 13:52:43 -04:00
80818a8861 remove a lingering debug console.log 2024-04-07 11:39:13 -06:00
d29a8d9637 fix title of the test app 2024-04-07 11:32:53 -06:00
f0b0231515 add linting before any build 2024-04-07 11:22:20 -06:00
b73d2a3b58 fix linting 2024-04-07 11:02:54 -06:00
22cba5babf Merge remote-tracking branch 'original-origin/master' into feat/vitejs-trent 2024-04-07 09:41:14 -06:00
708ac51f23 avoid a huge error message in a likely-well-known scenario 2024-04-07 09:24:55 -06:00
a91ffc88b9 reorder home page vapid check to avoid an error on localhost 2024-04-07 09:16:42 -06:00
d727c2841b add missing Dexie import (which causes failure upon download click) 2024-04-07 09:13:32 -06:00
226a97732d on home page, change the filtered button color 2024-04-06 17:58:10 -06:00
c94dd7743b Merge pull request 'ui-additions-2024-03' (#113) from ui-additions-2024-03 into master
Reviewed-on: #113
2024-04-06 19:46:16 -04:00
64e38cb8ff Merge branch 'master' into ui-additions-2024-03 2024-04-06 17:45:32 -06:00
e61ac31710 show in description when recipient is a project (not just Anonymous) 2024-04-06 17:39:40 -06:00
3fbf68b117 filter by selections (now all working), add cache for plans 2024-04-06 14:01:18 -06:00
d4390483d9 Merge pull request 'send a time for notifications to the push server' (#112) from notify-time into master
Reviewed-on: #112
2024-04-03 22:04:36 -04:00
8dea2091af Merge pull request 'ui-fixes-2024-03' (#111) from ui-fixes-2024-03 into master
Reviewed-on: #111
2024-04-03 22:04:02 -04:00
e3696e3ac5 feed filter: save the changed values to the DB, go to map if no location chosen, reload if necessary 2024-04-03 19:54:01 -06:00
Jose Olarte III
027825b155 Names and variables for filter toggles 2024-04-03 15:51:23 +08:00
911203c190 adjust more code to the PushSubscriptionJSON 2024-04-02 19:32:41 -06:00
2da0394003 adjust the notification-subscription objects to try and send correct info 2024-04-02 19:18:31 -06:00
4a65d095db add adjustment to UTC hour for notification time 2024-04-02 18:38:44 -06:00
8ea5779312 update tasks 2024-04-01 19:06:18 -06:00
144ab76716 add logic to send a time for notifications 2024-04-01 19:04:54 -06:00
Jose Olarte III
8da2c8cc30 Additions to Account View 2024-03-29 21:41:14 +08:00
Jose Olarte III
570b31e2d6 Removed one more 2024-03-29 15:55:16 +08:00
Jose Olarte III
07f542ca16 Filter options reduced for release 2024-03-29 15:53:46 +08:00
Jose Olarte III
62e0fc51c2 Feed filters dialog 2024-03-27 19:57:31 +08:00
Jose Olarte III
94b600e527 Map fix #2 2024-03-26 21:38:21 +08:00
Jose Olarte III
5388e6052c Button width changes
For buttons that are next to each other
2024-03-26 19:55:16 +08:00
Jose Olarte III
21fe5a0279 Optimized grid space for wider screens 2024-03-26 17:12:55 +08:00
Jose Olarte III
ffba89a7b5 Fixed map z-index 2024-03-26 16:54:43 +08:00
Jose Olarte III
31954d2690 Added close icon to gifted prompts dialog 2024-03-26 16:02:24 +08:00
340d0a5219 refactor tasks 2024-03-25 19:03:01 -06:00
2d2785d6a0 docs: adding do for updated development server run command
- `npm run dev`
2024-03-25 08:15:04 -06:00
41d6e5fc73 fix: buffer typescript error in util.ts when parsing ArrayBuffer 2024-03-25 08:10:38 -06:00
7412d67c33 bump version and add -beta 2024-03-24 19:04:24 -06:00
83db5302ad bump to version 0.3.6 2024-03-24 18:28:42 -06:00
75f9f20ea3 fix check for more camera-device options 2024-03-24 18:27:06 -06:00
e43c45ebea add onboarding help instructions as separate page 2024-03-24 17:01:53 -06:00
708032311a Merge pull request 'add button during photo to switch to mirror mode' (#109) from photo-reverse into master
Reviewed-on: #109
2024-03-24 18:59:50 -04:00
5dead960ae fix: es modules syntax for buffer deps instead of commonjs require 2024-03-24 13:05:22 -06:00
12d81b79c7 chore: update vitejs config to deploy on the same default port as the @vue/cli-service
This port is 8080. This is done to not break existing tooling and devops code.
2024-03-24 12:05:09 -06:00
f3dc81e6eb fix: AccountViewView.vue template not resolving dep for dexie-export-import/dist/import
Previous error:

Error: The following dependencies are imported but could not be resolved:

  dexie-export-import/dist/import (imported by /Users/jason/dev/src/trent/crowd-funder-for-time-pwa/src/views/AccountViewView.vue?id=0)

Are they installed?
    at file:///Users/jason/dev/src/trent/crowd-funder-for-time-pwa/node_modules/vite/dist/node/chunks/dep-DJaaTb_D.js:52506:23
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async file:///Users/jason/dev/src/trent/crowd-funder-for-time-pwa/node_modules/vite/dist/node/chunks/dep-DJaaTb_D.js:51972:38
2024-03-24 11:51:58 -06:00
ef5f81932d Initial stab at vitejs update 2024-03-24 11:18:29 -06:00
214a264179 change icon for detail view (from circle-info to file-lines) 2024-03-23 19:07:53 -06:00
9b183a4b6c add blurb explaining what data is shared with the world 2024-03-23 18:45:26 -06:00
f365cc9e3c show warnings before dismissing prompt, and add to tasks and help 2024-03-23 17:35:58 -06:00
9059f7a9a7 add button on photo to switch to mirror mode 2024-03-23 16:31:23 -06:00
e6cd86618e bump version to 0.3.5 2024-03-23 02:41:25 -06:00
c3fd27b140 fix so that project agent & location removals get saved 2024-03-23 02:31:44 -06:00
cf2e800dec add a camera-switch button 2024-03-23 01:32:55 -06:00
b60383cfe9 Merge pull request 'fix: npm audit fix to resolve vulnerabilities 1 low, 3 moderate, 1 high' (#108) from jsnbuchanan/crowd-funder-for-time-pwa:master into master
Reviewed-on: #108
2024-03-22 12:01:21 -04:00
c7d93db6f2 deps: npm audit fix to resolve vulnerabilities 1 low, 3 moderate, 1 high
There are still 9 moderate severity vulnerabilities, but I will work on those independentally because they may involve updating to library version that have breaking changes.
2024-03-22 09:29:42 -06:00
5e771e4a24 refactor tasks 2024-03-22 07:12:24 -06:00
4dd2c044d5 bump to v 0.3.4 2024-03-21 06:56:35 -06:00
3bfd54362e Merge pull request 'Sample button visual enhancement' (#104) from button-visual-enhancement into master
Reviewed-on: #104
2024-03-21 07:57:18 -04:00
Jose Olarte III
b6e344a15e Propagated button improvements across views 2024-03-21 19:30:42 +08:00
Jose Olarte III
3d1c46aef8 Merge branch 'master' into button-visual-enhancement 2024-03-21 15:40:54 +08:00
ce05f7d003 Merge pull request 'tweak imagery so that it doesn't get stretched on a mobile device' (#107) from photo-ratio into master
Reviewed-on: #107
2024-03-20 20:26:35 -04:00
313cd79e60 finalize the photo-taking code, adding comments and removing logging 2024-03-20 18:23:57 -06:00
121991b53a Merge branch 'gifted-camera-improvements' into photo-ratio 2024-03-20 16:54:39 -06:00
Jose Olarte III
cbf8cb9f46 Fixed placement of upload/retry buttons 2024-03-20 21:11:01 +08:00
Jose Olarte III
fe0668e4b3 Improved Camera Popup 2024-03-20 19:17:18 +08:00
a230506d96 change the X and picture button so that landscape is all functional (if not great-looking) 2024-03-19 21:02:57 -06:00
c49c55d394 change the photo ratios to fix all but portrait-orientation on mobile-emulation 2024-03-18 22:20:02 -06:00
ae572afff6 add help for when service workers get stuck; bump to version 0.3.2 2024-03-17 20:20:13 -06:00
ccea2486e4 change build for test servers, bump version to 0.3.1 2024-03-17 16:42:49 -06:00
155343a9d7 bump to v 0.3.0 2024-03-17 10:49:28 -06:00
85ad295eb9 Merge pull request 'photo-upload' (#105) from photo-upload into master
Reviewed-on: #105
2024-03-17 12:23:32 -04:00
64322b2804 change the default image server port 2024-03-17 10:22:05 -06:00
3e556dfa52 move the "part of project" text in giving-details screen 2024-03-17 08:53:14 -06:00
252952e017 show the image rate limits 2024-03-15 16:42:25 -06:00
251986d2bc make the photo show in a pop-up dialog 2024-03-14 19:44:11 -06:00
49bb1c07b7 fix file extension 2024-03-14 08:46:28 -06:00
67f34f9826 on "give details" page, distinguish between project & user destination 2024-03-10 17:49:53 -06:00
476d35452a send the claim type with an image 2024-03-10 17:37:10 -06:00
26582030df add another check when deleting an image 2024-03-10 17:36:49 -06:00
ae857f4c8f guard against another set of errors when deleting an image 2024-03-10 16:46:54 -06:00
c602c5ce50 add some other image deletions in other cases 2024-03-10 14:53:41 -06:00
e4543457e2 add image onto give claim, then display on feel (full round-trip, baby!) 2024-03-09 19:37:14 -07:00
c58f012d2c allow viewing and deletion of an image 2024-03-08 23:56:19 -07:00
792e9cb648 separate picture taking from uploading 2024-03-08 09:54:10 -07:00
acee761906 add page for extended details of gifts including pic (not fully tested) 2024-03-08 01:10:17 -07:00
cae2bbc4ff make styled button to take picture 2024-03-07 09:09:51 -07:00
Jose Olarte III
a5c3600673 Sample button visual enhancement 2024-03-07 18:57:08 +08:00
0eb64ed716 add authentication token for image server, change default image server to localhost 2024-03-06 06:12:41 -07:00
f1bb1b51aa Merge branch 'master' into photo-upload 2024-03-05 20:25:41 -07:00
92b924643e fix camera resolution, parameterize image API server 2024-03-05 20:20:54 -07:00
ca90447700 fix different "environment" variables for prod & dev 2024-03-02 16:15:03 -07:00
750700e75e bump version and add '-beta' 2024-03-01 15:59:38 -07:00
3612ea4224 bump to v 0.2.17; add "personalized" message and better confirmation-result messages 2024-03-01 15:54:50 -07:00
dbccbf7e4a fix: show on the confirmation page when there are hidden claims 2024-03-01 14:40:51 -07:00
1258cf02a1 bump to v 0.2.15 2024-03-01 14:06:01 -07:00
a488a36bc0 Merge pull request 'Shortcut page for BVC assertions & confirmations' (#103) from bvc-shortcut into master
Reviewed-on: #103
2024-03-01 15:14:01 -05:00
a93b556e0c doc: refactor tasks 2024-02-26 19:43:23 -07:00
2c28913d97 for BVC: finish submission of confirmations & final give 2024-02-26 19:27:34 -07:00
0b24d7bbd8 for BVC: fix the attendee & show appropriate success message 2024-02-25 18:55:58 -07:00
2058205150 for BVC shortcut: send attend & give actions, and list actions to confirm 2024-02-25 18:38:54 -07:00
866dcb3a2a add screens for the shortcuts for the BVC group (doesn't submit yet) 2024-02-24 18:38:11 -07:00
6aab1ff49d consolidate interface and remove copies of code 2024-02-24 10:26:12 -07:00
c696de33f3 add page to take a picture and upload to an image server 2024-02-23 19:02:10 -07:00
c239db6a4f doc: update tasks 2024-02-19 19:44:59 -07:00
3eda5f6b5d show more succinct info in feed, targeted toward user's visibility 2024-02-19 19:43:55 -07:00
783b38df65 order contacts by name & note outside network as "outside your network" 2024-02-18 14:58:10 -07:00
3475c32e1f update onboarding hint message, justify text on QR page 2024-02-17 12:55:30 -07:00
dcd881adae make the name-setting prompt yellow 2024-02-17 12:46:17 -07:00
37690cc855 increment versiona and add "-beta" 2024-02-14 20:56:37 -07:00
5f9edea116 bump version to 0.2.14 2024-02-14 20:50:15 -07:00
f517b09ed7 combine all service-worker scripts into a single file to try and ensure included scripts aren't lost 2024-02-14 20:46:34 -07:00
ca70b19831 fix claim-view page when the claim argument is not a global ID 2024-02-12 20:10:18 -07:00
f41e541fe2 send the last JWT instead of the identifier for plan edits 2024-02-11 16:05:15 -07:00
5c547783a7 remove unused page; tweak task list 2024-02-11 07:14:16 -07:00
8d2dd6357a update readme 2024-02-09 09:08:26 -07:00
189261e991 update messaging for contact registration 2024-02-07 18:57:58 -07:00
15464602f9 bump to version 0.2.14-beta 2024-02-07 18:42:33 -07:00
331c4f64d6 add check for valid "did:" DIDs 2024-02-07 18:23:13 -07:00
28ae317958 refactor tasks & add more estimates 2024-02-05 09:03:55 -07:00
643718619e remove unnecessary logic in account switcher; refactor task list 2024-02-04 20:11:04 -07:00
c3819ec919 don't autocapitalize website input; refactor tasks 2024-02-03 19:33:52 -07:00
719e3a467d make a number input targeted towards numbers 2024-02-03 19:21:07 -07:00
b251d7e4fd change project icon to a hammer 2024-02-03 19:20:54 -07:00
61c3a0e30b avoid error on browsers without a service worker 2024-02-03 19:19:58 -07:00
a76df55224 add display of my own offers 2024-02-03 18:56:09 -07:00
e140da081f fix name derivation on give dialog 2024-02-03 18:10:46 -07:00
1be899c48d ensure error message shows, and unset register flag if there's an API error 2024-02-02 17:40:06 -07:00
6aee93ca6c update tasks; enhance an error message & some typescripts 2024-02-02 12:25:04 -07:00
5412625d05 increment version and add -beta; tweak tasks & tests 2024-02-02 10:22:51 -07:00
8f579b40a9 bump to verson 0.2.12 2024-02-01 12:12:13 -07:00
e8a907c63a add more thankfulness prompts 2024-02-01 12:09:09 -07:00
f53a6f3045 tweak the prompt for contacts to be able to skip them 2024-02-01 11:52:31 -07:00
b38ebc45e1 add a prompt for things for which to express gratitude 2024-01-31 21:15:40 -07:00
c51d2629b3 bump version and add -beta 2024-01-28 15:20:15 -07:00
e642b99ff5 bump version to 0.2.11 2024-01-28 15:04:12 -07:00
26f1e88f5a doc: update tests & tasks 2024-01-28 15:01:47 -07:00
2e164dfeff tweak messages for missing identifier 2024-01-28 13:21:21 -07:00
d7530ff56b adjust more UI on the Advanced section, and make other small code & UI tweaks 2024-01-27 17:32:17 -07:00
2db52cb72e fix default server display in advanced section & refactor UI 2024-01-27 14:49:51 -07:00
c8eb3bfbc0 move save & cancel buttons further apart 2024-01-27 14:15:14 -07:00
71b210d541 add to manual tests & changelog 2024-01-27 13:05:33 -07:00
66289ec206 update tasks 2024-01-27 13:02:47 -07:00
639dc7b4e5 add instruction to error output 2024-01-27 12:40:40 -07:00
4fe072f19e move DB logic out of 'created' in components since it's not needed yet 2024-01-27 08:27:52 -07:00
f253f0af0f add ability to import from Endorser Mobile CSV 2024-01-26 20:36:29 -07:00
2d95a35905 add date to project give record list; don't wrap icon & amount 2024-01-26 16:10:14 -07:00
88f869d600 lower project "I Gave" button into list of contact, and tweak other wording 2024-01-24 20:46:05 -07:00
a0911bb0fd add copy-paste icon next to non-anonymous, non-hidden DIDs on details page 2024-01-21 15:50:39 -07:00
1053b78ab8 add sharing & copying instructions when asking contacts for help, and list all the visibleTo DIDs with an English description of their path 2024-01-21 15:16:39 -07:00
dcfa8d9451 add first stab at showing how the contact is visible in my network 2024-01-20 20:33:51 -07:00
dd38f76ee1 increment version and add -beta; add to tasks and tests 2024-01-18 21:11:19 -07:00
667e1e8890 bump version to 0.2.10 2024-01-18 20:05:44 -07:00
1731f2443b update offer dialog to allow other units 2024-01-17 20:50:35 -07:00
e1cffcda2d fix problem where extended screen of contacts didn't pass project 2024-01-17 20:18:01 -07:00
a5b1b97012 show the identicon in large size on the contacts screen 2024-01-17 19:47:33 -07:00
563b5793a9 add different identicons for people (and increment version & add -beta) 2024-01-17 19:27:05 -07:00
660436c8fa add copy-did-to-clipboard on contact list 2024-01-16 19:58:18 -07:00
31a7752168 add link to project from gives on front page 2024-01-16 19:48:47 -07:00
3ebe7bc156 put didInfo names in more places and add copy icons for DIDs & IDs 2024-01-16 18:58:08 -07:00
0eb16d5661 add links for give & offer when they fulfill other things 2024-01-16 17:52:32 -07:00
edb09da10f add detailed-info button for a project 2024-01-16 15:31:55 -07:00
be6ec6745a show a 'give' button directly on offers in the ProjectView 2024-01-16 15:23:40 -07:00
b79c5fcf91 move info button for offer & add cursor for hover 2024-01-15 19:50:00 -07:00
9dea4066c9 add ability to confirm give directly from a project 2024-01-15 19:40:38 -07:00
9b586566f0 increment version and add "-beta" 2024-01-15 12:37:39 -07:00
e5e702f8a5 bump version to 0.2.9 2024-01-15 12:14:15 -07:00
32c9076c39 fix visibility after adding contact, and some messaging 2024-01-15 12:06:33 -07:00
6ab4c40fd0 bump to version 0.2.8 2024-01-14 21:03:22 -07:00
d7ef07c2e2 automatically create an identity on the first page (and other UI tweaks) 2024-01-14 21:00:59 -07:00
9f595040d8 fix problem with anonymous contributor; refine tasks 2024-01-14 15:27:57 -07:00
40a8794649 remove checks on old fullIri field 2024-01-13 18:48:29 -07:00
fa72d38d18 allow an agent to edit a project 2024-01-13 18:45:51 -07:00
31aacb286f reword prompt for creating an identifier on the start screen 2024-01-13 18:44:59 -07:00
2511f18fa7 enhance (& fix for mobile) styling and verbiage 2024-01-13 15:14:16 -07:00
febfa8b098 bump version to 0.2.7 2024-01-12 20:54:29 -07:00
e0fcb1f67b fix various verbiage 2024-01-12 20:40:46 -07:00
9183092325 fix the name of the offerer 2024-01-12 20:39:30 -07:00
a87179d127 change wording from "identity" to "identifier" in many places 2024-01-12 16:37:02 -07:00
14e203dd74 bump to version 0.2.6 2024-01-12 15:58:08 -07:00
acaaf8776d add ability to give to fulfill an offer; adjust visibility of claim actions 2024-01-12 15:54:45 -07:00
cb1f38c182 bump to verson 0.2.5, and edit tasks 2024-01-09 19:32:18 -07:00
cfa7466b94 show users when there's an error on the import page 2024-01-09 19:18:49 -07:00
f998364c72 update package-lock for previous bump 2024-01-09 18:31:53 -07:00
7b4f084b4b bump to version 0.2.4, update tasks 2024-01-09 18:31:11 -07:00
115329e26c update the onboarding help blurb 2024-01-09 18:30:55 -07:00
61bef57563 update some dependencies with moderate severity 2024-01-09 17:56:27 -07:00
a5368d0f82 update library with vulnerability 2024-01-09 17:54:37 -07:00
48cb45d230 bump version to 0.2.3, add endpoint name update 2024-01-09 17:49:41 -07:00
8a7ce0fe65 add flag for logging a contribution as a trade 2024-01-08 21:28:04 -07:00
525d3fc15a bump to version 0.2.2 2024-01-05 13:54:57 -07:00
68f3b79983 add hints for registration on the contact page 2024-01-05 13:50:35 -07:00
5353fe770a tweak verbiage and usability 2024-01-05 13:08:20 -07:00
60fec5763d bump version to 0.2.1 2024-01-05 12:48:38 -07:00
aeb1d6a6a5 add next-public-key-hash to manual input 2024-01-05 12:44:28 -07:00
ec6175a550 make a confirmation for contact visibility 2024-01-05 12:14:56 -07:00
c1361e088f render full claim details in a more resonable format of YAML not JSON 2024-01-05 11:20:16 -07:00
a2c986951e doc: refactor project tasks 2024-01-05 11:11:06 -07:00
dce7b8e3d9 add terms & conditions, and a note about data in this service 2024-01-05 10:34:13 -07:00
211e0487fe add verbiage for other non-Chrome cases 2024-01-05 09:56:29 -07:00
cc931dcb04 add notification check with instructions on front screen 2024-01-05 09:48:15 -07:00
bfe14cc9c2 increment version and add -beta 2024-01-04 10:40:15 -07:00
275dba4468 bump version to 0.2.0 2024-01-04 10:35:32 -07:00
1f05e81b05 add notification immediately after setting up subscription, and tweak messaging 2024-01-03 21:27:13 -07:00
e9ad68f2a5 add maskable images 2024-01-03 18:32:38 -07:00
934664b9c9 add the hashed-next-key to the contact data, shown & stored 2024-01-03 17:44:41 -07:00
780be59c76 remove name from identity switcher (since they are not tied to a DID) 2024-01-02 19:53:19 -07:00
4a0bedb628 fix one more list-outside indent 2024-01-02 19:27:13 -07:00
5689f95230 change list-inside to list-outside 2024-01-02 19:21:57 -07:00
3083bb084a add more notification help instructions; remove confusing, big name-edit button 2024-01-02 19:16:54 -07:00
821d27a58a Merge pull request 'Set max screen content width' (#102) from app-screen-max-width into master
Reviewed-on: #102
2024-01-02 10:24:09 -05:00
Jose Olarte III
998a1d312f Set max screen content width 2024-01-02 15:25:51 +08:00
1f13bf772c move wait for service-worker initialization into the notification modal 2024-01-01 20:36:23 -07:00
def744b3df don't allow notification service-worker interaction until it is ready 2024-01-01 20:04:37 -07:00
0fb37acb24 increment version and add -beta 2024-01-01 19:38:07 -07:00
20bb723f0b bump to version 0.1.9 2024-01-01 19:34:34 -07:00
d821a7bd59 Merge pull request 'make a backup download for browsers that don't get it automatically' (#101) from another-download into master
Reviewed-on: #101
2024-01-01 21:28:22 -05:00
9f3b7314e8 Merge branch 'master' into another-download 2024-01-01 19:27:52 -07:00
4df7bb58a4 add help for clearing data, plus some other help fixes 2024-01-01 17:09:30 -07:00
15ccd2394f add missing 'date' to log interface 2024-01-01 16:25:23 -07:00
920c7bb612 adjust look & message on DB download 2024-01-01 16:24:36 -07:00
6eb26ea90c remove IndexedDB keys that shouldn't be keys, and remove unused table, and add commentary 2024-01-01 16:24:30 -07:00
28b6d9bbf9 doc: reorganize top tasks 2023-12-31 19:58:51 -05:00
7a099183ae remove db.close calls that caused an error when trying to download 2023-12-29 13:14:35 -07:00
11070755d6 refine error message when duplicate contact is input 2023-12-29 13:10:07 -07:00
c79dfac1fe add a way to import contacts & settings 2023-12-29 12:46:47 -07:00
2b06c64664 make a backup download for browsers that don't get it automatically 2023-12-28 14:42:56 -07:00
769a928b3d increment version & add "-beta" 2023-12-27 20:03:59 -07:00
d26d1d3601 bump to version 0.1.8 2023-12-27 19:50:47 -07:00
1e6159869f update daily check title & documentation 2023-12-27 18:51:49 -07:00
75d15ddeb9 add note to install as an app 2023-12-27 14:46:10 -07:00
051a0a97d8 fix issuer name in project list 2023-12-27 14:13:05 -07:00
f8d3fe2ee1 enhance service-worker logging, allow for filtered-push test 2023-12-27 13:59:24 -07:00
4f0a046723 fix quick-give check on contact page & add message on detailed view 2023-12-27 13:58:42 -07:00
c4a0458c08 doc: add to notification-help page & task list 2023-12-26 21:43:04 -07:00
25b1598fcb doc: add more help for the notifications 2023-12-26 17:48:14 -07:00
ddbb700c34 Merge pull request 'Daily service-worker logging' (#100) from sw-log into master
Reviewed-on: #100
2023-12-25 14:51:22 -05:00
fd8877900b add another alert message & test button 2023-12-25 12:51:06 -07:00
05c6ddda02 allow a test notification from the notification help screen 2023-12-24 21:24:51 -07:00
853eb3c623 include the data in the logged info for a service worker "push" 2023-12-23 19:34:26 -07:00
44cfe0d88e allow notifications even without an ID 2023-12-22 14:22:13 -07:00
7fe256dc9e log service worker messages to the DB (now works) 2023-12-22 12:51:18 -07:00
e739d0be7c update error messages to be less... confusing 2023-12-22 09:19:36 -07:00
8d873b51bd doc: update tasks 2023-12-21 21:03:47 -07:00
d7f4acb702 make more adjustments to try and get logging to work 2023-12-21 20:50:35 -07:00
f8002c4550 add DB logging for service-worker events 2023-12-20 20:40:00 -07:00
d6b1386741 add console logging for all service worker events 2023-12-20 19:49:04 -07:00
50fdd95c60 increment version & add -beta 2023-12-19 20:41:57 -07:00
91c6c7c11c bump to version 0.1.7 2023-12-19 20:39:11 -07:00
4e28dc8de6 update commentary, help, kudos 2023-12-19 20:35:28 -07:00
fb425f0d51 update all icon images to our own art 2023-12-19 20:29:19 -07:00
a19aebcb37 simplify the notification message logic, hopefully fixing what's on servers 2023-12-18 19:12:09 -07:00
d0697c1ef4 fix top warnings when on prod or non-prod servers 2023-12-18 16:06:55 -07:00
1dd2333624 Merge pull request 'claim-view-improvements' (#99) from claim-view-improvements into master
Reviewed-on: #99
2023-12-18 16:54:02 -05:00
Jose Olarte III
b4b78f6a2c Recolored buttons 2023-12-18 19:46:58 +08:00
Jose Olarte III
3c0f6ce0de Design and uniformity tweaks 2023-12-18 19:44:03 +08:00
5534f8fa50 fix logic for prod & test host detection 2023-12-17 20:17:45 -07:00
a5004d475e bump version to next -beta 2023-12-17 20:02:28 -07:00
b445b1234f bump version to 0.1.6 2023-12-17 20:00:12 -07:00
17c96dd01a fix linting, etc with previous feature (env warning) 2023-12-16 17:17:54 -07:00
6ad17101b2 Merge pull request 'add warning if on unexpected server' (#98) from server-warn into master
Reviewed-on: #98
2023-12-16 10:08:17 -05:00
b4085ffaa7 Merge branch 'master' into server-warn 2023-12-16 08:08:00 -07:00
4f2cb55753 add warning if on unexpected server 2023-12-16 08:04:16 -07:00
ebf9164ecc Merge pull request 'add infinite scroll to the home page feed' (#97) from home-infinite into master
Reviewed-on: #97
2023-12-15 07:49:01 -05:00
540cc21839 add infinite scroll to the home page feed 2023-12-14 21:54:15 -07:00
c182068901 give more details on map-overlap issue 2023-12-13 21:27:13 -07:00
aaa1f31945 fix one single linting problem 2023-12-13 21:16:17 -07:00
17c632eb16 on brand new ID, go back home (plus some task adjustments) 2023-12-13 20:19:29 -07:00
41c4cbe61a fix more messaging and actions if they don't have an ID 2023-12-13 20:05:58 -07:00
c8402797ad remove "mute" notifications (since they can turn them off, and the mute functionality isn't built) 2023-12-13 19:53:46 -07:00
4a09b9b9b1 more fixes for when there is no identity 2023-12-13 19:46:40 -07:00
5db3423301 enhance error messages 2023-12-13 19:17:18 -07:00
2b00b243e8 fix error when lacking ID, and format linked projects 2023-12-13 19:13:04 -07:00
f2e5d8168d Merge pull request 'design-tweaks-2023-12' (#96) from design-tweaks-2023-12 into master
Reviewed-on: #96
2023-12-13 11:20:54 -05:00
1d262b8da9 Merge branch 'master' into design-tweaks-2023-12 2023-12-13 11:17:33 -05:00
8ed74b71f2 change colors and add spacing to make buttons obvious 2023-12-13 09:16:45 -07:00
8fb21c3d89 doc: update tasks, update verbiage, and add to help doc 2023-12-13 08:58:04 -07:00
8dbfcd38d3 fix bad text in pop-up, and fix name of class 2023-12-13 08:57:24 -07:00
04df0d4eff fix problem when there's a null description 2023-12-13 08:56:53 -07:00
106 changed files with 19292 additions and 16089 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# I tried and failed to set things here with vue-cli-service but
# things may be more reliable with vite so let's try again.

4
.env.production Normal file
View File

@@ -0,0 +1,4 @@
# Only the variables that start with VITE_ are seen in the application process.env in Vue.
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

View File

@@ -2,6 +2,7 @@ module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: [
"plugin:vue/vue3-essential",
@@ -9,9 +10,9 @@ module.exports = {
"@vue/typescript/recommended",
"plugin:prettier/recommended",
],
parserOptions: {
ecmaVersion: 2020,
},
// parserOptions: {
// ecmaVersion: 2020,
// },
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@
node_modules
/dist
signature.bin
# generated during `npm run build`
sw_scripts-combined.js
*.pem
verified.txt
myenv

View File

@@ -6,10 +6,229 @@ 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.3.14]
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/
- Deselection of project on gifted details page
### Fixed
- Don't show registration pop-up for a new contact that is registered
### Changed in DB or environment
- Nothing
## [0.1.6]
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
### Added
- Photos on projects
### Changed in DB or environment
- Nothing
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
### Fixed
- Photo share (share_target) failed because requests were sent to server
### Changed in DB or environment
- Nothing
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
### Added
- Choose a file for gifts, and a URL for gifts & profiles
### Fixed
- Multiple button pushes were required to switch camera
### Changed in DB or environment
- Nothing
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
### Added
- Share an image
- Choose a file on the device for a profile image
### Changed in DB or environment
- Nothing
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
### Added
- Offers on contacts page
- Checks on front page until they show as registered
### Changed
- Scanned contacts now add immediately and prompt for registration.
- Better UI for gives on contact page
- Better UI for all confirmation messages
### Fixed
- Repeated elements at top of main feed
### Changed in DB or environment
- Nothing
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
### Added
- Profile image for user
### Fixed
- Slow loading of home page feed
### Changed in DB or environment
- Nothing
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
### Added
- Filter on home page feed
- Ability to set time of daily notification
- Jump to app on click of notification
### Changed
- Built with vite
- Descriptions on home page to include projects
### Changed in DB or environment
- Nothing
## [0.3.6] - 2024.03.24 - 3a07e31d6313ab95711265562d9023c42916e141
### Added
- Button to mirror photo during video
- More detailed onboarding help screen
- Public-data blurb
### Changed in DB or environment
- Nothing
## [0.3.5] - 2024.03.23 - 28754bdfb1e11aa221dd49a5dce4219b69cf6a9d
### Added
- Photo on gift records
### Fixed
- Environment variable for BVC meetings project
- Environment variables and build enhancements for test vs prod
### Changed in DB or environment
- New environment variable for image API server
- Test that a new browser session will get the right default APIs.
- Test that a new browser session will send the right BVC meetings project.
## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36
### Added
- Shortcut page for Bountiful Voluntaryist Community
### Changed
- More readable, targeted summaries in home-page feed items
### Changed in DB
- Nothing
## [0.2.14] - 2024.02.14 - 5f9edea1167dbfb64e16648764eed8c09b24eaeb
### Changed
- Combine all service worker scripts into a single file.
### Changed in DB
- Nothing
## [0.2.13] - 2024.02.07
### Added
- Display of user's offers
- Check for valid DIDs
### Fixed
- Name display on give prompt
- Non-numbers on number input & autocapitalize on URL input
### Changed in DB
- Nothing
## [0.2.12] - 2024.02.01
### Added
- Prompts for gratitude
## [0.2.11] - 2024.01.28
### Added
- Actions to share claim data with contacts
- Bulk CSV import from Endorser Mobile export
- Dates on give summaries
## [0.2.10] - 2024.01.18 - 667e1e8890b42de59cd939caca1a01c7a7a702be
### Added
- Person identicons for contacts
- Confirmation & delivery directly from project page
- Offer dialog now allows units
- Links from claim detail page to the fulfilled project or offer
- Link to project from home feed
- Copy to clipboard in more places
### Fixed
- "More Contacts" for give on project page now links correctly.
## [0.2.9] - 2024.01.15 - e5e702f8a5a53a6efbed48d35f0bc3cee63024a0
### Fixed
- Set visibility for new contact.
## [0.2.8] - 2024.01.14
### Added
- Automatic ID creation from home page
- Agent who can also edit a project
### Fixed
- Cannot declare anonymous gift
## [0.2.7] - 2024.01.12
### Added
- Give to fulfill a particular offer
- Give as part of a trade as opposed to a donation
- Error notifications on import
### Changed
- Library security updates
- Visibility of actions & confirmations on claim page
### Fixed
- Name of offerer
## [0.2.2] - 2024.01.05
### Added
- Check for notification capability on front screen
- Contact next-public-key-hash in manual textual input
- Confirmation for contact visibility change
- YAML rendering of full claim details
- Hints for onboarding on the contact screen
## [0.2.0] - 2024.01.04
### Added
- Contact next-public-key-hash
- Icon for Android
- More thorough messaging and testing for notifications
## [0.1.9] - 2024.01.01
### Added
- Import for contacts and settings
- Second download button for DuckDuckGo
### Changed
- Removed some keys from Dexie's IndexedDB declarations
## [0.1.8] - 2023.12.27- d26d1d360152a7d0e559b68486e85b72b88bd9ff
### Added
- DB logging for service-worker events
- Help page for notifications
- Test notification & web-push triggers inside app
- Check that the app is installed
### Fixed
- Project issuer display name
## [0.1.7] - 2023.12.19 - 91c6c7c11c71f96006cc876fc946f1f98a274ba2
### Changed
- Icons
### Fixed
- Notification switch now shows message
- Prod/test server warning message at top of page
## [0.1.6] - 2023.12.17 - b445b1234fbfcf6b37d695373f259aab0eda1118
### Added
- Infinite scroll on home page
### Changed
- UI improvements
- Show web-push subscription info
- Icon
## [0.1.5] - 2023.12.09 - 9c36bb509a9bae9bb3306d3bd9eeb144b67aa8ad

6
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,6 @@
# Contributing
Welcome! We are happy to have your help with this project.
Note that all contributions will be under our
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).

164
README.md
View File

@@ -1,44 +1,69 @@
# TimeSafari.app - Crowd-Funder for Time - PWA
## Project setup
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
and expand to crowd-fund with time & money, then record and see the impact of contributions.
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
## Roadmap
See [project.task.yaml](project.task.yaml) for current priorities.
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
```
npm install
```
### Compiles and hot-reloads for development
### Compile and hot-reloads for development
```
npm run dev
```
### Build the test & production app
```
npm run serve
```
### Lints and fixes files
### Lint and fix files
```
npm run lint
```
### Compiles and minifies for production
### Compile and minify for test & production
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/`
...to make sure the service worker scripts are in proper form
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Update the CHANGELOG.md & the version in package.json, run `npm install`, and commit. Tag wth the new version: `git tag 0.1.0`
* Record what version is currently on production.
* If production, change src/constants/app.ts DEFAULT_*_SERVER to be PROD.
* Run the correct build:
* `npm run build`
* Test
```
# (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
```
* Revert src/constants/app.ts & change version to "-beta"
* Production
```
# This picks up values from .env.production
npm run build
```
* `cp sw_scripts/[ns]* dist/`
* Get on the server and back up 3 DBs and the time-safari folder.
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntu@endorser.ch:time-safari`
@@ -47,17 +72,10 @@ If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js,
### Register new user on test server
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: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:
* In the `src/views/AccountViewView.vue` file, uncomment the lines referring to "testServerRegisterUser".
* Visit the `/account` page.
playing by importing that user and registering others. 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).)
### Create multiple identifiers
@@ -75,46 +93,70 @@ For your own web-push tests, change the push server URL in Advanced settings on
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
### Manual walk-through
### Manual walk-through test
- 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.
- Backup seed & data & get a CSV dump from Endorser Mobile.
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
- Use a mobile user as well as a desktop user.
- Check that the version is updated.
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
- Make sure that it's using the test API (under Identity in 'Advanced').
- Clear the browser data again. (See "Reset" below.)
- Go to the account page before visiting the home page to see that there is no ID.
- On the home page:
- Check that it generated an ID.
- Check the feed without names.
- Copy the contact URL.
- On each page, verify the messaging, and that they cannot take action.
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
- 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 contacts page, check that they can add a contact even without their own ID.
- Install the PWA.
- As User 0 in another browser on the test API, add a give & a project.
- Note that some combinations of desktop with mobile emulation stretch the image.
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- Add new user as a contact (which allows them to see User 0).
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
- As the new user, import contacts & identifiers.
- As the new user on the contacts page, add User 0 as a contact.
- On the home page, see the feed that shows User 0 with a name.
- Switch back to the generated identifier.
- 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.
- As User 0, register the ID.
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
- On the contacts page, check that they cannot register someone else yet.
- Walk through the functions on each page.
## Scenarios
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard.
- Go back to /start and import test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` with this this seed phrase:
`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).)
- Go to "Your Contacts" screen and add the ID you copied to the clipboard, and hit "+" to add them.
- Click on the "Registration Unknown" button and register that person to be able to make claims as them.
- Set and run notifications.
- Export & import, both seed and contacts & settings.
- Choose location on the search map.
- Offer, deliver a give, and confirm. Create a third user and test connections.
- On mobile, share an image with the app.
- Switch to "no identifier" to see that things look OK without any ID.
### Clear/Reset data & restart
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data".)
* Unregister service worker (in Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers`).
* Clear notification permission (in Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications").
* Clear Cache Storage (in Chrome, in dev tools under Application; in Firefox, in dev tools under Storage).
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals`; in Firefox, go to `about:serviceworkers`.)
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
(If you find more, add them to the HelpNotificationsView.vue file.)
## Troubleshooting
* A problem with `GET http://localhost:8080/web-push/vapid` means the py-push-server is not running
(and notifications won't work for a local app without special routing from the browser's web push service provider, anyway).
* Red errors everywhere with a console message like this:
`Error: An ID is chosen but there are no keys for it so it cannot be used to talk with the service`
... has happened on account switching when the current account was erased (or maybe replaced -- once I had a duplicate and I don't know how).
* The error `DEXIE ENCRYPT ADDON: Could not decrypt message!` or
`Encryption key has changed` means that the encryption key is wrong,
sometimes seen after clearing storage for testing; you can make it happen by clearing localStorage.
Maybe only part of the storage was cleared out. Unless you got a copy of that password, you'll
have to erase storage and reload the identifier.
@@ -127,12 +169,18 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
* [Customize Vue configuration](https://cli.vuejs.org/config/).
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
### Kudos
Gifts make the world go 'round!
* [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
* [Many tools & libraries]() such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)

View File

@@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
</head>
<body>
<noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

19393
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +1,98 @@
{
"name": "TimeSafari",
"version": "0.1.6-beta",
"private": true,
"version": "0.3.15-beta",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite",
"serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js"
},
"dependencies": {
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@tweenjs/tween.js": "^21.0.0",
"@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",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.5.0",
"buffer": "^6.0.3",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"core-js": "^3.32.1",
"dexie": "^3.2.4",
"dexie-export-import": "^4.0.7",
"did-jwt": "^7.2.7",
"ethereum-cryptography": "^2.1.2",
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"git-describe": "^4.1.1",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"localstorage-slim": "^2.5.0",
"luxon": "^3.4.3",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"js-yaml": "^4.1.0",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.0",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"ramda": "^0.29.0",
"readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
"register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1",
"ua-parser-js": "^1.0.37",
"util": "^0.12.5",
"vue": "^3.3.4",
"vue": "^3.4.21",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.2",
"vue-qrcode-reader": "^5.4.1",
"vue-router": "^4.2.4",
"vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.3.0",
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/ramda": "^0.29.3",
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-pwa": "~5.0.8",
"@vue/cli-plugin-router": "~5.0.8",
"@vue/cli-plugin-typescript": "~5.0.8",
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.3",
"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",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.23.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.29",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.3",
"typescript": "~5.2.2"
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8"
}
}

View File

@@ -1,129 +1,4 @@
tasks:
tasks :
- 08 notifications :
- get it to work on Android
- get it to work on iOS
- lock down regenerate_vapid endpoint (so only we admins can do it on demand)
- make the app behave correctly when App Notifications are turned off
- remove "mute notifications"
- remove sleep in py-push-server app.py?
- see if we can detect OS-level notifications if turned off
- write troubleshooting docs for notifications
- make the "App Notifications" toggle on when they turn notifications on
- make the "App Notifications" toggle off when they turn notifications off
- in py-push-server, when sending a push to a subscriber and we get on a 410 "error #106", delete the subscription record
- https://gitea.anomalistdesign.com/trent_larson/py-push-server/pulls/3/files
- remove "notification push server" advanced setting since it only makes sense on the current domain
- .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 If notifications are not enabled, add message to front page with link/button to enable
- fix notification error when first loading the app
- add note after contact addition that they can see your info
- enhance help page instructions for debugging
- add way to test quickly a push notification
- help instructions for PWA install problems (secret failed, must reinstall)
- look at other examples for better UI friend.tech
- show VC details... somehow:
- 01 show my VCs - most interesting, or via search
- 01 allow download of each VC (& confirmations, to show that they actually own their data)
- 04 allow user to download VCs, mine + ones I can see about me from others
- add VC confirmation?
- 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.
- 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.
- Other features - donation vs give, show offers, show give & outstanding totals, show network view, restrict registration, connect to contacts
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- allow some gives even if they aren't registered
- .5 Add start date to project
- .3 check that Android shows "back" buttons on screens without bottom tray
- .1 Make give description text box into something that expands as they type?
- .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 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
- switch some checks for activeDid to check for isRegistered
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
- 04 fix lack of initial notification in Firefox (on MacOS, maybe others)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
- .5 don't show "Offer" on project screen if they aren't registered
- 24 Move to Vite
- 32 accept images for projects
- 32 accept images for contacts
- linking between projects or plans :
- show total time given to & from a project
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 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
- 04 show gives spreading to other places
- badge for most gives/receives/confirms per day/week/month
- badge for amount given/offered to your project
- set a goal of given/offers
- automated tests, eg. cypress
- Notifications (wake on the phone, push notifications)
- pull instead of push, maybe via scheduled runs
- have a notification pop-up on Mac screen
- Connect with phone contacts
- Multiple identities
- Support KERI AIDs
- Support Peer DIDs
- Support messaging through DIDComm
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
- Do we want split first name & last name?
- 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
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,7 +1,7 @@
<template>
<router-view />
<!-- https://github.com/emmanuelsw/notiwind -->
<!-- Messages in the upper-right - 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"
@@ -129,6 +129,10 @@
</div>
</NotificationGroup>
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
@@ -142,12 +146,96 @@
move="transition duration-500"
move-delay="delay-300"
>
<!-- see NotificationIface in constants/app.ts -->
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<!--
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">
{{ notification.title }}
</span>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button
v-if="notification.onYes"
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes
</button>
<button
v-if="notification.onNo"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
{{ notification.onYes ? "Cancel" : "Close" }}
</button>
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -156,24 +244,51 @@
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 v-if="serviceWorkerReady" class="text-lg mb-4">
Would you like to be notified of new activity once a day?
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 10
seconds...
<fa icon="spinner" spin />
</p>
<button
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 v-if="serviceWorkerReady">
<span class="flex flex-row justify-center">
<span class="mt-2">Yes, tell me at: </span>
<input
type="number"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
</span>
</span>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
() => {
if (checkHour()) {
close(notification.id);
turnOnNotifications();
}
}
"
>
Turn on Daily Message
</button>
</div>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
>
Maybe Later
No, Not Now
</button>
</div>
</div>
@@ -256,8 +371,11 @@
<style></style>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import axios from "axios";
import { Vue, Component } from "vue-facing-decorator";
import * as libsUtil from "@/libs/util";
interface ServiceWorkerMessage {
type: string;
data: string;
@@ -281,64 +399,78 @@ interface VapidResponse {
};
}
import { AppString } from "@/constants/app";
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
notifyTime: { utcHour: number };
}
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { sendTestThroughPushServer } from "@/libs/util";
@Component
export default class App extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = "";
hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl: string = AppString.DEFAULT_PUSH_SERVER;
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
await axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data?.vapidKey || "";
console.log("Got vapid key:", this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
if (pushUrl.startsWith("http://localhost")) {
console.log("Not checking for VAPID in this local environment.");
} else {
await axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data?.vapidKey || "";
console.log("Got vapid key:", this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
});
});
});
if (!this.b64) {
if (!this.b64) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
-1,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
console.log("Ignoring the error getting VAPID for local development.");
} else {
console.error("Got an error initializing notifications:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
text: "Got an error setting notifications.",
},
-1,
);
}
} catch (error) {
console.error("Got an error initializing notifications:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
-1,
);
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
}
private sendMessageToServiceWorker(
@@ -366,6 +498,7 @@ export default class App extends Vue {
}
private askPermission(): Promise<NotificationPermission> {
console.log("Requesting permission for notifications:", navigator);
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
return Promise.reject("Service worker not available.");
}
@@ -416,7 +549,49 @@ export default class App extends Vue {
});
}
async turnOnNotifications() {
// this allows us to show an error without closing the dialog
checkHour() {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Number",
text: "The time must be an hour number.",
},
5000,
);
return false;
}
const hourNum = libsUtil.numberOrZero(this.hourInput);
if (!Number.isInteger(hourNum)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be a whole hour number.",
},
5000,
);
return false;
}
if (hourNum < 1 || 12 < hourNum) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be an hour between 1 and 12.",
},
5000,
);
return false;
}
return true;
}
public async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
@@ -425,20 +600,54 @@ export default class App extends Vue {
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
return navigator.serviceWorker.ready;
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then((subscription) => {
.then(async (subscription) => {
if (subscription) {
return this.sendSubscriptionToServer(subscription);
await this.$notify(
{
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
const hourNum = adjHourNum % 24;
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour },
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(() => {
console.log("Subscription data sent to server.");
.then(async (subscription: PushSubscriptionWithTime) => {
console.log(
"Subscription data sent to server and all finished successfully.",
);
await sendTestThroughPushServer(subscription, true);
this.$notify(
{
group: "alert",
type: "success",
title: "Notifications Turned On",
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
},
-1,
);
})
.catch((error) => {
console.error(
@@ -455,9 +664,7 @@ export default class App extends Vue {
"An error occurred setting notification permissions:",
error,
);
alert(
"Some error occurred setting notification permissions. See logs.",
);
alert("Some error occurred setting notification permissions.");
});
}
@@ -504,11 +711,7 @@ export default class App extends Vue {
resolve();
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
options,
);
console.error("Push subscription failed:", error, options);
// Inform the user about the issue
alert(
@@ -522,9 +725,9 @@ export default class App extends Vue {
}
private sendSubscriptionToServer(
subscription: PushSubscription,
subscription: PushSubscriptionWithTime,
): Promise<void> {
console.log("About to send subscription", subscription);
console.log("About to send subscription...", subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
@@ -541,7 +744,7 @@ export default class App extends Vue {
async turnOffNotifications() {
let subscription;
const pushProviderSuccess = await navigator.serviceWorker.ready
const pushProviderSuccess = await navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.getSubscription();
})
@@ -555,7 +758,7 @@ export default class App extends Vue {
}
})
.catch((error) => {
console.log("Push provider server communication failed:", error);
console.error("Push provider server communication failed:", error);
return false;
});
@@ -570,7 +773,7 @@ export default class App extends Vue {
return response.ok;
})
.catch((error) => {
console.log("Push server communication failed:", error);
console.error("Push server communication failed:", error);
return false;
});

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#ffffff"></rect>
</svg>

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" enable-background="new 0 0 50 50">
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/><path d="M24 7h2v21h-2z"/><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
<g>
<circle fill="#FFFFFF" cx="37.785" cy="28.501" r="28.836"/>
<path d="M37.441-3.5c8.951,0,16.572,3.125,22.857,9.372c3.008,3.009,5.295,6.448,6.857,10.314
c1.561,3.867,2.344,7.971,2.344,12.314c0,4.381-0.773,8.486-2.314,12.313c-1.543,3.828-3.82,7.21-6.828,10.143
c-3.123,3.085-6.666,5.448-10.629,7.086c-3.961,1.638-8.057,2.457-12.285,2.457s-8.276-0.808-12.143-2.429
c-3.866-1.618-7.333-3.961-10.4-7.027c-3.067-3.066-5.4-6.524-7-10.372S5.5,32.767,5.5,28.5c0-4.229,0.809-8.295,2.428-12.2
c1.619-3.905,3.972-7.4,7.057-10.486C21.08-0.394,28.565-3.5,37.441-3.5z M37.557,2.272c-7.314,0-13.467,2.553-18.458,7.657
c-2.515,2.553-4.448,5.419-5.8,8.6c-1.354,3.181-2.029,6.505-2.029,9.972c0,3.429,0.675,6.734,2.029,9.913
c1.353,3.183,3.285,6.021,5.8,8.516c2.514,2.496,5.351,4.399,8.515,5.715c3.161,1.314,6.476,1.971,9.943,1.971
c3.428,0,6.75-0.665,9.973-1.999c3.219-1.335,6.121-3.257,8.713-5.771c4.99-4.876,7.484-10.99,7.484-18.344
c0-3.543-0.648-6.895-1.943-10.057c-1.293-3.162-3.18-5.98-5.654-8.458C50.984,4.844,44.795,2.272,37.557,2.272z M37.156,23.187
l-4.287,2.229c-0.458-0.951-1.019-1.619-1.685-2c-0.667-0.38-1.286-0.571-1.858-0.571c-2.856,0-4.286,1.885-4.286,5.657
c0,1.714,0.362,3.084,1.085,4.113c0.724,1.029,1.791,1.544,3.201,1.544c1.867,0,3.181-0.915,3.944-2.743l3.942,2
c-0.838,1.563-2,2.791-3.486,3.686c-1.484,0.896-3.123,1.343-4.914,1.343c-2.857,0-5.163-0.875-6.915-2.629
c-1.752-1.752-2.628-4.19-2.628-7.313c0-3.048,0.886-5.466,2.657-7.257c1.771-1.79,4.009-2.686,6.715-2.686
C32.604,18.558,35.441,20.101,37.156,23.187z M55.613,23.187l-4.229,2.229c-0.457-0.951-1.02-1.619-1.686-2
c-0.668-0.38-1.307-0.571-1.914-0.571c-2.857,0-4.287,1.885-4.287,5.657c0,1.714,0.363,3.084,1.086,4.113
c0.723,1.029,1.789,1.544,3.201,1.544c1.865,0,3.18-0.915,3.941-2.743l4,2c-0.875,1.563-2.057,2.791-3.541,3.686
c-1.486,0.896-3.105,1.343-4.857,1.343c-2.896,0-5.209-0.875-6.941-2.629c-1.736-1.752-2.602-4.19-2.602-7.313
c0-3.048,0.885-5.466,2.658-7.257c1.77-1.79,4.008-2.686,6.713-2.686C51.117,18.558,53.938,20.101,55.613,23.187z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="-0.5 0.5 64 64" enable-background="new -0.5 0.5 64 64" xml:space="preserve">
<g>
<circle fill="#FFFFFF" cx="31.325" cy="32.873" r="30.096"/>
<path id="text2809_1_" d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42
c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157
c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z
M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136
c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"/>
<path id="path2815_1_" d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532
C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396
c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465
c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5
c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259
c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061
c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994
c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516
c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628
C18.092,8.818,24.252,6.259,31.567,6.259z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,9 +1,8 @@
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
@layer base {
html {
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;

View File

@@ -1,31 +1,40 @@
<template>
<div v-html="generateIdenticon()" class="w-fit"></div>
<div v-html="generateIcon()" class="w-fit"></div>
</template>
<script lang="ts">
import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from "@dicebear/collection";
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",
};
import { Contact } from "@/db/tables/contacts";
@Component
export default class EntityIcon extends Vue {
@Prop entityId = "";
@Prop contact: Contact;
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIdenticon() {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) {
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else {
const identifier = this.contact?.did || this.entityId;
if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = {
seed: (identifier as string) || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
return svgString;
}
}
}
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1>
<p class="mb-4 font-bold">Show only activities that</p>
<div class="grid grid-cols-1 gap-2">
<div
class="flex items-center justify-between cursor-pointer"
@click="toggleHasVisibleDid()"
>
<!-- label -->
<div>Include someone visible to me</div>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="hasVisibleDid"
name="toggleFilterFromMyContacts"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<em>or</em>
<div
class="flex items-center justify-between cursor-pointer"
@click="
hasSearchBox
? toggleNearby()
: $router.push({ name: 'search-area' })
"
>
<!-- label -->
<div>Are nearby</div>
<!-- toggle -->
<div v-if="hasSearchBox" class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="isNearby"
name="toggleFilterNearby"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
<div v-else class="relative ml-2">
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
Select Location
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="setAll()"
>
Set All
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="clearAll()"
>
Clear All
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="done()"
>
Done
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { db } from "@/db/index";
@Component({
components: {
LRectangle,
LMap,
LMarker,
LTileLayer,
},
})
export default class FeedFilters extends Vue {
onCloseIfChanged = () => {};
hasSearchBox = false;
hasVisibleDid = false;
isNearby = false;
settingChanged = false;
visible = false;
async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged;
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.hasVisibleDid = !!settings?.filterFeedByVisible;
this.isNearby = !!settings?.filterFeedByNearby;
if (settings?.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true;
}
this.settingChanged = false;
this.visible = true;
}
toggleHasVisibleDid() {
this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid;
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid,
});
}
toggleNearby() {
this.settingChanged = true;
this.isNearby = !this.isNearby;
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
async clearAll() {
if (this.hasVisibleDid || this.isNearby) {
this.settingChanged = true;
}
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
this.hasVisibleDid = false;
this.isNearby = false;
}
async setAll() {
if (!this.hasVisibleDid || !this.isNearby) {
this.settingChanged = true;
}
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
this.hasVisibleDid = true;
this.isNearby = true;
}
close() {
if (this.settingChanged) {
this.onCloseIfChanged();
}
this.visible = false;
}
done() {
this.close();
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
#dialogFeedFilters.dialog-overlay {
z-index: 99999;
overflow: scroll;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -2,31 +2,30 @@
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giver?.name || "somebody not named" }}
{{ customTitle }}
</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
placeholder="What was given"
v-model="description"
/>
<div class="flex flex-row">
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ UNIT_SHORT[unitCode] }}
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
v-if="amountInput !== '0'"
@click="amountInput === '0' ? null : 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"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
@@ -36,108 +35,142 @@
<fa icon="chevron-right" />
</div>
</div>
<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 class="mt-4 flex justify-center">
<span>
<router-link
:to="{
name: 'gifted-details',
query: {
amountInput,
description,
giverDid: giver?.did,
giverName: giver?.name,
offerId,
projectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode,
},
}"
class="text-blue-500"
>
Photo & Details ...
</router-link>
</span>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { createAndSubmitGive, GiverInputInfo } from "@/libs/endorserServer";
import { NotificationIface } from "@/constants/app";
import {
createAndSubmitGive,
didInfo,
GiverReceiverInputInfo,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { Contact } from "@/db/tables/contacts";
@Component
export default class GiftedDialog extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = "";
@Prop projectId = "";
@Prop showGivenToUser = false;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
amountInput = "0";
giver?: GiverInputInfo; // undefined means no identified giver agent
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = "";
givenToUser = false;
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
receiver?: GiverReceiverInputInfo;
unitCode = "HUR";
visible = false;
/* eslint-disable prettier/prettier */
UNIT_SHORT: Record<string, string> = {
"BTC": "BTC",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
};
/* eslint-enable prettier/prettier */
libsUtil = libsUtil;
/* eslint-disable prettier/prettier */
UNIT_LONG: Record<string, string> = {
"BTC": "BTC",
"ETH": "ETH",
"HUR": "hours",
"USD": "dollars",
};
/* eslint-enable prettier/prettier */
async open(
giver?: GiverReceiverInputInfo,
receiver?: GiverReceiverInputInfo,
offerId?: string,
customTitle?: string,
callbackOnSuccess?: (amount: number) => void,
) {
this.customTitle = customTitle;
this.description = "";
this.giver = giver;
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || "";
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();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
this.giver.did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.log("Error retrieving settings from database:", err);
console.error("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.",
text: err.message || "There was an error retrieving your settings.",
},
-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.amountInput = "0";
this.visible = true;
}
@@ -148,7 +181,7 @@ export default class GiftedDialog extends Vue {
}
changeUnitCode() {
const units = Object.keys(this.UNIT_SHORT);
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
@@ -172,11 +205,50 @@ export default class GiftedDialog extends Vue {
eraseValues() {
this.description = "";
this.giver = undefined;
this.givenToUser = this.showGivenToUser;
this.amountInput = "0";
this.unitCode = "HUR";
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
3000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.description && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.close();
this.$notify(
{
@@ -189,7 +261,8 @@ export default class GiftedDialog extends Vue {
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive(
this.giver?.did as string | undefined,
(this.giver?.did as string) || null,
(this.receiver?.did as string) || null,
this.description,
parseFloat(this.amountInput),
this.unitCode,
@@ -198,74 +271,35 @@ export default class GiftedDialog extends Vue {
});
}
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 recipientDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param amount may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive(
giverDid?: string,
description?: string,
amountInput?: number,
unitCode?: string,
async recordGive(
giverDid: string | null,
recipientDid: string | null,
description: string,
amount: number,
unitCode: string = "HUR",
) {
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 && !amountInput) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.UNIT_LONG[this.unitCode]
}.`,
},
-1,
);
return;
}
try {
const identity = await this.getIdentity(this.activeDid);
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.givenToUser ? this.activeDid : undefined,
this.receiver?.did as string,
description,
amountInput,
amount,
unitCode,
this.projectId,
this.offerId,
this.isTrade,
);
if (
@@ -273,7 +307,7 @@ export default class GiftedDialog extends Vue {
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.log("Error with give creation result:", result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
@@ -289,15 +323,18 @@ export default class GiftedDialog extends Vue {
group: "alert",
type: "success",
title: "Success",
text: "That gift was recorded.",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
7000,
);
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with give recordation caught:", error);
const message =
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
@@ -306,7 +343,7 @@ export default class GiftedDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: message,
text: errorMessage,
},
-1,
);
@@ -336,6 +373,18 @@ export default class GiftedDialog extends Vue {
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

View File

@@ -0,0 +1,242 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Here's one:
<div
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@click="cancel"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<span class="flex justify-between">
<span
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="prevIdea()"
>
<fa icon="chevron-left" class="m-auto" />
</span>
<div class="m-2">
<span v-if="currentIdeaIndex < IDEAS.length">
<p class="text-center text-lg font-bold">
{{ IDEAS[currentIdeaIndex] }}
</p>
</span>
<div v-if="currentIdeaIndex == IDEAS.length + 0">
<p class="text-center">
<span
v-if="currentContact == null"
class="text-orange-500 text-lg font-bold"
>
That's all your contacts.
</span>
<span v-else>
<span class="text-lg font-bold">
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }}
<br />
or someone near them do anything &ndash; maybe a while ago?
</span>
<span class="flex justify-between">
<span />
<button
class="text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4"
@click="nextIdeaPastContacts()"
>
Skip Contacts <fa icon="forward" />
</button>
</span>
</span>
</p>
</div>
</div>
<span
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="nextIdea()"
>
<fa icon="chevron-right" class="m-auto" />
</span>
</span>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4"
@click="cancel"
>
That's it!
</button>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
@Component
export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
IDEAS = [
"Did anyone fix food for you?",
"Did a family member do something for you?",
"Did anyone give you a compliment?",
"Who is someone you can always rely on, and how did they demonstrate that?",
"Did you see anyone give to someone else?",
"Is there someone who you have never met who has helped you somehow?",
"How did an artist or musician or author inspire you?",
"What inspiration did you get from someone who handled tragedy well?",
"Did some organization give something worth respect?",
"Who last gave you a good laugh?",
"Do you recall anything that was given to you while you were young?",
"Did someone forgive you or overlook a mistake?",
"Do you know of a way an ancestor contributed to your life?",
"Did anyone give you help at work?",
"How did a teacher or mentor or great example help you?",
];
OTHER_PROMPTS = 1;
CONTACT_PROMPT_INDEX = this.IDEAS.length; // expected after other prompts
currentContact: Contact | undefined = undefined;
currentIdeaIndex = 0;
numContacts = 0;
shownContactDbIndices: number[] = [];
visible = false;
AppString = AppString;
async open() {
this.visible = true;
await db.open();
this.numContacts = await db.contacts.count();
}
close() {
// close the dialog but don't change values (just in case some actions are added later)
this.visible = false;
}
/**
* Get the next idea.
* If it is a contact prompt, loop through.
*/
async nextIdea() {
// if we're incrementing to the contact prompt
// or if we're at the contact prompt and there was a previous contact...
if (
this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX - 1 ||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
this.shownContactDbIndices.length < this.numContacts)
) {
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
this.findNextUnshownContact();
} else {
// we're not at the contact prompt (or we ran out), so increment the idea index
this.currentIdeaIndex =
(this.currentIdeaIndex + 1) % (this.IDEAS.length + this.OTHER_PROMPTS);
// ... and clear out any other prompt info
this.currentContact = undefined;
this.shownContactDbIndices = [];
}
}
prevIdea() {
if (
this.currentIdeaIndex ==
(this.CONTACT_PROMPT_INDEX + 1) %
(this.IDEAS.length + this.OTHER_PROMPTS) ||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
this.shownContactDbIndices.length < this.numContacts)
) {
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
this.findNextUnshownContact();
} else {
// we're not at the contact prompt (or we ran out), so increment the idea index
this.currentIdeaIndex--;
if (this.currentIdeaIndex < 0) {
this.currentIdeaIndex = this.IDEAS.length - 1 + this.OTHER_PROMPTS;
}
// ... and clear out any other prompt info
this.currentContact = undefined;
this.shownContactDbIndices = [];
}
}
nextIdeaPastContacts() {
this.currentIdeaIndex = 0;
this.currentContact = undefined;
this.shownContactDbIndices = [];
}
async findNextUnshownContact() {
// get a random contact
if (this.shownContactDbIndices.length === this.numContacts) {
// no more contacts to show
this.currentContact = undefined;
} else {
// get a random contact that hasn't been shown yet
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
// and guarantee that one is found by walking past shown contacts
let shownContactIndex =
this.shownContactDbIndices.indexOf(someContactDbIndex);
while (shownContactIndex !== -1) {
// increment both indices until we find a spot where "shown" skips a spot
shownContactIndex = (shownContactIndex + 1) % this.numContacts;
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
if (
this.shownContactDbIndices[shownContactIndex] !== someContactDbIndex
) {
// we found a contact that hasn't been shown yet
break;
}
// continue
// ... and there must be at least one because shownContactDbIndices length < numContacts
}
this.shownContactDbIndices.push(someContactDbIndex);
this.shownContactDbIndices.sort();
// get the contact at that offset
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
}
}
cancel() {
this.currentContact = undefined;
this.currentIdeaIndex = 0;
this.numContacts = 0;
this.shownContactDbIndices = [];
this.close();
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Camera or Other?
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</div>
<div>
<div class="text-center mt-8">
<div class>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog()"
/>
</div>
<div class="mt-4">
<input type="file" @change="uploadImageFile" />
</div>
<div class="mt-4">
<span class="mt-2">
... or paste a URL:
<input type="text" v-model="imageUrl" class="border-2" />
</span>
<span class="ml-2">
<fa
v-if="imageUrl"
icon="check"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
/>
<!-- so that there's no shifting when it becomes visible -->
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
</span>
</div>
</div>
</div>
</div>
</div>
<PhotoDialog ref="photoDialog" />
</template>
<script lang="ts">
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import { NotificationIface } from "@/constants/app";
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { PhotoDialog },
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string;
crop: boolean = false;
imageCallback: (imageUrl?: string) => void = () => {};
imageUrl?: string;
visible = false;
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
}
async uploadImageFile(event: Event) {
this.visible = false;
inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
async acceptUrl() {
this.visible = false;
if (this.crop) {
try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error retrieving that image.",
},
5000,
);
}
} else {
this.imageCallback(this.imageUrl);
}
}
close() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
}
</style>

View File

@@ -8,22 +8,24 @@
placeholder="Description, prerequisites, terms, etc."
v-model="description"
/>
<div class="flex flex-row mb-6">
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center text-blue-500 px-2 py-2"
@click="changeUnitCode()"
>
Hours
{{ libsUtil.UNIT_SHORT[amountUnitCode] }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
v-if="amountInput !== '0'"
>
<fa icon="chevron-left" />
</div>
<input
type="text"
type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="hours"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@@ -32,7 +34,7 @@
<fa icon="chevron-right" />
</div>
</div>
<div class="flex flex-row mb-6">
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
@@ -41,99 +43,122 @@
<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)"
:placeholder="datePlaceholder()"
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>
<p class="text-center mt-6 mb-2 italic">
Sign & Send to publish to the world
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { DateTime } from "luxon";
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;
}
import { NotificationIface } from "@/constants/app";
import { createAndSubmitOffer } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@Component
export default class OfferDialog extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = "";
@Prop projectId = "";
@Prop projectId? = "";
activeDid = "";
apiServer = "";
amountInput = "0";
amountUnitCode = "HUR";
description = "";
expirationDateInput = "";
hours = "0";
recipientDid? = "";
visible = false;
async created() {
libsUtil = libsUtil;
async open(recipientDid?: string) {
try {
this.recipientDid = recipientDid;
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);
console.error("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.",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
open() {
this.visible = true;
}
close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false;
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.amountUnitCode);
this.amountUnitCode = units[(index + 1) % units.length];
}
increment() {
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() {
this.close();
this.eraseValues();
}
eraseValues() {
this.description = "";
this.hours = "0";
this.amountInput = "0";
this.amountUnitCode = "HUR";
}
async confirm() {
@@ -150,38 +175,25 @@ export default class OfferDialog extends Vue {
// this is asynchronous, but we don't need to wait for it to complete
this.recordOffer(
this.description,
parseFloat(this.hours),
parseFloat(this.amountInput),
this.amountUnitCode,
this.expirationDateInput,
).then(() => {
this.description = "";
this.hours = "0";
this.amountInput = "0";
});
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Offer records for DID ${activeDid} but no identity was found",
);
}
return identity;
}
/**
*
* @param description may be an empty string
* @param hours may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordOffer(
description?: string,
hours?: number,
description: string,
amount: number,
unitCode: string = "HUR",
expirationDateInput?: string,
) {
if (!this.activeDid) {
@@ -190,20 +202,20 @@ export default class OfferDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record an offer.",
text: "You must select an identifier before you can record an offer.",
},
-1,
);
return;
}
if (!description && !hours) {
if (!description && !amount) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must enter a description or some number of hours.",
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
},
-1,
);
@@ -211,14 +223,16 @@ export default class OfferDialog extends Vue {
}
try {
const identity = await this.getIdentity(this.activeDid);
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitOffer(
this.axios,
this.apiServer,
identity,
description,
hours,
amount,
unitCode,
expirationDateInput,
this.recipientDid,
this.projectId,
);
@@ -227,7 +241,7 @@ export default class OfferDialog extends Vue {
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result);
console.log("Error with offer creation result:", result);
console.error("Error with offer creation result:", result);
this.$notify(
{
group: "alert",
@@ -250,7 +264,7 @@ export default class OfferDialog extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with offer recordation caught:", error);
console.error("Error with offer recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||

View File

@@ -0,0 +1,441 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
<span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span>
<span v-else> Say "Cheese"! </span>
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</div>
<div v-if="uploading" class="flex justify-center">
<fa
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
</div>
<div v-else-if="blob">
<div v-if="crop">
<VuePictureCropper
:boxStyle="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 9 / 9,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div>
<div v-else>
<div class="flex justify-center">
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
/>
</div>
</div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
<button
@click="uploadImage"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
>
<span>Upload</span>
</button>
</div>
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button
@click="retryImage"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
>
<span>Retry</span>
</button>
</div>
</div>
<div v-else ref="cameraContainer">
<!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera
facingMode="environment"
autoplay
ref="camera"
@started="cameraStarted()"
>
<div
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
>
<button
@click="takeImage()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
>
<fa icon="camera" class="w-[1em]"></fa>
</button>
</div>
<div
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
>
<button
@click="swapMirrorClass()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
>
<fa icon="left-right" class="w-[1em]"></fa>
</button>
</div>
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
<button
@click="switchCamera()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
>
<fa icon="rotate" class="w-[1em]"></fa>
</button>
</div>
</camera>
</div>
</div>
</div>
</template>
<script lang="ts">
import axios from "axios";
import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { getIdentity } from "@/libs/util";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera, VuePictureCropper } })
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0;
activeDid = "";
blob?: Blob;
claimType = "";
crop = false;
fileName?: string;
mirror = false;
numDevices = 0;
setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
URL = window.URL || window.webkitURL;
async mounted() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
open(
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string,
) {
this.visible = true;
this.claimType = claimType;
this.crop = !!crop;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "none";
}
this.setImageCallback = setImageFn;
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
}
close() {
this.visible = false;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
}
async cameraStarted() {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
if (cameraComponent) {
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
this.mirror = cameraComponent.facingMode === "user";
// figure out which device is active
const currentDeviceId = cameraComponent.currentDeviceID();
const devices = await cameraComponent.devices(["videoinput"]);
this.activeDeviceNumber = devices.findIndex(
(device) => device.deviceId === currentDeviceId,
);
}
}
async switchCamera() {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
const devices = await cameraComponent?.devices(["videoinput"]);
await cameraComponent?.changeCamera(
devices[this.activeDeviceNumber].deviceId,
);
}
async takeImage(/* payload: MouseEvent */) {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
/**
* This logic to set the image height & width correctly.
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
* Now that I've done it, I can't explain why it works.
*/
let imageHeight = cameraComponent?.resolution?.height;
let imageWidth = cameraComponent?.resolution?.width;
const initialImageRatio = imageWidth / imageHeight;
const windowRatio = window.innerWidth / window.innerHeight;
if (initialImageRatio > 1 && windowRatio < 1) {
// the image is wider than it is tall, and the window is taller than it is wide
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
// We're gonna force it opposite.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
} else if (initialImageRatio < 1 && windowRatio > 1) {
// the image is taller than it is wide, and the window is wider than it is tall
// Haven't seen this happen, but we'll do it just in case.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
}
const newImageRatio = imageWidth / imageHeight;
if (newImageRatio < windowRatio) {
// the image is a taller ratio than the window, so fit the height first
imageHeight = window.innerHeight / 2;
imageWidth = imageHeight * newImageRatio;
} else {
// the image is a wider ratio than the window, so fit the width first
imageWidth = window.innerWidth / 2;
imageHeight = imageWidth / newImageRatio;
}
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error taking the picture. Please try again.",
},
5000,
);
return;
}
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = undefined;
}
/****
Here's an approach to photo capture without a library. It has similar quirks.
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
<button id="start-camera" @click="cameraClicked">Start Camera</button>
<video id="video" width="320" height="240" autoplay></video>
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
<canvas id="canvas" width="320" height="240"></canvas>
async cameraClicked() {
const video = document.querySelector("#video");
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
if (video instanceof HTMLVideoElement) {
video.srcObject = stream;
}
}
photoSnapped() {
const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
if (
canvas instanceof HTMLCanvasElement &&
video instanceof HTMLVideoElement
) {
canvas
?.getContext("2d")
?.drawImage(video, 0, 0, canvas.width, canvas.height);
// ... or set the blob:
// canvas?.toBlob(
// (blob) => {
// this.blob = blob;
// },
// "image/jpeg",
// 1,
// );
// data url of the image
const image_data_url = canvas?.toDataURL("image/jpeg");
}
}
****/
async uploadImage() {
this.uploading = true;
if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined;
}
const identifier = await getIdentity(this.activeDid);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000,
);
this.uploading = false;
return;
}
formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append("claimType", this.claimType);
try {
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.close();
this.setImageCallback(response.data.url as string);
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture.",
},
5000,
);
this.uploading = false;
this.blob = undefined;
}
}
swapMirrorClass() {
this.mirror = !this.mirror;
if (this.mirror) {
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
} else {
(this.$refs.cameraContainer as HTMLElement).classList.remove(
"mirror-video",
);
}
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
}
.mirror-video {
transform: scaleX(-1);
-webkit-transform: scaleX(-1); /* For Safari */
-moz-transform: scaleX(-1); /* For Firefox */
-ms-transform: scaleX(-1); /* For IE */
-o-transform: scaleX(-1); /* For Opera */
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<a
v-if="linkToFull && imageUrl"
:href="imageUrl"
target="_blank"
class="h-full w-full object-contain"
>
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
</a>
<div
v-else
v-html="generateIdenticon()"
class="h-full w-full object-contain"
/>
</template>
<script lang="ts">
import { toSvg } from "jdenticon";
import { Vue, Component, Prop } from "vue-facing-decorator";
const BLANK_CONFIG = {
lightness: {
color: [1.0, 1.0],
grayscale: [1.0, 1.0],
},
saturation: {
color: 0.0,
grayscale: 0.0,
},
backColor: "#0000",
};
@Component
export default class ProjectIcon extends Vue {
@Prop entityId = "";
@Prop iconSize = 0;
@Prop imageUrl = "";
@Prop linkToFull = false;
generateIdenticon() {
if (this.imageUrl) {
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
} else {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
}
}
</script>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<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">
<ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
<!-- Home Feed -->
<li
:class="{
@@ -12,7 +12,7 @@
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
<fa icon="house-chimney" class="fa-fw" />
</router-link>
</li>
<!-- Search -->
@@ -28,7 +28,7 @@
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
<fa icon="magnifying-glass" class="fa-fw" />
</router-link>
</li>
<!-- Projects -->
@@ -44,7 +44,7 @@
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
<fa icon="hand" class="fa-fw" />
</router-link>
</li>
<!-- Contacts -->
@@ -60,7 +60,7 @@
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
<fa icon="users" class="fa-fw" />
</router-link>
</li>
<!-- Profile -->
@@ -76,7 +76,7 @@
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
<fa icon="circle-user" class="fa-fw" />
</router-link>
</li>
</ul>

View File

@@ -0,0 +1,52 @@
<template>
<div class="text-center text-red-500">{{ message }}</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component
export default class TopMessage extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop selected = "";
message = "";
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
if (
settings?.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix;
} else if (
settings?.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message =
"You're linked to the production server, user " + didPrefix;
}
} catch (err: unknown) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Detecting Server",
text: JSON.stringify(err),
},
-1,
);
}
}
}
</script>

View File

@@ -4,21 +4,34 @@
* See also ../libs/veramo/setup.ts
*/
export enum AppString {
APP_NAME = "Time Safari",
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,
PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
DEFAULT_PUSH_SERVER = TEST1_PUSH_SERVER,
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
NO_CONTACT_NAME = "(no name)",
}
export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
/**
* The possible values for "group" and "type" are in App.vue.
* From the notiwind package
@@ -27,5 +40,9 @@ export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
text?: string;
onCancel?: (stopAsking: boolean) => Promise<void>;
onNo?: (stopAsking: boolean) => Promise<void>;
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
}

View File

@@ -1,19 +1,23 @@
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactsSchema } from "./tables/contacts";
import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs";
import {
MASTER_SETTINGS_KEY,
Settings,
SettingsSchema,
} from "./tables/settings";
import { AppString } from "@/constants/app";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
logs: Table<Log>;
settings: Table<Settings>;
temp: Table<Temp>;
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
@@ -23,10 +27,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = { ...ContactsSchema, ...SettingsSchema };
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
@@ -36,17 +37,23 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// Define the schema for our databases
accountsDB.version(1).stores(SensitiveSchemas);
db.version(1).stores(NonsensitiveSchemas);
// Define the schemas for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
accountsDB.version(1).stores(AccountsSchema);
// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
...ContactSchema,
...LogSchema,
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY,
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,
// remember that things you add from now on aren't automatically in the DB for old users
webPushServer: AppString.DEFAULT_PUSH_SERVER,
apiServer: DEFAULT_ENDORSER_API_SERVER,
});
});

View File

@@ -3,41 +3,46 @@
*/
export type Account = {
/**
* Auto-generated ID by Dexie.
* Auto-generated ID by Dexie
*/
id?: number;
/**
* The date the account was created.
* The date the account was created
*/
dateCreated: string;
/**
* The derivation path for the account.
* The derivation path for the account, if this is from a mnemonic
*/
derivationPath: string;
derivationPath?: string;
/**
* Decentralized Identifier (DID) for the account.
* Decentralized Identifier (DID) for the account
*/
did: string;
/**
* Stringified JSON containing underlying key material.
* Based on the IIdentifier type from Veramo.
* Stringified JSON containing underlying key material, if generated from a mnemonic
* Based on the IIdentifier type from Veramo
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/
identity: string;
identity?: string;
/**
* The public key in hexadecimal format.
* The mnemonic phrase for the account, if this is from a mnemonic
*/
mnemonic?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/
publicKeyHex: string;
/**
* The mnemonic passphrase for the account.
*/
mnemonic: string;
};
/**

View File

@@ -1,11 +1,13 @@
export interface Contact {
did: string;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
profileImageUrl?: string;
publicKeyBase64?: string;
seesMe?: boolean;
registered?: boolean;
}
export const ContactsSchema = {
contacts: "&did, name, publicKeyBase64, registered, seesMe",
export const ContactSchema = {
contacts: "&did, name", // no need to key by other things
};

11
src/db/tables/logs.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Log {
date: string;
message: string;
}
export const LogSchema = {
// Currently keyed by "date" because A) today's log data is what we need so we append, and
// B) we don't want it to grow so we remove everything if this is the first entry today.
// See safari-notifications.js logMessage for the associated logic.
logs: "date", // definitely don't key by the potentially large message field
};

View File

@@ -12,15 +12,23 @@ export type BoundingBox = {
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
id: number; // Only one entry using MASTER_SETTINGS_KEY
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
firstName?: string; // User's first name
lastName?: string; // deprecated - put all names in firstName
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string; // Last notified claim ID
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
firstName?: string; // user's full name
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
webPushServer?: string; // Web Push server URL
lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
profileImageUrl?: string;
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
@@ -29,11 +37,17 @@ export type Settings = {
}>;
showContactGivesInline?: boolean; // Display contact inline or not
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
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
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
};
export function isAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
}
/**
* Schema for the Settings table in the database.
*/

13
src/db/tables/temp.ts Normal file
View File

@@ -0,0 +1,13 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string;
blob?: Blob;
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};

View File

@@ -11,6 +11,8 @@ import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
export const LOCAL_KMS_NAME = "local";
/**
*
*
@@ -31,7 +33,7 @@ export const newIdentifier = (
keys: [
{
kid: publicHex,
kms: "local",
kms: LOCAL_KMS_NAME,
meta: { derivationPath: derivationPath },
privateKeyHex: privateHex,
publicKeyHex: publicHex,
@@ -64,6 +66,10 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath];
};
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/**
*
*
@@ -113,7 +119,7 @@ export const sign = async (privateKeyHex: string) => {
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
@@ -173,3 +179,19 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
return jwt.payload;
};
export const nextDerivationPath = (origDerivPath: string) => {
let lastStr = origDerivPath.split("/").slice(-1)[0];
if (lastStr.endsWith("'")) {
lastStr = lastStr.slice(0, -1);
}
const lastNum = parseInt(lastStr, 10);
const newLastNum = lastNum + 1;
const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : "");
const newDerivPath = origDerivPath
.split("/")
.slice(0, -1)
.concat([newLastStr])
.join("/");
return newDerivPath;
};

View File

@@ -0,0 +1,102 @@
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
/**
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
*
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
*/
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
return finalSignature;
}
/**
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
* should be removed based on the following logic:
*
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
* then remove the leading 0x0 byte"
*/
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
/**
* Combine multiple Uint8Arrays into a single Uint8Array
*/
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: unknown = undefined;
export function getWebCrypto() {
/**
* Hello there! If you came here wondering why this method is asynchronous when use of
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
* become synchronous if we make this synchronous (since nothing else in that method is async)
* which represents a breaking API change in this library's core API.
*
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
* to keep this method asynchronous.
*/
const toResolve = new Promise((resolve, reject) => {
if (webCrypto) {
return resolve(webCrypto);
}
/**
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
* support (and Node v20+)
*/
const _globalThisCrypto = _getWebCryptoInternals.stubThisGlobalThisCrypto();
if (_globalThisCrypto) {
webCrypto = _globalThisCrypto;
return resolve(webCrypto);
}
// We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto());
});
return toResolve;
}
export class MissingWebCrypto extends Error {
constructor() {
const message = "An instance of the Crypto API could not be located";
super(message);
this.name = "MissingWebCrypto";
}
}
// Make it possible to stub return values during testing
export const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: unknown) => {
webCrypto = newCrypto;
},
};

570
src/libs/didPeer.ts Normal file
View File

@@ -0,0 +1,570 @@
import asn1 from "asn1-ber";
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt";
import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types";
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers";
const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName: "Time Safari",
rpID: window.location.hostname,
userName: passkeyName || "Time Safari User",
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "none",
authenticatorSelection: {
// Defaults
residentKey: "preferred",
userVerification: "preferred",
// Optional
authenticatorAttachment: "platform",
},
});
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
// with pubKeyCredParams: { type: "public-key", alg: -7 }
const attResp = await startRegistration(options);
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: options.challenge,
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
});
// references for parsing auth data and getting the public key
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.")
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
).toString("hex");
const { publicKeyJwk } = cborToKeys(
verification.registrationInfo?.credentialPublicKey as Uint8Array,
);
return {
authData: verification.registrationInfo?.attestationObject,
credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.registrationInfo
?.credentialPublicKey as Uint8Array,
};
}
export function createPeerDid(publicKeyBytes: Uint8Array) {
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
const methodSpecificId = bytesToMultibase(
publicKeyBytes,
"base58btc",
"p256-pub",
);
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
}
function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export class PeerSetup {
public authenticatorData?: ArrayBuffer;
public challenge?: Uint8Array;
public clientDataJsonBase64Url?: Base64URLString;
public signature?: Base64URLString;
public async createJwtSimplewebauthn(
issuerDid: string,
payload: object,
credIdHex: string,
) {
const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
// const payloadHash: Uint8Array = sha256(this.challenge);
const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({
challenge: this.challenge,
rpID: window.location.hostname,
allowCredentials: [{ id: credentialId }],
});
// console.log("simple authentication options", options);
const clientAuth = await startAuthentication(options);
// console.log("simple credential get", clientAuth);
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
this.authenticatorData = Buffer.from(
clientAuth.response.authenticatorData,
"base64",
).buffer;
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
// console.log("simple authenticatorData for signing", this.authenticatorData);
this.signature = clientAuth.response.signature;
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const signature = clientAuth.response.signature;
return headerBase64 + "." + payloadBase64 + "." + signature;
}
public async createJwtNavigator(
issuerDid: string,
payload: object,
credIdHex: string,
) {
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataToSignString = JSON.stringify(fullPayload);
const dataToSignBuffer = Buffer.from(dataToSignString);
const credentialId = Buffer.from(credIdHex, "hex");
// console.log("lower credentialId", credentialId);
this.challenge = new Uint8Array(dataToSignBuffer);
const options = {
publicKey: {
allowCredentials: [
{
id: credentialId,
type: "public-key",
},
],
challenge: this.challenge.buffer,
rpID: window.location.hostname,
userVerification: "preferred",
},
};
const credential = await navigator.credentials.get(options);
// console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON,
);
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature)
.toString("base64")
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
return jwt;
}
// return a low-level signing function, similar to createJWS approach
// async webAuthnES256KSigner(credentialID: string) {
// return async (data: string | Uint8Array) => {
// // get signature from WebAuthn
// const signature = await this.generateWebAuthnSignature(data);
//
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
// const signatureBuffer = Buffer.from(signature);
// console.log("lower signature inside signer", signature);
// console.log("lower buffer signature inside signer", signatureBuffer);
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
// // Decode the DER-encoded signature to extract R and S values
// const reader = new asn1.BerReader(signatureBuffer);
// console.log("lower after reader");
// reader.readSequence();
// console.log("lower after read sequence");
// const r = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r");
// const s = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r & s");
//
// // Ensure R and S are 32 bytes each
// const rBuffer = Buffer.from(r);
// const sBuffer = Buffer.from(s);
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
// const rPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
// : rWithoutPrefix;
// const sPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
// : sWithoutPrefix;
//
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
// console.log(
// "lower combinedSignature",
// combinedSignature.length,
// combinedSignature,
// );
//
// const combSig64 = combinedSignature.toString("base64");
// console.log("lower combSig64", combSig64);
// const combSig64Url = combSig64
// .replace(/\+/g, "-")
// .replace(/\//g, "_")
// .replace(/=+$/, "");
// console.log("lower combSig64Url", combSig64Url);
// return combSig64Url;
// };
// }
}
// I'd love to use this but it doesn't verify.
// Requires:
// npm install @noble/curves
// ... and this import:
// import { p256 } from "@noble/curves/p256";
export async function verifyJwtP256(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const isValid = p256.verify(
finalSigBuffer,
new Uint8Array(preimage),
publicKeyBytes,
);
return isValid;
}
export async function verifyJwtSimplewebauthn(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const credId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const authOpts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: credId,
credentialPublicKey: publicKeyBytes,
counter: 0,
},
expectedChallenge: arrayToBase64Url(challenge),
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
response: {
authenticatorAttachment: "platform",
clientExtensionResults: {},
id: credId,
rawId: credId,
response: {
authenticatorData: authData,
clientDataJSON: clientDataJsonBase64Url,
signature: signature,
},
type: "public-key",
},
};
const verification = await verifyAuthenticationResponse(authOpts);
return verification.verified;
}
export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const WebCrypto = await getWebCrypto();
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"],
);
const verified = await WebCrypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSigBuffer,
preimage,
);
return verified;
}
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) {
throw new Error(
"This only verifies a peer DID, method 0, encoded base58btc.",
);
}
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver)
const id = did.split(":")[2];
const multibase = id.slice(1);
const encnumbasis = multibase.slice(1);
const didDocument = {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis],
id: did,
keyAgreement: undefined,
service: undefined,
verificationMethod: [
{
controller: did,
id: did + "#" + encnumbasis,
publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019",
},
],
};
return {
didDocument,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" },
};
}
// convert COSE public key to PEM format
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
function base64urlDecode(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer) {
const bytes = new Uint8Array(buffer);
let str = "";
for (const charCode of bytes) {
str += String.fromCharCode(charCode);
}
const base64String = btoa(str);
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
// from @simplewebauthn/browser
function base64URLStringToArrayBuffer(base64URLString: string) {
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (base64.length % 4)) % 4;
const padded = base64.padEnd(base64.length + padLength, "=");
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes);
if (
jwkObj[1] != 2 || // kty "EC"
jwkObj[3] != -7 || // alg "ES256"
jwkObj[-1] != 1 || // crv "P-256"
jwkObj[-2].length != 32 || // x
jwkObj[-3].length != 32 // y
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]),
]);
return { publicKeyJwk, publicKeyBuffer };
}
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,278 @@
// many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios";
import { IIdentifier } from "@veramo/core";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer";
export const PRIVACY_MESSAGE =
"The data you send be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow.";
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
"BX": "BX",
"BTC": "BTC",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
};
/* eslint-enable prettier/prettier */
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BX": "Buxbe",
"BTC": "Bitcoin",
"ETH": "Ethereum",
"HUR": "hours",
"USD": "dollars",
};
/* eslint-enable prettier/prettier */
const UNIT_CODES: Record<string, Record<string, string>> = {
BTC: {
name: "Bitcoin",
faIcon: "bitcoin-sign",
},
HUR: {
name: "hours",
faIcon: "clock",
},
USD: {
name: "US Dollars",
faIcon: "dollar",
},
};
export function iconForUnitCode(unitCode: string) {
return UNIT_CODES[unitCode]?.faIcon || "question";
}
// from https://stackoverflow.com/a/175787/845494
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
//
export function isNumeric(str: string): boolean {
// This ignore commentary is because typescript complains when you pass a string to isNaN.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return !isNaN(str) && !isNaN(parseFloat(str));
}
export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0;
}
export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};
export const isGiveAction = (veriClaim: GenericCredWrapper) => {
return veriClaim.claimType === "GiveAction";
};
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
};
/**
* @returns true if the user can confirm the claim
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export const isGiveRecordTheUserCanConfirm = (
veriClaim: GenericCredWrapper,
activeDid: string,
confirmerIdList: string[] = [],
) => {
return (
isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
);
};
/**
* @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer
*/
export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = (
veriClaim,
) => {
let giver;
if (
veriClaim.claim.offeredBy?.identifier &&
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string)
) {
giver = veriClaim.claim.offeredBy.identifier;
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
giver = veriClaim.issuer;
}
return giver;
};
/**
* @returns true if the user can fulfill the offer
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export const canFulfillOffer = (veriClaim: GenericCredWrapper) => {
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
};
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
export function findAllVisibleToDids(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
input: any,
humanReadable = false,
): Record<string, Array<string>> {
if (Array.isArray(input)) {
const result: Record<string, Array<string>> = {};
for (let i = 0; i < input.length; i++) {
const inside = findAllVisibleToDids(input[i], humanReadable);
for (const key in inside) {
const pathKey = humanReadable
? "#" + (i + 1) + " " + key
: "[" + i + "]" + key;
result[pathKey] = inside[key];
}
}
return result;
} else if (input instanceof Object) {
// regular map (non-array) object
const result: Record<string, Array<string>> = {};
for (const key in input) {
if (key.endsWith("VisibleToDids")) {
const newKey = key.slice(0, -"VisibleToDids".length);
const pathKey = humanReadable ? newKey : "." + newKey;
result[pathKey] = input[key];
} else {
const inside = findAllVisibleToDids(input[key], humanReadable);
for (const insideKey in inside) {
const pathKey = humanReadable
? key + "'s " + insideKey
: "." + key + insideKey;
result[pathKey] = inside[insideKey];
}
}
}
return result;
} else {
return {};
}
}
/**
* Test findAllVisibleToDids
*
pkgx +deno.land sh
deno
import * as R from 'ramda';
//import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function
console.log(R.equals(findAllVisibleToDids(null), {}));
console.log(R.equals(findAllVisibleToDids(9), {}));
console.log(R.equals(findAllVisibleToDids([]), {}));
console.log(R.equals(findAllVisibleToDids({}), {}));
console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {}));
console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] }));
console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] }));
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] }));
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] }));
*
**/
export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
`Attempted to load identity ${activeDid} but no identifier was found`,
);
}
return identity;
};
/**
* Generates a new identity, saves it to the database, and sets it as the active identity.
* @return {Promise<string>} with the DID of the new identity
*/
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const mnemonic = generateSeed();
// address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] =
deriveAddress(mnemonic);
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
return newId.did;
};
export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean,
): Promise<AxiosResponse> => {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const newPayload = {
// eslint-disable-next-line prettier/prettier
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
...subscriptionJSON,
};
console.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload);
const response = await axios.post(
pushUrl + "/web-push/send-test",
payloadStr,
{
headers: {
"Content-Type": "application/json",
},
},
);
console.log("Got response from web push server:", response);
return response;
};

View File

@@ -1,5 +1,5 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
@@ -11,14 +11,22 @@ import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
@@ -29,21 +37,29 @@ import {
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
@@ -53,8 +69,10 @@ import {
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
@@ -63,14 +81,22 @@ import {
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
@@ -81,21 +107,29 @@ library.add(
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
@@ -105,8 +139,10 @@ library.add(
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
@@ -115,11 +151,40 @@ library.add(
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
createApp(App)
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) {
// @ts-expect-error 'cause we cannot see why config is not defined
app.config.errorHandler = (
err: Error,
instance: ComponentPublicInstance | null,
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Error:",
err,
"Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.
alert(
(err.message || "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.use(Notifications)
.mount("#app");
.use(Notifications);
setupGlobalErrorHandler(app);
app.mount("#app");

View File

@@ -2,8 +2,8 @@
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register("/additional-scripts.js", {
if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +

View File

@@ -28,200 +28,177 @@ const enterOrStart = async (
};
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: () =>
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
},
{
path: "/account",
name: "account",
component: () =>
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
component: () => import("../views/AccountViewView.vue"),
},
{
path: "/claim/:id?",
name: "claim",
component: () =>
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
component: () => import("../views/ClaimView.vue"),
},
{
path: "/confirm-contact",
name: "confirm-contact",
component: () =>
import(
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue"
),
component: () => import("../views/ConfirmContactView.vue"),
},
{
path: "/confirm-gift/:id?",
name: "confirm-gift",
component: () => import("@/views/ConfirmGiftView.vue"),
},
{
path: "/contact-amounts",
name: "contact-amounts",
component: () =>
import(
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
),
component: () => import("../views/ContactAmountsView.vue"),
},
{
path: "/contact-gives",
name: "contact-gives",
component: () =>
import(
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
),
path: "/contact-gift",
name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"),
},
{
path: "/contact-qr",
name: "contact-qr",
component: () =>
import(
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
),
component: () => import("../views/ContactQRScanShowView.vue"),
},
{
path: "/contacts",
name: "contacts",
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
component: () => import("../views/ContactsView.vue"),
},
{
path: "/did/:did?",
name: "did",
component: () => import("../views/DIDView.vue"),
},
{
path: "/discover",
name: "discover",
component: () =>
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
component: () => import("../views/DiscoverView.vue"),
},
{
path: "/gifted-details",
name: "gifted-details",
component: () => import("../views/GiftedDetails.vue"),
},
{
path: "/help",
name: "help",
component: () =>
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
component: () => import("../views/HelpView.vue"),
},
{
path: "/help-notifications",
name: "help-notifications",
component: () =>
import(
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
),
component: () => import("../views/HelpNotificationsView.vue"),
},
{
path: "/help-onboarding",
name: "help-onboarding",
component: () => import("../views/HelpOnboardingView.vue"),
},
{
path: "/",
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
component: () => import("../views/IdentitySwitcherView.vue"),
},
{
path: "/import-account",
name: "import-account",
component: () =>
import(
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
),
component: () => import("../views/ImportAccountView.vue"),
},
{
path: "/import-derive",
name: "import-derive",
component: () =>
import(
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
),
component: () => import("../views/ImportDerivedAccountView.vue"),
},
{
path: "/new-edit-account",
name: "new-edit-account",
component: () =>
import(
/* webpackChunkName: "new-edit-account" */ "../views/NewEditAccountView.vue"
),
},
{
path: "/new-edit-commitment",
name: "new-edit-commitment",
component: () =>
import(
/* webpackChunkName: "new-edit-commitment" */ "../views/NewEditCommitmentView.vue"
),
component: () => import("../views/NewEditAccountView.vue"),
},
{
path: "/new-edit-project",
name: "new-edit-project",
component: () =>
import(
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
),
component: () => import("../views/NewEditProjectView.vue"),
},
{
path: "/new-identifier",
name: "new-identifier",
component: () =>
import(
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
),
component: () => import("../views/NewIdentifierView.vue"),
},
{
path: "/project/:id?",
name: "project",
component: () =>
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
component: () => import("../views/ProjectViewView.vue"),
},
{
path: "/projects",
name: "projects",
component: () =>
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
component: () => import("../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/quick-action-bvc",
name: "quick-action-bvc",
component: () => import("../views/QuickActionBvcView.vue"),
},
{
path: "/quick-action-bvc-begin",
name: "quick-action-bvc-begin",
component: () => import("../views/QuickActionBvcBeginView.vue"),
},
{
path: "/quick-action-bvc-end",
name: "quick-action-bvc-end",
component: () => import("../views/QuickActionBvcEndView.vue"),
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
component: () => import("../views/ContactScanView.vue"),
},
{
path: "/search-area",
name: "search-area",
component: () =>
import(
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
),
component: () => import("../views/SearchAreaView.vue"),
},
{
path: "/seed-backup",
name: "seed-backup",
component: () =>
import(
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
),
component: () => import("../views/SeedBackupView.vue"),
},
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
{
path: "/start",
name: "start",
component: () =>
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
component: () => import("../views/StartView.vue"),
},
{
path: "/statistics",
name: "statistics",
component: () =>
import(
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
),
component: () => import("../views/StatisticsView.vue"),
},
{
path: "/test",
name: "test",
component: () =>
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
component: () => import("../views/TestView.vue"),
},
];
/** @type {*} */
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});

1
src/util.d.ts vendored
View File

@@ -1,4 +1,5 @@
// from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts
/* eslint-disable */
/**
* The `node:util` module supports the needs of Node.js internal APIs. Many of the
* utilities are useful for application and module developers as well. To access

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -10,7 +10,7 @@
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<fa icon="chevron-left" class="fa-fw" />
</button>
Verifiable Claim Details
</h1>
@@ -18,34 +18,148 @@
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<div class="block pb-4 flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<h2 class="text-xl">{{ veriClaim.id }}</h2>
<div class="text-sm">
<div>
{{ veriClaim.claimType }}
</div>
<div>
<fa icon="message" class="fa-fw text-slate-400"></fa>
{{ veriClaim.claim?.description }}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuer }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<h2 class="text-md font-bold">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
</h2>
<div class="text-sm">
<div>
{{ veriClaim.id }}
<button
@click="
libsUtil.doCopyTwoSecRedo(
veriClaim.id as string,
() => (showIdCopy = !showIdCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showIdCopy">Copied ID</span>
</div>
<div>
<fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400" />
{{ veriClaim.issuer }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
veriClaim.issuer as string,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
Fulfills a bigger plan...
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
detailsForGive?.fulfillsType &&
detailsForGive?.fulfillsType !== 'PlanAction' &&
detailsForGive?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<a
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
class="text-blue-500 mt-4"
>
Fulfills
{{
capitalizeAndInsertSpacesBeforeCaps(
detailsForGive.fulfillsType,
)
}}...
</a>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan...
</router-link>
</div>
</div>
</div>
</div>
</div>
<div>
<h2 class="font-bold text-2xl">Confirmations</h2>
<div class="flex columns-3">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<span class="px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
>
Confirmation Details...
</router-link>
</span>
<button
v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()"
class="col-span-1 block w-fit text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Affirm Delivery
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
</button>
</div>
<GiftedDialog ref="customGiveDialog" />
<div v-if="libsUtil.isGiveAction(veriClaim)">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
@@ -71,16 +185,28 @@
</div>
<div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim.
<ul>
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
:key="confirmerId"
class="list-disc"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ confirmerId }}
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
@@ -89,25 +215,39 @@
</div>
<!--
Never need to show the following message.
Never need to show this message:
"Nobody that you know can see someone who has confirmed this claim."
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
If there is somebody in the confirmerIdList then that's all they need to show.
-->
<!-- Nobody that you know can see someone who has confirmed this claim. -->
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who have issued or
confirmed this claim.
<ul>
<ul class="ml-4">
<li
v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo"
class="list-disc"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ confsVisibleTo }}
{{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
@@ -116,104 +256,223 @@
</div>
</div>
<div class="mt-4">
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="containsHiddenDid(veriClaim.claim)">
You cannot confirm this claim because it contains data that is hidden
from you.
</div>
<div v-else>
<button
class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4"
@click="confirmClaim(veriClaim.id)"
>
Confirm Claim
</button>
</div>
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="veriClaim.issuer == activeDid">
You cannot confirm this because you issued this claim, so you already
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<div>
<h2 class="font-bold text-2xl mt-8">Claim</h2>
<pre>{{ util.inspect(veriClaim, false, null) }}</pre>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
</h2>
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN". They
are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation)"
class="text-blue-500"
>share this page with them</a
>
and see if they are willing to make an introduction.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to some
of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="copyToClipboard('The DID of ' + visDid, visDid)"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
<h2 class="font-bold text-2xl mt-8">Full Claim</h2>
<p>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Full Claim</h2>
<p class="mb-4">
The full claim includes the claim as it was originally issued, including
the signature (ie. the proof of issuance by that person).
</p>
<div v-if="!fullClaim">
<div v-if="fullClaimMessage">
<p v-if="fullClaimMessage" class="mb-4">
{{ fullClaimMessage }}
</div>
</p>
<button
v-else
class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4"
@click="showFullClaim(veriClaim.id)"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
@click="showFullClaim(veriClaim.id as string)"
>
Load Full Claim Details
</button>
</div>
<div v-else>
<pre>{{ util.inspect(fullClaim, false, null) }}</pre>
<pre>{{ fullClaimDump }}</pre>
</div>
<a :href="apiServer + '/api/claim/' + veriClaim.id" target="_blank">
<button class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4">
View on the Public Server
</button>
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
>
View on the Public Server
</a>
</section>
</template>
<script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import * as util from "util";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
confirmerIdList = []; // list of DIDs that have confirmed this claim excluding the issuer
canShare = false;
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList = []; // list of DIDs that can see any confirmer
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
detailsForGive = null;
detailsForOffer = null;
fullClaim = null;
fullClaimDump = "";
fullClaimMessage = "";
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
showDidCopy = false;
showIdCopy = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location.href;
util = util;
containsHiddenDid = serverUtil.containsHiddenDid;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.detailsForGive = null;
this.detailsForOffer = null;
this.fullClaim = null;
this.fullClaimDump = "";
this.fullClaimMessage = "";
this.numConfsNotVisible = 0;
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
async created() {
await db.open();
@@ -224,16 +483,17 @@ export default class ClaimView extends Vue {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
const accountsArr: Array<Account> = 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.accountIdentityStr = (account?.identity as string) || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring("/claim/".length);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
this.loadClaim(claimId, identity);
await this.loadClaim(claimId, identity);
} else {
this.$notify(
{
@@ -245,6 +505,18 @@ export default class ClaimView extends Vue {
-1,
);
}
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
totalConfirmers() {
@@ -265,7 +537,7 @@ export default class ClaimView extends Vue {
if (!identity) {
throw new Error(
"Attempted to load project records with no identity available.",
"Attempted to load project records with no identifier available.",
);
}
return identity;
@@ -283,57 +555,83 @@ export default class ClaimView extends Vue {
}
// Isn't there a better way to make this available to the template?
didInfo(
did: string,
activeDid: string,
dids: Array<string>,
contacts: Array<Contact>,
) {
return serverUtil.didInfo(did, activeDid, dids, contacts);
didInfo(did: string) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async loadClaim(claimId: string, identity: IIdentifier) {
const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(claimId);
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
const headers = await this.getHeaders(identity);
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
} else {
// actually, axios typically throws an error so we never get here
console.log("Error getting claim:", resp);
console.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem getting that claim. See logs for more info.",
text: "There was a problem retrieving that claim.",
},
-1,
);
return;
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving that claim. See logs for more info.",
},
-1,
);
}
const confirmUrl =
this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await this.getHeaders(identity);
try {
// retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType === "GiveAction") {
const giveUrl =
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await this.getHeaders(identity);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
if (giveResp.status === 200) {
this.detailsForGive = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
}
} else if (this.veriClaim.claimType === "Offer") {
const offerUrl =
this.apiServer +
"/api/v2/report/offers?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const offerHeaders = await this.getHeaders(identity);
const offerResp = await this.axios.get(offerUrl, {
headers: offerHeaders,
});
if (offerResp.status === 200) {
this.detailsForOffer = offerResp.data.data[0];
} else {
console.error("Error getting detailed offer info:", offerResp);
}
}
// retrieve the list of confirmers
const confirmUrl =
this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await this.getHeaders(identity);
const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders,
});
@@ -359,18 +657,25 @@ export default class ClaimView extends Vue {
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving confirmations:", serverError);
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations. See logs for more info.";
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
-1,
);
}
}
async showFullClaim(claimId: string) {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
const accountsArr: Account[] = await accounts?.toArray();
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const identity = JSON.parse((account?.identity as string) || "null");
const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
@@ -380,9 +685,10 @@ export default class ClaimView extends Vue {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.fullClaim = resp.data;
this.fullClaimDump = yaml.dump(this.fullClaim);
} else {
// actually, axios typically throws an error so we never get here
console.log("Error getting full claim:", resp);
console.error("Error getting full claim:", resp);
this.$notify(
{
group: "alert",
@@ -418,55 +724,112 @@ export default class ClaimView extends Vue {
}
}
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
const confirmationClaim: serverUtil.GenericVerifiableCredential & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any;
} = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.log("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
}
}
}
showDifferentClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
});
}
openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.veriClaim.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation,
});
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -30,17 +30,19 @@
</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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
value="Add Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,878 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
>
Do you agree?
</span>
<span v-else> Details </span>
</h1>
</div>
<div v-if="giveDetails">
<div class="flex justify-center">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<button
v-else
@click="notifyWhyCannotConfirm()"
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<a
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
:href="urlForNewGive"
>
Record a Similar One
</a>
</div>
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-down" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
<div v-if="giveDetails.amount">
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
</div>
<div v-if="giveDetails.description">
<fa icon="message" class="fa-fw text-slate-400" />
{{ giveDetails.amount ? "and:" : "" }}
{{ giveDetails.description }}
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-up" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
on
{{ giveDetails.issuedAt.substring(0, 10) }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills a bigger plan
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails.fulfillsType,
)
}}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="mt-2">
<fa icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person has confirmed this.
</span>
<span v-else>
{{ totalConfirmers() }} people have confirmed this.
</span>
<div v-if="totalConfirmers() > 0">
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
"
>
Nobody that you know confirmed this claim, nor do they have any
confirmers in their network.
</div>
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
"
>
<!-- Only show if this person has links to confirmers (below). -->
Nobody that you know has issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
:key="confirmerId"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!--
Never need to show this message:
"Nobody that you know can see someone who has confirmed this claim."
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
If there is somebody in the confirmerIdList then that's all they need to show.
-->
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who have issued or
confirmed this claim.
<ul class="ml-4">
<li
v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
>
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="giveDetails.agentDid == activeDid">
You cannot confirm this because you issued this claim, so you already
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
@click="showDetails = !showDetails"
>
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
<span v-else><fa icon="chevron-up" /></span>
</h2>
<div v-if="showDetails">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them</a
>
and see if they are willing to make an introduction.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to
some of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />
<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
</div>
<div v-else>This does not have details to confirm.</div>
<div class="mt-4">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="pl-2" />
All Generic Info
</a>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as serverUtil from "@/libs/endorserServer";
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util";
@Component({
methods: { displayAmount },
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
canShare = false;
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails = null;
giverName = "";
issuerName = "";
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showDetails = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = null;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
async mounted() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
this.accountIdentityStr = (account?.identity as string) || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, identity);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
3000,
);
}
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
totalConfirmers() {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
// Isn't there a better way to make this available to the template?
didInfo(did: string | undefined) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async loadClaim(claimId: string, identity: IIdentifier) {
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
try {
const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers });
// resp.data is:
// - a Jwt from https://api.endorser.ch/api-docs/
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem retrieving that claim.",
},
3000,
);
return;
}
// retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType !== "GiveAction") {
// no need to go further... this page is for gifts
return;
}
this.issuerName = this.didInfo(this.veriClaim.issuer);
// use give record when possible since it may include edits
const giveUrl =
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await this.getHeaders(identity);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
if (giveResp.status === 200) {
this.giveDetails = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gift data.",
},
3000,
);
}
this.urlForNewGive = "/gifted-details?";
if (this.giveDetails.amount) {
this.urlForNewGive +=
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
}
if (this.giveDetails.unit) {
this.urlForNewGive +=
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
}
if (this.giveDetails.description) {
this.urlForNewGive +=
"&description=" + encodeURIComponent(this.giveDetails.description);
}
this.giverName = this.didInfo(this.giveDetails.agentDid);
if (this.giveDetails.agentDid) {
this.urlForNewGive +=
"&giverDid=" +
encodeURIComponent(this.giveDetails.agentDid) +
"&giverName=" +
encodeURIComponent(this.giverName);
}
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
if (this.giveDetails.recipientDid) {
this.urlForNewGive +=
"&recipientDid=" +
encodeURIComponent(this.giveDetails.recipientDid) +
"&recipientName=" +
encodeURIComponent(this.recipientName);
}
if (this.giveDetails.fullClaim.image) {
this.urlForNewGive +=
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
}
if (
this.giveDetails.type == "Offer" &&
this.giveDetails.fulfillsHandleId
) {
this.urlForNewGive +=
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId);
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&projectId=" +
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
}
// retrieve the list of confirmers
const confirmUrl =
this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await this.getHeaders(identity);
const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders,
});
if (response.status === 200) {
const resultList1 = response.data.result || [];
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject(
(did: string) => did === this.giveDetails.agentDid,
resultList2,
);
this.confirmerIdList = resultList3;
this.numConfsNotVisible = resultList1.length - resultList2.length;
if (resultList3.length === resultList2.length) {
// the issuer was not in the "visible" list so they must be hidden
// so subtract them from the non-visible confirmers count
this.numConfsNotVisible = this.numConfsNotVisible - 1;
}
this.confsVisibleToIdList =
response.data.result.resultVisibleToDids || [];
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
3000,
);
}
}
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
3000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
5000,
);
}
}
showClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
});
}
openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.giveDetails.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
notifyWhyCannotConfirm() {
if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (this.confirmerIdList.includes(this.activeDid)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You have already confirmed this claim.",
},
3000,
);
} else if (this.giveDetails.agentDid == this.activeDid) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because it contains hidden identifiers.",
},
3000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim.",
},
3000,
);
}
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation.href,
});
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<h1
@@ -16,7 +16,7 @@
</h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Given with {{ contact?.name }}
Transferred with {{ contact?.name }}
</h1>
</div>
@@ -25,6 +25,13 @@
<span class="justify-around">(Only 50 most recent)</span>
<span />
</div>
<div class="flex justify-around">
<span />
<span class="justify-around">
(This does not include claims by them if they're not visible to you.)
</span>
<span />
</div>
<!-- Results List -->
<table
@@ -50,7 +57,7 @@
<td class="p-1">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -74,7 +81,7 @@
<td class="p-1">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -98,41 +105,38 @@
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
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 {
AgreeVerifiableCredential,
GiveServerRecord,
displayAmount,
GiveSummaryRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import * as didJwt from "did-jwt";
import { AxiosError } from "axios";
import QuickNav from "@/components/QuickNav.vue";
import { IIdentifier } from "@veramo/core";
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;
export default class ContactAmountssView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = [];
giveRecords: Array<GiveSummaryRecord> = [];
numAccounts = 0;
displayAmount = displayAmount;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
@@ -148,7 +152,7 @@ export default class ContactsView extends Vue {
if (!identity) {
throw new Error(
"Attempted to load Give records with no identity available.",
"Attempted to load Give records with no identifier available.",
);
}
return identity;
@@ -178,6 +182,7 @@ export default class ContactsView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings or gives.", err);
this.$notify(
{
group: "alert",
@@ -185,7 +190,7 @@ export default class ContactsView extends Vue {
title: "Error",
text:
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.",
"There was an error retrieving your settings or contacts or gives.",
},
-1,
);
@@ -195,7 +200,7 @@ export default class ContactsView extends Vue {
async loadGives(activeDid: string, contact: Contact) {
try {
const identity = await this.getIdentity(this.activeDid);
let result: Array<GiveServerRecord> = [];
let result: Array<GiveSummaryRecord> = [];
const url =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
@@ -250,7 +255,7 @@ export default class ContactsView extends Vue {
);
}
const sortedResult: Array<GiveServerRecord> = R.sort(
const sortedResult: Array<GiveSummaryRecord> = R.sort(
(a, b) =>
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
result,
@@ -269,7 +274,7 @@ export default class ContactsView extends Vue {
}
}
async confirm(record: GiveServerRecord) {
async confirm(record: GiveSummaryRecord) {
// Make claim
// I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,7 +1,7 @@
<template>
<QuickNav selected="Home"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -20,19 +20,19 @@
<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"
<span class="grow">
<img
src="../assets/blank-square.svg"
width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
></EntityIcon>
Anonymous
/>
Unnamed/Unknown
</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"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
@@ -45,19 +45,19 @@
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"
<span class="grow font-semibold">
<EntityIcon
:contact="contact"
: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"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
@@ -66,49 +66,72 @@
</li>
</ul>
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<GiftedDialog ref="customDialog" :projectId="projectId" />
</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;
}
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class ContactGiftingView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
apiServer = "";
accounts: typeof AccountsSchema;
numAccounts = 0;
projectId = localStorage.getItem("projectId") || "";
async beforeCreate() {
accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
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 || "";
// .orderBy("name") wouldn't retrieve any entries with a blank name
// .toCollection.sortBy("name") didn't sort in an order I understood
const baseContacts = await db.contacts.toArray();
this.allContacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
localStorage.removeItem("projectId");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.message ||
"There was an error retrieving your settings or contacts.",
},
-1,
);
}
}
public async getIdentity(activeDid: string) {
@@ -121,7 +144,7 @@ export default class ContactGiftingView extends Vue {
if (!identity) {
throw new Error(
"Attempted to load Give records with no identity available.",
"Attempted to load Give records with no identifier available.",
);
}
return identity;
@@ -136,31 +159,16 @@ export default class ContactGiftingView extends Vue {
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);
openDialog(giver: GiverReceiverInputInfo) {
const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
);
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -18,19 +18,22 @@
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
<p v-if="!givenName" class="text-center mt-2">
<p
v-if="!givenName"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<span class="text-red">Beware!</span>
You aren't sharing your name, so hurry and
You aren't sharing your name, so quickly
<router-link
:to="{ name: 'new-edit-account' }"
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md"
>
go here to set it for them.
click here to set it for them.
</router-link>
</p>
</div>
<div @click="onCopyToClipboard()" v-if="activeDid">
<div @click="onCopyToClipboard()" v-if="activeDid" class="text-center">
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
@@ -41,49 +44,63 @@
:dotsOptions="{ type: 'square' }"
class="flex justify-center"
/>
<span> Click that QR to copy your contact URL to your clipboard. </span>
<div>Not scanning? Show it in pieces.</div>
</div>
<div class="text-center" v-else>
You have no identitifiers yet, so
<router-link :to="{ name: 'start' }" class="text-blue-500">
<router-link
:to="{ name: 'start' }"
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
>
create your identifier.
</router-link>
<br />
We recommend you do that first; otherwise, these contacts won't see your
activity.
If you don't that first, these contacts won't see your activity.
</div>
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
<div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
<span>
If you do not see a scanning camera window here, check your camera
permissions.
</span>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as R from "ramda";
import { SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
deriveAddress,
getContactPayloadFromJwtUrl,
nextDerivationPath,
SimpleSigner,
} from "@/libs/crypto";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
isDid,
register,
setVisibilityUtil,
} 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;
}
import { Buffer } from "buffer/";
@Component({
components: {
@@ -93,36 +110,24 @@ interface Notification {
},
})
export default class ContactQRScanShow extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = "";
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identity available.",
);
}
return identity;
}
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.givenName = settings?.firstName || "";
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.givenName = (settings?.firstName as string) || "";
this.hideRegisterPromptOnNewContact =
!!settings?.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
@@ -131,6 +136,14 @@ export default class ContactQRScanShow extends Vue {
const identity = await this.getIdentity(this.activeDid);
const publicKeyHex = identity.keys[0].publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
@@ -139,6 +152,9 @@ export default class ContactQRScanShow extends Vue {
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
profileImageUrl: settings?.profileImageUrl,
registered: settings?.isRegistered,
},
};
@@ -156,45 +172,269 @@ export default class ContactQRScanShow extends Vue {
}
}
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
timeout,
);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identifier available.",
);
}
return identity;
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
// 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" });
async onScanDetect(content: any) {
const url = content[0]?.rawValue;
if (url) {
let newContact: Contact;
try {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
newContact = {
did: payload.iss as string,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
} catch (e) {
console.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
return;
}
try {
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
console.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
}
} else {
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
-1,
5000,
);
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
console.error("Got strange result from setting visibility:", result);
}
}
async register(contact: Contact) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
console.log("Scan was invalid:", error);
console.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
title: "Invalid Scan",
text: "The scan was invalid.",
},
-1,
5000,
);
}
onCopyToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.qrValue)
.then(() => {

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -65,17 +65,19 @@
/>
<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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
value="Look Up Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>

File diff suppressed because it is too large Load Diff

337
src/views/DIDView.vue Normal file
View File

@@ -0,0 +1,337 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Identifier Details
</h1>
</div>
<!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<h2 class="text-xl font-semibold">
{{
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
</h2>
<span class="mt-2 text-xl font-semibold break-words">
{{ viewingDid }}
</span>
</div>
<div class="flex justify-center mt-4">
<span v-if="contact?.profileImageUrl" class="flex justify-between">
<EntityIcon
:icon-size="96"
:profileImageUrl="contact?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
/>
</span>
</div>
<div class="mt-4">
<div class="flex justify-center">Auto-Generated Icon:</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
</div>
</div>
<div
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
class="fixed z-[100] top-0 inset-x-0 w-full"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
showLargeIdenticonUrl = undefined;
"
/>
</div>
</div>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">Claims That Involve Them</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="claim in claims"
:key="claim.handleId"
>
<div class="grid grid-cols-12 gap-4">
<span class="col-span-2">
{{ claim.issuedAt.substring(0, 10) }}
</span>
<span class="col-span-2">
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
</span>
<span class="col-span-2">
{{ claimAmount(claim) }}
</span>
<span class="col-span-5">
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a
@click="onClickLoadClaim(claim.handleId)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</span>
</div>
</li>
</ul>
</InfiniteScroll>
<div
v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4"
>
<span>They Are in No Claims Visible to You</span>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
TopMessage,
},
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper> = [];
contact?: Contact;
hitEnd = false;
isLoading = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid);
await this.loadClaimsAbout();
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
-1,
);
}
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
}
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
return;
}
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
let postfix = "";
if (this.claims.length > 0) {
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{
method: "GET",
headers: await this.buildHeaders(),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
},
5000,
);
return;
}
const results = await response.json();
this.claims = this.claims.concat(results.data);
this.hitEnd = !results.hitLimit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
return displayAmount(
giveClaim.object.unitCode,
giveClaim.object.amountOfThisGood,
);
} else {
return "";
}
} else if (claim.claimType === "Offer") {
const offerClaim = claim.claim as OfferVerifiableCredential;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood
) {
return displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
} else {
return "";
}
}
return "";
}
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
}
</script>

View File

@@ -1,11 +1,12 @@
<template>
<QuickNav selected="Discover"></QuickNav>
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Discover
Discover Projects
</h1>
<!-- Quick Search -->
@@ -50,13 +51,13 @@
<li>
<a
href="#"
v-bind:class="computedRemoteTabClassNames()"
@click="
projects = [];
isRemoteActive = true;
isLocalActive = false;
searchAll();
"
v-bind:class="computedRemoteTabClassNames()"
>
Anywhere
<span
@@ -73,7 +74,7 @@
<div v-if="isLocalActive">
<div>
<button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })"
>
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
@@ -99,14 +100,15 @@
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
class="block py-4 flex gap-4 cursor-pointer"
>
<div class="w-12">
<EntityIcon
<div>
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="block border border-slate-300 rounded-md"
></EntityIcon>
:imageUrl="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div class="grow">
@@ -128,38 +130,34 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { 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;
}
import { didInfo, PlanData } from "@/libs/endorserServer";
@Component({
components: {
QuickNav,
InfiniteScroll,
EntityIcon,
ProjectIcon,
QuickNav,
TopMessage,
},
})
export default class DiscoverView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
searchTerms = "";
projects: ProjectData[] = [];
projects: PlanData[] = [];
isLoading = false;
isLocalActive = true;
isRemoteActive = false;
@@ -173,8 +171,8 @@ export default class DiscoverView extends Vue {
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.searchBox = settings?.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray();
@@ -218,7 +216,7 @@ export default class DiscoverView extends Vue {
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
@@ -255,7 +253,7 @@ export default class DiscoverView extends Vue {
if (response.status !== 200) {
const details = await response.text();
console.log("Problem with full search:", details);
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
@@ -271,11 +269,18 @@ export default class DiscoverView extends Vue {
const results = await response.json();
const plans: ProjectData[] = results.data;
const plans: PlanData[] = results.data;
if (plans) {
for (const plan of plans) {
const { name, description, handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
}
this.remoteCount = this.projects.length;
} else {
@@ -283,7 +288,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.log("Error with feed load:", e);
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -338,7 +343,7 @@ export default class DiscoverView extends Vue {
if (response.status !== 200) {
const details = await response.text();
console.log("Problem with nearby search:", details);
console.error("Problem with nearby search:", details);
this.$notify(
{
group: "alert",
@@ -355,10 +360,16 @@ export default class DiscoverView extends Vue {
if (results.data) {
if (beforeId) {
const plans: ProjectData[] = results.data;
const plans: PlanData[] = results.data;
for (const plan of plans) {
const { name, description, handleId = plan.handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
issuerDid,
rowid,
});
}
} else {
this.projects = results.data;
@@ -369,7 +380,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.log("Error with feed load:", e);
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -417,13 +428,15 @@ export default class DiscoverView extends Vue {
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isLocalActive,
"text-blue-600": this.isLocalActive,
"border-blue-600": this.isLocalActive,
"text-black": this.isLocalActive,
"border-black": this.isLocalActive,
"font-semibold": this.isLocalActive,
"text-blue-600": !this.isLocalActive,
"border-transparent": !this.isLocalActive,
"hover:text-slate-600": !this.isLocalActive,
"hover:border-slate-300": !this.isLocalActive,
"hover:border-slate-400": !this.isLocalActive,
};
}
@@ -433,13 +446,15 @@ export default class DiscoverView extends Vue {
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isRemoteActive,
"text-blue-600": this.isRemoteActive,
"border-blue-600": this.isRemoteActive,
"text-black": this.isRemoteActive,
"border-black": this.isRemoteActive,
"font-semibold": this.isRemoteActive,
"text-blue-600": !this.isRemoteActive,
"border-transparent": !this.isRemoteActive,
"hover:text-slate-600": !this.isRemoteActive,
"hover:border-slate-300": !this.isRemoteActive,
"hover:border-slate-400": !this.isRemoteActive,
};
}
}

601
src/views/GiftedDetails.vue Normal file
View File

@@ -0,0 +1,601 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>From {{ giverName || "somebody not named" }}</span>
<span> to {{ recipientName || "somebody not named" }}</span>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !givenToUser"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="!givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToUser"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="
notifyUser('You cannot assign this both a project and also to you.')
"
/>
<label class="text-sm mt-1">This was given to you</label>
</div>
<div class="mt-4 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive, getPlanFromCache } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
@Component({
components: {
ImageMethodDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
destinationNameAfter = "";
givenToProject = false;
givenToUser = false;
giverDid: string | undefined;
giverName = "";
hideBackButton = false;
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
showGivenToUser = false;
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
this.amountInput =
(this.$route.query.amountInput as string) || this.amountInput;
this.description = (this.$route.query.description as string) || "";
this.destinationNameAfter = this.$route.query
.destinationNameAfter as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = (this.$route.query.giverName as string) || "";
this.hideBackButton = this.$route.query.hideBackButton === "true";
this.message = (this.$route.query.message as string) || "";
this.offerId = this.$route.query.offerId as string;
this.projectId = this.$route.query.projectId as string;
this.recipientDid = this.$route.query.recipientDid as string;
this.recipientName = (this.$route.query.recipientName as string) || "";
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
this.imageUrl =
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
// this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.query.shareTitle) {
this.description = this.$route.query.shareTitle as string;
}
if (this.$route.query.shareText) {
this.description =
(this.description ? this.description + " " : "") +
(this.$route.query.shareText as string);
}
if (this.$route.query.shareUrl) {
this.imageUrl = this.$route.query.shareUrl as string;
}
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
if (this.giverDid && !this.giverName) {
this.giverName =
this.giverDid === this.activeDid ? "you" : "someone not named";
}
this.givenToUser = this.recipientDid === this.activeDid;
if (this.recipientDid && !this.recipientName) {
this.recipientName =
this.recipientDid === this.activeDid ? "you" : "someone not named";
}
this.givenToProject = !!this.projectId;
this.givenToUser =
!this.projectId && this.recipientDid === this.activeDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
if (this.projectId) {
// console.log("Getting project name from cache", this.projectId);
const identity = await libsUtil.getIdentity(this.activeDid);
const project = await getPlanFromCache(
this.projectId,
identity,
this.axios,
this.apiServer,
);
console.log("Got project name from cache", project);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back();
}
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "GiveAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const token = await accessToken(identity);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
localStorage.removeItem("imageUrl");
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.description && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive();
}
notifyUser(message: string) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: message,
},
3000,
);
}
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this dialog through a project.",
},
3000,
);
} else {
// must be because givenToUser is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to yourself.",
},
3000,
);
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive() {
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const recipientDid =
this.recipientDid === this.activeDid
? this.givenToUser
? this.activeDid
: undefined
: this.recipientDid;
const projectId = this.givenToProject ? this.projectId : undefined;
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -21,45 +21,392 @@
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p>Here are things to try to get notifications working.</p>
<p>Here are ways to test notifications and get them working.</p>
<h2 class="text-xl font-semibold">Test</h2>
<p>Somehow call the service-worker self.showNotification</p>
<h2 class="text-xl font-semibold mt-4">Full Test</h2>
<div>
<p>
If this works then you're all set.
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
>
Send Yourself a Test Web Push Message (Through Push Server but
Skipping Client Filter)
</button>
</p>
</div>
<h2 class="text-xl font-semibold">Check OS-level permissions</h2>
<h2 class="text-xl font-semibold mt-4">
If this app doesn't support notifications...
<!-- Note that that exact verbiage shows in a message elsewhere. -->
</h2>
<div>
<p>
To be notified of interesting updates, install this app on your device
(as opposed to using it inside the browser app). In Chrome, it may prompt
you, and you can also look for the "Install" command in the browser
settings; on the the desktop, look for this icon in the address bar:
<img
src="../assets/help/chrome-install-pwa.png"
alt="Chrome 'install' icon"
class="ml-4"
/>
</p>
</div>
<h2 class="text-xl font-semibold mt-4">
If you must enable notifications...
<!-- Note that that exact verbiage shows in a message elsewhere. -->
</h2>
<div>
<p>
<button class="text-blue-500" @click="showNotificationChoice()">
Click here.
</button>
</p>
</div>
<h2 class="text-xl font-semibold mt-4">
If you're waiting for system initialization...
<!-- Note that that exact verbiage shows in a message elsewhere. -->
</h2>
<div>
<p>
... and it never stops, then there is a problem with the underlying
service worker or push server mechanism in your browser. Your best bet
is to follow the "Reinstall" steps below or use a different browser.
</p>
</div>
<h2 class="text-xl font-semibold mt-4">Check App Permissions</h2>
<div>
<p>
In Apple iOS, check "Settings" -> "Notifications", look for the Time
Safari app (or the browser you're using), and make sure notifications
are enabled.
</p>
<p>
In Android, hold on to the app icon, then select "App Info", then
"Notifications" and make sure they're enabled. If it's still a problem
then go further:
</p>
<p>
If you installed the app with Chrome, make sure there are no other
tabs with it open. Here are some ways to clear caches that can mess
things up (and note that this clears out data from the installed app
-- which is good to do while the app is installed):
</p>
<ul>
<li class="list-disc ml-4">
Go to Chrome "App Info", then "Storage & Cache" and "Clear Storage".
</li>
<li class="list-disc ml-4">
Go to Chrome "Settings", then "Privacy and Security" and "Clear
"Clear browsing data", then "Cookies and site data". Make sure the
"Time Range" at the top shows "All time".
</li>
</ul>
<p>
On a Mac, go to "Settings" and check "Notifications".
<img
src="../assets/help/mac-installed-app-settings.png"
alt="Mac app settings"
class="ml-4"
/>
</p>
</div>
<h2 class="text-xl font-semibold mt-4">Check Browser Permissions</h2>
<div>
<p>In Apple iOS, check Settings -> Notifications.</p>
<p>In Android, check Settings -> Notifications.</p>
You can find more details about compatibility
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
class="text-blue-500"
target="_blank"
>
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
<h2 class="text-xl font-semibold mt-4">
Check Operating System (OS) Permissions
</h2>
<div class="px-2">
<div>
<h3 class="text-lg font-semibold">Mobile Phone - Apple iOS</h3>
<div>
Notifications require iOS 16.4 or higher. To check your iOS version,
go to Settings > General > About > Software Version.
</div>
<h3 class="text-lg font-semibold">Mobile Phone - Google Android</h3>
<div>
We recommend Chrome. It must be version 42 or higher. Check your
version under Settings -> About Chrome.
</div>
<h3 class="text-lg font-semibold">Desktop - Mac</h3>
<div>
<span>
See "System Settings" -> "Notifications" and make sure it is
enabled for the browser you're using. Note that these
notifications require Mac OS 13; see your macOS version under
Apple -> "About This Mac".
</span>
</div>
<h3 class="text-lg font-semibold">Desktop - Windows</h3>
In Windows, check "Settings" -> "Notifications".
<img
src="../assets/help/windows-system-enable-notifications.png"
alt="Windows system settings"
class="ml-4"
/>
</div>
<div>
You can find more details about compatibility
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
class="text-blue-500"
target="_blank"
>
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
</div>
<h2 class="text-xl font-semibold mt-4">Reinstall</h2>
<div>
<p>
If all else fails, uninstall the app, ensure all the browser tabs with
it are closed, and clear out caches and storage.
</p>
<p>
Of course, you'll want to back up all your data first -- all seeds as
well as the contacts & settings -- on the Account
<fa icon="circle-user" /> page.
</p>
<ul class="ml-4 list-disc">
<li>
Clear cache.
<ul>
<li>
In mobile, look for the browser app settings. This is true even
for an installed app: go to the browser which you used to
initially visit timesafari.app, because those settings affect
the app. Look for "Delete browsing data" in the "Settings",
under "Privacy and Security".
</li>
<li>
In Chrome, go to `chrome://settings/cookies` and "all site data
and permissions" for timesafari.app; in Firefox, go to
`about:preferences` and search for "cache" then "Manage Data"
for timesafari.app. Also manually remove the IndexedDB data if
the DBs still show.)
</li>
</ul>
</li>
<li>
Clear notification permission. (In Chrome, go to
`chrome://settings/content/notifications`; in Firefox, go to
`about:preferences` and search for "notifications".)
</li>
<li>
Unregister service worker. (In Chrome, go to
`chrome://serviceworker-internals/`; in Firefox, go to
`about:serviceworkers`.)
</li>
<li>
Clear "Cache Storage". (In Chrome, in dev tools under "Application";
in Firefox, in dev tools under "Storage".)
</li>
</ul>
<p>Then reinstall the app.</p>
</div>
<h2 class="text-xl font-semibold mt-4">Tests</h2>
<button
@click="showTestNotification()"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
>
Send Test Notification Directly to Device (Not Through Push Server)
</button>
<p>
Walk-throughs & screenshots, maybe for all combinations of OS &
browsers.
If that didn't show a notification on your device, the problem is that
your browser or your operating system are not allowing notifications
through. See "Check App Permissions" and "Check Browser Permissions" and
"Check Operating System (OS) Permissions" above.
</p>
<h2 class="text-xl font-semibold">Check browser-level permissions</h2>
<p>Walk-throughs & screenshots for browser settings</p>
<h2 class="text-xl font-semibold">Explain full reset to start again</h2>
<button
@click="alertWebPushSubscription()"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
>
Show Web Push Subscription Info
</button>
<p>
Walk-throughs for clearing everything & subscribing anew to get a
message
If that showed "null" then the notification is not active.
<button class="text-blue-500" @click="showNotificationChoice()">
Click here.
</button>
</p>
<h2 class="text-xl font-semibold">Auto-detection</h2>
<p>Show results of auto-detection whether they're turned on</p>
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
>
Send Yourself a Test Web Push Message (Through Push Server but Skipping
Client Filter)
</button>
<p>
If that didn't show a notification on your device, there is a problem
getting to the push server. Disable notifications and then enable them
again.
</p>
<button
@click="sendTestWebPushMessage()"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
>
Send Yourself a Test Web Push Message (Through Push Server and Client
Filter)
</button>
<p>
If you don't see a message, it could be that there is nothing new for
you to see. If the previous test worked, then things should work fine.
If you notice a full 24 hours where you get no notification and you know
that there are new items that should show, gather as many details as
possible and go to the bottom of
<router-link to="help" class="text-blue-500"> this page </router-link>
for ways to contact us.
</p>
</div>
<!-- eslint-enable -->
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { sendTestThroughPushServer } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class HelpNotificationsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
subscriptionJSON?: PushSubscriptionJSON;
async mounted() {
try {
const registration = await navigator.serviceWorker.ready;
const fullSub = await registration.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) {
console.error("Mount error:", error);
}
}
alertWebPushSubscription() {
console.log(
"Web push subscription:",
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
);
alert(JSON.stringify(this.subscriptionJSON));
}
async sendTestWebPushMessage(skipFilter: boolean = false) {
if (!this.subscriptionJSON) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not Subscribed",
// Note that this exact verbiage shows in help text.
text: "You must enable notifications before testing the web push.",
},
-1,
);
return;
}
try {
await sendTestThroughPushServer(this.subscriptionJSON, skipFilter);
this.$notify(
{
group: "alert",
type: "success",
title: "Test Web Push Sent",
text:
"Check your device for the test web push message" +
(skipFilter ? "." : " if there are new items in your feed."),
},
-1,
);
} catch (error) {
console.error("Got an error sending test notification:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Sending Test",
text: "Got an error sending the test web push notification.",
},
-1,
);
}
}
showTestNotification() {
const TEST_NOTIFICATION_TITLE = "It Worked";
navigator.serviceWorker.ready
.then((registration) => {
return registration.showNotification(TEST_NOTIFICATION_TITLE, {
body: "This is your test notification.",
});
})
.then(() => {
this.$notify(
{
group: "alert",
type: "success",
title: "Sent",
text: `A notification was triggered, so one should show on your device entitled '${TEST_NOTIFICATION_TITLE}'.`,
},
5000,
);
})
.catch((error) => {
console.error("Got a notification error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Failed",
text: "Got an error sending a notification.",
},
-1,
);
});
}
showNotificationChoice() {
this.$notify(
{
group: "modal",
type: "notification-permission",
title: "", // unused, only here to satisfy type check
text: "", // unused, only here to satisfy type check
},
-1,
);
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<!-- Don't include nav buttons since this is shown in a different window. -->
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Don't include 'back' button since this is shown in a different window. -->
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Time Safari Onboarding Instructions
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<div class="ml-4">
<h1 class="font-bold text-xl">Install</h1>
<div>
<p>
1) Have them visit TimeSafari.app in a browser, preferably Chrome or Safari.
</p>
<p>
2) Have them "Install" the site to their desktop.
</p>
</div>
<h1 class="font-bold text-xl">Add Contact & Register</h1>
<div>
<p>
3) Have them follow their yellow prompts.
</p>
<p>
4) Add them to your contacts <fa icon="users" />
</p>
<p>
5) Register them <fa icon="person-circle-question" />
</p>
<p>
6) Add yourself to their contacts <fa icon="users" />
</p>
</div>
<h1 class="font-bold text-xl">Enable Notifications</h1>
<div>
<p>
7) Enable notifications from <fa icon="circle-user" />
</p>
</div>
<h1 class="font-bold text-xl">Discuss Backups</h1>
<div>
<p>
8) Exporting backups <fa icon="circle-user" /> are important if they lose their phone --- especially for the Identifier Seed!
</p>
</div>
</div>
<!-- eslint enable -->
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class Help extends Vue {}
</script>

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -11,7 +11,7 @@
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>
<fa icon="chevron-left" class="fa-fw" />
</h1>
</div>
@@ -21,38 +21,40 @@
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p>
This app is a window into data that you and your friends own, focused on
gifts and collaboration.
</p>
<h2 class="text-xl font-semibold">What is the philosophy here?</h2>
<h2 class="text-xl font-semibold">What is the idea here?</h2>
<p>
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
to broader work: volunteers can get confirmation of activity and
selectively show off their contributions and network.
First of all, you can see what people have given, and also recognize
gifts you've seen, in a way 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 to broader work: volunteers get
confirmation of activity, and selectively show off their contributions
and network.
</p>
<p>
You can also record projects and plans and invite others to collaborate.
Soon you'll be able to see when others are interested and see how much
they're willing to contribute, even if there are conditions.
You highlight giving and also offer help to ideas -- which could be
conditional on others' willingness to help, too.
You can record your own ideas and invite others to collaborate.
</p>
<p>
This app uses the power of cryptography to build a reputation, recording
activity that you can share at your discretion. You put some activity
public, but your sensitive information is not shared with anyone,
including our services. This is in contrast to Meta and Google, who hold
your data and allow you use it. Those services are useful, but they have
the control; this app gives you the control.
public, but these services don't share your ID with others without explicit consent.
This is in contrast to Meta and Google, who hold
your data and allow you use it while they manage sharing...
those services are useful but they have the control, whereas this app gives you the control.
</p>
<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
You need someone to register you, like the person who told you
about this app, on the Contacts
<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
@@ -62,38 +64,48 @@
register others; later, you can create projects, too.
</p>
<p>
Note that there are limits to how many others each person can register,
so you may have to wait.
Note that there are rate limits to how many others you can register,
so it may take some time to register everyone you want. Take your time...
make it an opportunity to get to know their projects, and show your own.
</p>
<h2 class="text-xl font-semibold">
I had an identifier, but I reinstalled and I got a new one automatically.
How do I restore my old one?
</h2>
<p>
Go
<router-link class="text-blue-500" to="/import-account">import your identifier</router-link>.
If you don't want the old one, click "Advanced" and check the box to erase it.
(The erase option only shows if you have exactly one identifier.
For more in-depth surgery, you'll have to erase data from the browser or reinstall.)
</p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<button class="text-blue-500" @click="showOnboardInfo">
Click here to show an alert with the steps.
</button>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Use these instructions.
</a>
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.
If they are not nearby to scan QR codes, you each can tap on the QR code
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
</p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
There are two sets of data to backup: the identifier secrets and the
other data that isn't quite a secret such as settings, contacts, etc.
There are three sets of data to backup: the identifier secrets;
the non-public textual data that isn't quite a secret such as settings and contacts;
the non-public image for yourself; and the data that you have sent to the public.
</p>
<div class="px-4">
<h2 class="text-xl font-semibold">
How do I backup my identifier (secret) data?
</h2>
<ul class="list-disc list-inside">
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
</li>
@@ -107,9 +119,9 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data?
How do I backup my non-secret, non-public text data?
</h2>
<ul class="list-disc list-inside">
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
</li>
@@ -119,11 +131,32 @@
won't lose it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my non-secret, non-public image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my public data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
This requires use of the API, so investigate the endpoints
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
(particularly the "claim" endpoints).
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
<p>
There are two parts to restore your data: the identity secrets and the
There are two steps to restore your data: the identity secrets, then the
other data such as settings, contacts, etc.
</p>
@@ -131,7 +164,7 @@
<h2 class="text-xl font-semibold">
How do I restore my identifier (secret) data?
</h2>
<ul class="list-disc list-inside">
<ul class="list-disc list-outside ml-4">
<li>
<router-link class="text-blue-500" to="/import-account">
Go to the import page
@@ -143,12 +176,10 @@
<h2 class="text-xl font-semibold">
How do I restore my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<ul class="list-disc list-outside ml-4">
<li>
Make sure you have your backup file (above), then contact us 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.
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
</li>
</ul>
</div>
@@ -163,22 +194,49 @@
</router-link>
</p>
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
<p>
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
Mobile
<ul>
<li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
</li>
<li class="list-disc list-outside ml-4">
DuckDuckGo: long hold -> Clear Data (takes effect immediately)
</li>
</ul>
</li>
<li class="list-disc list-outside ml-4">
Desktop
<ul>
<li class="list-disc list-outside ml-4">
Chrome:
Clear at "chrome://settings/content/all" and
also clear under dev tools Application
</li>
<li class="list-disc list-outside ml-4">
Firefox: Navigate to "about:preferences", Manage Data,
find timesafari.app and select, hit Remove Selected, then Save
Changes
</li>
<li class="list-disc list-outside ml-4">
Safari: Settings -> Privacy -> Manage Website Data, search for
timesafari.app and select, hit Remove Selected, then Done.
</li>
</ul>
</li>
</ul>
<p>To erase your data from our servers, contact us (below).</p>
<h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info?
How do I get higher limits?
</h2>
<p>
If you don't see anything associated with a person, this is typically
because they have not given you permission to see their information. Ask
them to add you to their contact list and make sure the eye next to your
name is open like this
<fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />.
</p>
<p>
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.
Let's talk. Contact us (below).
</p>
<h2 class="text-xl font-semibold">
@@ -191,23 +249,127 @@
<p>
There is a even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" class="text-blue-500">
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com
</a>
</p>
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
<h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info?
</h2>
<p>
See
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
the Endorser Service Privacy Policy.
If you don't see anything associated with a person, this is typically
because they have not given you permission to see their information. Ask
them to add you to their contact list, and ask specifically to make sure
the eye next to your name is open like this
<fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />.
</p>
<p>
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">
Where do I get help with notifications?
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>
</p>
<h2 class="text-xl font-semibold">
My app is misbehaving, like showing me a blank screen or failing to show a feed.
What can I do?
</h2>
<p>
First, note that clearing the cache will clear all your identity and contact info,
so we recommend doing other things first (unless you know you have your backups ready).
</p>
<ul class="list-disc list-outside ml-4">
<li>
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the current version.
You can see the version information at the bottom of this page; the best
way to determine the current version is to open this page in an incognito
browser window and look at the version there.
</li>
<li>
Close all tabs that have Time Safari open; it can be difficult to find them all,
and you may have to close all your tabs. In addition, it may be running as an
installed app, so look for any Time Safari app that may be running outside a browser.
</li>
<li>
There may be a problem with your identity. Go to the Identity
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
and you may see helpful info there. If it shows a problem, try adding your identifier again.
</li>
<li>
It can help to reregister the service worker:
<ul>
<li>
In Chrome, open a tab to
"chrome://serviceworker-internals",
find "timesafari.app", and click "Unregister".</li>
<li>
In Firefox,
open a tab to "about:serviceworkers",
find "timesafari.app", and click "Unregister".
</li>
<li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p>
If you still have problems, you can clear the cache (see "erase my data" above)
and even uninstall and reinstall the app
-- just be sure to have your backups ready or be
prepared to restart with a new identity and recreate your network.
Nobody else has access to your identity or contact information because
this app is designed to give you full control over your data.
</p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center">
This work is public domain, governed by
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"
/>
</a>
<br />
For notifications, this service stores push token data; that can be revoked at any time
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br />
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</p>
<h2 class="text-xl font-semibold">Where can I read more?</h2>
<p>
This is part of the
<a href="https://livesofgiving.org" class="text-blue-500">
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving
</a>
initiative.
@@ -217,46 +379,31 @@
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
For any other questions, including removing your data:
For any other questions, including removing all your data from the public ledger:
</h2>
<p>
Contact us at
<a mailto="info@TimeSafari.app">info@TimeSafari.app</a>
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
>info@TimeSafari.app</a
>
</p>
</div>
<!-- eslint enable -->
</section>
</template>
<script lang="ts">
import * as Package from "../../package.json";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import * as Package from "../../package.json";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
@Component({ components: { QuickNav } })
export default class Help extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
package = Package;
commitHash = process.env.VUE_APP_GIT_HASH;
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,
);
}
commitHash = import.meta.env.VITE_GIT_HASH;
}
</script>

View File

@@ -1,25 +1,93 @@
<template>
<QuickNav selected="Home"></QuickNav>
<QuickNav selected="Home" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-8">
Time Safari
</h1>
<!-- show the actions for recognizing a give -->
<!-- prompt to install notifications -->
<div class="mb-8">
<div
v-if="!activeDid"
v-if="!notificationsSupported()"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<p style="display: inline; align-items: center">
This currently doesn't support notifications, so let's fix that.
<br />
<!-- Note that that exact verbiage shows in the help. -->
<span v-if="userAgentInfo.getOS().name === 'iOS'">
Tap on "Share"<img
src="../assets/help/apple-share-icon.svg"
alt="Apple 'share' icon"
width="30"
style="display: inline; margin: 0 5px; vertical-align: middle"
/>and then "Add to Home Screen"
<fa icon="square-plus" title="Apple 'Add' icon" />
and go click on that new app.
</span>
<span
v-else-if="userAgentInfo.getBrowser()?.name?.startsWith('Chrome')"
>
You should see a prompt to install, or you can click on the
top-right dots
<fa
icon="ellipsis-vertical"
title="vertical ellipsis"
class="fa-fw"
/>
and then "Install"<img
src="../assets/help/install-android-chrome.png"
alt="Android 'install' icon"
width="30"
style="display: inline; margin: 0 5px; vertical-align: middle"
/>
and go use that app. If you already did these steps, reload this app
so that it is fully detected.
</span>
<span v-else>
Try
<a href="https://www.google.com/chrome/" class="text-blue-500"
>Google Chrome</a
>
or look for a way to install as an app from this browser.
</span>
</p>
</div>
</div>
<div v-if="showShortcutBvc" class="mb-4">
<router-link
:to="{ name: 'quick-action-bvc' }"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Bountiful Voluntaryist Community Actions</router-link
>
</div>
<!-- show the actions for recognizing a give -->
<div class="mb-8">
<div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p>
</div>
<div
v-if="!activeDid && !isCreatingIdentifier"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<p class="text-lg mb-3">
You need an <b>identifier</b> before you can record others' giving.
Want to connect with your contacts, or share contributions or
projects?
</p>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Create Your Identifier</router-link
Create An Identifier</router-link
>
</div>
@@ -27,49 +95,45 @@
v-else-if="!isRegistered"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
Someone must register your account before you can record others' giving.
To do this:
Someone must register you before you can give or offer.
<router-link
:to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
1. Show Them Your Identity Info</router-link
>
<router-link
:to="{ name: 'account' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
2. Check Your Limits</router-link
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Show Them Your Identifier Info
</router-link>
</div>
<div v-else>
<!-- activeDid && isRegistered -->
<h2 class="text-xl font-bold mb-4">Record Something Given</h2>
<div class="mb-4">
<h2 class="text-xl font-bold">Record Something Given By:</h2>
</div>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
>
<li @click="openDialog()">
<EntityIcon
:entityId="null"
:iconSize="64"
<img
src="../assets/blank-square.svg"
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
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts"
v-for="contact in allContacts.slice(0, 7)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
@@ -78,111 +142,237 @@
</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 class="flex justify-between">
<router-link
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gift' }"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Choose From All Contacts
</router-link>
<button
@click="openGiftedPrompts()"
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
</div>
</div>
</div>
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<GiftedDialog ref="customDialog" />
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
<!-- Results List -->
<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 }">
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold">Latest Activity</h2>
<button @click="openFeedFilters()" class="block text-center ml-auto">
<span class="text-sm text-white">
<span
v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
>
Filtered
</span>
<span
v-else
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
>
Unfiltered
</span>
</span>
</button>
</div>
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300 py-2"
v-for="record in feedData"
:key="record.jwtId"
>
<div
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
>
You've already seen all the following
</div>
<div class="grid grid-cols-12">
<span class="col-span-1 justify-self-start">
<span>
<fa
v-if="record.giver.known || record.receiver.known"
icon="circle-user"
class="pt-1 text-slate-500"
/>
<fa v-else icon="gift" class="pt-1 pl-3 text-slate-500" />
</span>
</span>
<span class="col-span-10 justify-self-stretch">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span
v-if="
record.giver.profileImageUrl ||
record.receiver.profileImageUrl
"
>
<EntityIcon
v-if="record.agentDid !== activeDid"
:icon-size="32"
:profile-image-url="record.giver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
<fa
v-if="
record.agentDid !== activeDid &&
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
icon="ellipsis"
class="text-slate"
/>
<EntityIcon
v-if="
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
:iconSize="32"
:profile-image-url="record.receiver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
/>
</span>
-->
<span class="pl-2">
{{ giveDescription(record) }}
</span>
<a @click="onClickLoadClaim(record.jwtId)">
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</a>
</span>
<span class="col-span-1 justify-self-end">
<router-link
v-if="record.fulfillsPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.fulfillsPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
<div v-if="record.image" class="flex justify-center">
<a :href="record.image" target="_blank">
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li>
</ul>
</InfiniteScroll>
<div v-if="isFeedLoading">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p>
</div>
<div v-if="!isFeedLoading && feedData.length === 0">
<p class="text-slate-500 text-center italic mt-4 mb-4">
No claims match your filters.
</p>
</div>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300 py-2"
v-for="record in feedData"
:key="record.jwtId"
>
<div
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
v-if="record.jwtId == feedLastViewedId"
>
You've seen all the following
</div>
<div class="flex">
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
<span class="">{{ this.giveDescription(record) }}</span>
<a @click="onClickLoadClaim(record.jwtId)">
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
</a>
</div>
</li>
</ul>
</div>
</section>
</template>
<script lang="ts">
import { UAParser } from "ua-parser-js";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPrompts from "@/components/GiftedPrompts.vue";
import FeedFilters from "@/components/FeedFilters.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import {
BoundingBox,
isAnyFeedFilterOn,
MASTER_SETTINGS_KEY,
Settings,
} from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
didInfo,
GiverInputInfo,
GiveServerRecord,
contactForDid,
containsNonHiddenDid,
didInfoForContact,
fetchEndorserRateLimits,
getPlanFromCache,
GiverReceiverInputInfo,
GiveSummaryRecord,
} 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";
import { Account } from "@/db/tables/accounts";
import { generateSaveAndActivateIdentity } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
giver: {
displayName: string;
known: boolean;
profileImageUrl?: string;
};
image?: string;
recipientProjectName?: string;
receiver: {
displayName: string;
known: boolean;
profileImageUrl?: string;
};
}
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
components: {
GiftedDialog,
GiftedPrompts,
FeedFilters,
QuickNav,
EntityIcon,
InfiniteScroll,
TopMessage,
},
})
export default class HomeView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
feedAllLoaded = false;
feedData = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedId?: string;
isHiddenSpinner = true;
feedLastViewedClaimId?: string;
isAnyFeedFilterOn: boolean;
isCreatingIdentifier = false;
isFeedFilteredByVisible = false;
isFeedFilteredByNearby = false;
isFeedLoading = true;
isRegistered = false;
numAccounts = 0;
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
}> = [];
showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) {
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
@@ -201,7 +391,7 @@ export default class HomeView extends Vue {
return headers;
}
async created() {
async mounted() {
try {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
@@ -212,11 +402,50 @@ export default class HomeView extends Vue {
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedId = settings?.lastViewedClaimId;
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
this.isRegistered = !!settings?.isRegistered;
this.updateAllFeed();
this.searchBoxes = settings?.searchBoxes || [];
this.showShortcutBvc = !!settings?.showShortcutBvc;
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true;
this.activeDid = await generateSaveAndActivateIdentity();
this.allMyDids = [this.activeDid];
this.isCreatingIdentifier = false;
}
// someone may have have registered after sharing contact info
if (!this.isRegistered && this.activeDid) {
const identity = await this.getIdentity(this.activeDid);
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
identity as IIdentifier,
);
if (resp.status === 200) {
// we just needed to know that they're registered
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true,
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
// this returns a Promise but we don't need to wait for it
await this.updateAllFeed();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings or feed.", err);
this.$notify(
{
group: "alert",
@@ -224,87 +453,201 @@ export default class HomeView extends Vue {
title: "Error",
text:
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.",
"There was an error retrieving your settings or the latest activity.",
},
-1,
);
}
}
resultsAreFiltered() {
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
}
notificationsSupported() {
return "Notification" in window;
}
public async buildHeaders() {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find(
(acc) => acc.did === this.activeDid,
) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
if (identity) {
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
// only called when a setting was changed
async reloadFeedOnChange() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
this.feedData = [];
this.feedPreviousOldestId = undefined;
this.updateAllFeed();
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
public async loadMoreGives(payload: boolean) {
// Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading.
if (payload && !this.isFeedLoading) {
this.updateAllFeed();
}
}
latLongInAnySearchBox(lat: number, long: number) {
for (const boxInfo of this.searchBoxes) {
if (
boxInfo.bbox.westLong <= long &&
long <= boxInfo.bbox.eastLong &&
boxInfo.bbox.minLat <= lat &&
lat <= boxInfo.bbox.maxLat
) {
return true;
}
}
}
public async updateAllFeed() {
this.isHiddenSpinner = false;
await this.retrieveClaims(this.apiServer, this.feedPreviousOldestId)
this.isFeedLoading = true;
let endOfResults = true;
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
.then(async (results) => {
if (results.data.length > 0) {
this.feedData = this.feedData.concat(results.data);
this.feedAllLoaded = results.hitLimit;
endOfResults = false;
// include the descriptions of the giver and receiver
const identity = await this.getIdentity(this.activeDid);
for (const record: GiveSummaryRecord of results.data) {
// 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 = (record.fullClaim as any).claim || record.fullClaim;
// agent.did is for legacy data, before March 2023
const giverDid =
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
// recipient.did is for legacy data, before March 2023
const recipientDid =
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
// This has indeed proven problematic. See loadMoreGives
// We should display it immediately and then get the plan later.
const plan = await getPlanFromCache(
record.fulfillsPlanHandleId,
identity,
this.axios,
this.apiServer,
);
// check if the record should be filtered out
let anyMatch = false;
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
// has a visible DID so it's a keeper
anyMatch = true;
}
if (!anyMatch && this.isFeedFilteredByNearby) {
// check if the associated project has a location inside user's search box
if (record.fulfillsPlanHandleId) {
if (plan?.locLat && plan?.locLon) {
if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
anyMatch = true;
}
}
}
}
if (this.isAnyFeedFilterOn && !anyMatch) {
continue;
}
const newRecord: GiveRecordWithContactInfo = {
...record,
giver: didInfoForContact(
giverDid,
this.activeDid,
contactForDid(giverDid, this.allContacts),
this.allMyDids,
),
image: claim.image,
recipientProjectName: plan?.name as string,
receiver: didInfoForContact(
recipientDid,
this.activeDid,
contactForDid(recipientDid, this.allContacts),
this.allMyDids,
),
};
this.feedData.push(newRecord);
}
this.feedPreviousOldestId =
results.data[results.data.length - 1].jwtId;
// The following update is only done on the first load.
if (
this.feedLastViewedId == null ||
this.feedLastViewedId < results.data[0].jwtId
this.feedLastViewedClaimId == null ||
this.feedLastViewedClaimId < results.data[0].jwtId
) {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
lastViewedClaimId: results.data[0].jwtId,
});
// but not for this page because we need to remember what it was before
}
}
})
.catch((e) => {
console.log("Error with feed load:", e);
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Export Error",
title: "Feed Error",
text: e.userMessage || "There was an error retrieving feed data.",
},
-1,
);
});
this.isHiddenSpinner = true;
if (this.feedData.length === 0 && !endOfResults) {
// repeat until there's at least some data
this.updateAllFeed();
}
this.isFeedLoading = false;
}
public async retrieveClaims(endorserApiServer: string, beforeId?: string) {
/**
* Retrieve claims in reverse chronological order
*
* @param beforeId the earliest ID (of previous searches) to search earlier
* @return claims in reverse chronological order
*/
public async retrieveGives(endorserApiServer: string, beforeId?: string) {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const response = await fetch(
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
endorserApiServer +
"/api/v2/report/gives?giftNotTrade=true" +
beforeQuery,
{
method: "GET",
headers: await this.buildHeaders(),
},
);
if (response.status !== 200) {
if (!response.ok) {
throw await response.text();
}
@@ -317,53 +660,75 @@ export default class HomeView extends Vue {
}
}
giveDescription(giveRecord: GiveServerRecord) {
giveDescription(giveRecord: GiveRecordWithContactInfo) {
// 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
// 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.allMyDids,
this.allContacts,
);
let gaveAmount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: "";
if (claim.description) {
if (gaveAmount) {
gaveAmount = gaveAmount + ", and also: ";
gaveAmount = " (and " + gaveAmount + ")";
}
gaveAmount = gaveAmount + claim.description;
gaveAmount = claim.description + gaveAmount;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// recipient.did is for legacy data, before March 2023
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.allMyDids,
this.allContacts,
)
: "";
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
/**
* Only show giver and/or receiver info first if they're named.
* - If only giver is named, show "... gave"
* - If only receiver is named, show "... received"
*/
const giverInfo = giveRecord.giver;
const recipientInfo = giveRecord.receiver;
if (giverInfo.known && recipientInfo.known) {
// both giver and recipient are named
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
} else if (giverInfo.known) {
// giver is named but recipient is not
// show the project name if to one
if (giveRecord.recipientProjectName) {
// retrieve the project name
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
}
// it's not to a project
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
} else if (recipientInfo.known) {
// recipient is named but giver is not
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
} else {
// neither giver nor recipient are named
// show the project name if to one
if (giveRecord.recipientProjectName) {
// retrieve the project name
return `${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
}
// it's not to a project
let peopleInfo;
if (giverInfo.displayName === recipientInfo.displayName) {
peopleInfo = `between two who are ${giverInfo.displayName}`;
} else {
peopleInfo = `from ${giverInfo.displayName} to ${recipientInfo.displayName}`;
}
return gaveAmount + " (" + peopleInfo + ")";
}
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
(this.$router as Router).push(route);
}
displayAmount(code: string, amt: number) {
@@ -374,8 +739,24 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
openDialog(giver?: GiverReceiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "you",
},
undefined,
"Given by " + (giver?.name || "someone not named"),
);
}
openGiftedPrompts() {
(this.$refs.giftedPrompts as GiftedPrompts).open();
}
openFeedFilters() {
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -18,16 +18,23 @@
<!-- 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">
<div
v-if="activeDid && !activeDidInIdentities"
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4"
>
<fa icon="circle-check" class="fa-fw text-red-600 text-xl mr-3"></fa>
<div class="text-sm text-slate-500">
<div class="overflow-hidden truncate">
<b>ID:</b> <code>{{ activeDid }}</code>
</div>
</span>
<b
>There is a data corruption error: this identity is selected but it is
not in storage. You cannot send any more claims with this identity
until you import the seed again. This may require reinstalling the
app; if you know how, you can also clear out the TimeSafariAccounts
IndexedDB. Be sure to back up all your Settings & Contacts first.</b
>
</div>
</div>
<!-- Other Identity/ies -->
@@ -38,7 +45,12 @@
:key="ident.did"
@click="switchAccount(ident.did)"
>
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa>
<fa
v-if="ident.did === activeDid"
icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3"
/>
<fa v-else icon="circle" class="fa-fw text-slate-400 text-xl mr-3" />
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0"></h2>
<div class="text-sm text-slate-500 truncate">
@@ -53,13 +65,13 @@
<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"
class="block text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
>
Add Another Identity&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"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-8"
@click="switchAccount('0')"
>
No Identity
@@ -68,42 +80,26 @@
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { AppString } from "@/constants/app";
import { AppString, NotificationIface } 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;
$notify!: (notification: NotificationIface, timeout?: number) => void;
Constants = AppString;
public accounts: typeof AccountsSchema;
public activeDid = "";
public activeDidInIdentities = false;
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();
@@ -111,24 +107,20 @@ export default class IdentitySwitcherView extends Vue {
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) {
try {
const did = accounts[n]["did"];
this.otherIdentities.push({ did: did });
if (did && this.activeDid === did) {
this.activeDidInIdentities = true;
}
} catch (err) {
console.error("Error parsing identity:", err);
continue;
}
}
} catch (err) {
@@ -154,16 +146,6 @@ export default class IdentitySwitcherView extends Vue {
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" });
}
}

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -10,22 +10,22 @@
>
<fa icon="chevron-left"></fa>
</button>
Import Existing Identity
Import Existing Identifier
</h1>
</div>
<!-- Import Account Form -->
<p class="text-center text-xl mb-4 font-light">
Enter your seed phrase below to import your identity on this device.
Enter your seed phrase below to import your identifier on this device.
</p>
<!-- id used by puppeteer test script -->
<input
<textarea
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"
@@ -36,41 +36,56 @@
Enter a custom derivation path
<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"
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>
<span class="ml-4">
For previous uPort or Endorser users,
<a
@click="derivationPath = UPORT_DERIVATION_PATH"
class="text-blue-500"
>
click here to use that value.
</a>
</span>
<div class="mt-4" v-if="numAccounts == 1">
<input type="checkbox" class="mr-2" v-model="shouldErase" />
<label>Erase the previous identifier.</label>
</div>
</div>
<div class="mt-8">
<button
@click="from_mnemonic()"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
Import
</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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="fromMnemonic()"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
} from "@/libs/crypto";
@Component({
components: {},
@@ -78,20 +93,29 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
export default class ImportAccountView extends Vue {
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
$notify!: (notification: NotificationIface, timeout?: number) => void;
mnemonic = "";
address = "";
numAccounts = 0;
privateHex = "";
publicHex = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
showAdvanced = false;
shouldErase = false;
async created() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public onCancelClick() {
this.$router.back();
}
public async from_mnemonic() {
public async fromMnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
if (this.mnemonic.trim().length > 0) {
try {
[this.address, this.privateHex, this.publicHex] = deriveAddress(
mne,
this.derivationPath,
@@ -104,25 +128,48 @@ export default class ImportAccountView extends Vue {
this.derivationPath,
);
try {
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: this.derivationPath,
did: newId.did,
identity: JSON.stringify(newId),
mnemonic: mne,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await accountsDB.open();
if (this.shouldErase) {
await accountsDB.accounts.clear();
}
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: this.derivationPath,
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);
// record that as the active DID
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
this.$router.push({ name: "account" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error saving mnemonic & updating settings:", err);
if (err == "Error: invalid mnemonic") {
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Mnemonic",
text: "Please check your mnemonic and try again.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error creating that identifier.",
},
-1,
);
}
}
}

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -49,19 +49,21 @@
</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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="incrementDerivation()"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Increment and Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>
@@ -72,6 +74,7 @@ import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
nextDerivationPath,
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@@ -121,17 +124,7 @@ export default class ImportAccountView extends Vue {
}
});
// 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 newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
const mne: string = accountWithMaxDeriv.mnemonic;

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -22,21 +22,23 @@
/>
<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 class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md 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-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</section>
</template>

View File

@@ -1,67 +0,0 @@
<template>
<!-- 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">
<!-- Cancel -->
<router-link
:to="{ name: 'project' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa>
</router-link>
Make Commitment
</h1>
</div>
<!-- Project Details -->
<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
>
</div>
<!-- Crypto 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>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
@Component({
components: {},
})
export default class NewEditCommitmentView extends Vue {}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<QuickNav selected="Projects"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -29,6 +29,44 @@
v-model="fullClaim.name"
/>
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<input
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid"
/>
<div class="mb-4">
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
<span class="text-red-500">Beware!</span>
If you save this, the original project owner will no longer be able to
edit it.
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
Click here to make the original owner an authorized representative.
</button>
</p>
</div>
<textarea
placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
@@ -37,15 +75,33 @@
maxlength="5000"
></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description.length }}/5000 max. characters
{{ fullClaim.description?.length }}/5000 max. characters
</div>
<input
v-model="fullClaim.url"
placeholder="Website"
autocapitalize="none"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="flex mb-4 columns-3 w-full">
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time"
type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/>
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
</div>
<div class="flex items-center mb-4">
<input
type="checkbox"
@@ -55,16 +111,17 @@
/>
<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>
<div v-if="includeLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at the
place.
</p>
<l-map
ref="map"
v-model:zoom="zoom"
:center="[0, 0]"
class="!z-40 rounded-md"
@click="
(event) => {
latitude = event.latlng.lat;
@@ -80,34 +137,36 @@
<l-marker
v-if="latitude && longitude"
:lat-lng="[latitude, longitude]"
@click="maybeEraseLatLong()"
@click="confirmEraseLatLong()"
/>
</l-map>
</div>
<div class="mt-8">
<button
:disabled="isHiddenSave"
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="onSaveProjectClick()"
>
<!-- SHOW if in idle state -->
<span :class="{ hidden: isHiddenSave }">Save Project</span>
<!-- SHOW if in saving state; DISABLE button while in saving state -->
<span :class="{ hidden: isHiddenSpinner }">
<!-- icon no worky? -->
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving&hellip;</span
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
:disabled="isHiddenSave"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onSaveProjectClick()"
>
</button>
<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="onCancelClick()"
>
Cancel
</button>
<!-- SHOW if in idle state -->
<span :class="{ hidden: isHiddenSave }">Save Project</span>
<!-- SHOW if in saving state; DISABLE button while in saving state -->
<span :class="{ hidden: isHiddenSpinner }">
<!-- icon no worky? -->
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving...</span
>
</button>
<button
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onCancelClick()"
>
Cancel
</button>
</div>
</div>
</section>
</template>
@@ -116,31 +175,35 @@
import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import { DateTime } from "luxon";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import * as libsUtil from "@/libs/util";
import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
import { PlanVerifiableCredential } from "@/libs/endorserServer";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
@Component({
components: { LMap, LMarker, LTileLayer, QuickNav },
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = "";
agentDid = "";
apiServer = "";
errorMessage = "";
fullClaim: PlanVerifiableCredential = {
@@ -149,16 +212,22 @@ export default class NewEditProjectView extends Vue {
name: "",
description: "",
}; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false;
isHiddenSave = false;
isHiddenSpinner = true;
lastClaimJwtId = "";
latitude = 0;
longitude = 0;
numAccounts = 0;
projectId = localStorage.getItem("projectId") || "";
projectIssuerDid = "";
startDateInput?: string;
startTimeInput?: string;
zoneName = DateTime.local().zoneName;
zoom = 2;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
libsUtil = libsUtil;
public async getIdentity(activeDid: string) {
await accountsDB.open();
@@ -166,11 +235,11 @@ export default class NewEditProjectView extends Vue {
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identity available.",
"Attempted to load project records with no identifier available.",
);
}
return identity;
@@ -185,32 +254,31 @@ export default class NewEditProjectView extends Vue {
return headers;
}
projectId = localStorage.getItem("projectId") || "";
isHiddenSave = false;
isHiddenSpinner = true;
async mounted() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
if (this.projectId) {
if (this.numAccounts === 0) {
console.error("Error: no account was found.");
this.errNote("There was a problem loading your account info.");
} else {
const identity = await this.getIdentity(this.activeDid);
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
this.LoadProject(identity);
this.loadProject(identity);
}
}
}
async LoadProject(identity: IIdentifier) {
async loadProject(identity: IIdentifier) {
const url =
this.apiServer +
"/api/claim/byHandle/" +
@@ -224,23 +292,126 @@ export default class NewEditProjectView extends Vue {
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) {
this.includeLocation = true;
this.latitude = this.fullClaim.location.geo.latitude;
this.longitude = this.fullClaim.location.geo.longitude;
}
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
).toLocal();
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
}
} catch (error) {
console.error("Got error retrieving that project", error);
this.errNote("There was an error retrieving that project.");
}
}
private async SaveProject(identity: IIdentifier) {
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const token = await accessToken(identity);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
return;
}
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
private async saveProject(identity: IIdentifier) {
// Make a claim
const vcClaim: PlanVerifiableCredential = this.fullClaim;
if (this.projectId) {
vcClaim.identifier = this.projectId;
vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
vcClaim.agent = {
identifier: this.agentDid,
};
} else {
delete vcClaim.agent;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
}
if (this.includeLocation) {
vcClaim.location = {
@@ -250,6 +421,30 @@ export default class NewEditProjectView extends Vue {
longitude: this.longitude,
},
};
} else {
delete vcClaim.location;
}
if (this.startDateInput) {
try {
const startTimeFull = this.startTimeInput || "00:00:00";
const fullTimeString = this.startDateInput + " " + startTimeFull;
// throw an error on an invalid date or time string
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.startTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "The date was invalid so it was not set.",
},
5000,
);
}
} else {
delete vcClaim.startTime;
}
// Make a payload for the claim
const vcPayload = {
@@ -283,25 +478,16 @@ 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://api.endorser.ch/api-docs/
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
if (resp.data?.success?.handleId) {
this.errorMessage = "";
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://api.endorser.ch/api-docs/
useAppStore().setProjectId(
resp.data.success.handleId || resp.data.success.fullIri,
);
setTimeout(
function (that: NewEditProjectView) {
that.$router.push({ name: "project" });
},
2000,
this,
);
useAppStore()
.setProjectId(resp.data.success.handleId)
.then(() => {
this.$router.push({ name: "project" });
});
} else {
console.log(
console.error(
"Got unexpected 'data' inside response from server",
resp,
);
@@ -321,9 +507,11 @@ export default class NewEditProjectView extends Vue {
error?: { message?: string };
}>;
if (serverError) {
console.log("Got error from server", serverError);
console.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
userMessage =
(serverError.response?.data?.error?.message as string) ||
userMessage;
this.$notify(
{
group: "alert",
@@ -373,16 +561,29 @@ export default class NewEditProjectView extends Vue {
console.error("Error: there is no account.");
} else {
const identity = await this.getIdentity(this.activeDid);
this.SaveProject(identity);
this.saveProject(identity);
}
}
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;
}
confirmEraseLatLong() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
public eraseLatLong() {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
}
public onCancelClick() {

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -54,9 +54,7 @@
<script lang="ts">
import "dexie-export-import";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db/index";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { generateSaveAndActivateIdentity } from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
@@ -64,31 +62,10 @@ export default class NewIdentifierView extends Vue {
loading = true;
async mounted() {
const mnemonic = generateSeed();
// address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] =
deriveAddress(mnemonic);
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
await generateSaveAndActivateIdentity();
this.loading = false;
setTimeout(() => {
this.$router.push({ name: "account" });
this.$router.push({ name: "home" });
}, 1000);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,51 @@
<template>
<QuickNav selected="Projects"></QuickNav>
<section id="Content" class="p-6 pb-24">
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Ideas
</h1>
<!-- Quick Search -->
<!-- Result Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
<a
href="#"
@click="
offers = [];
projects = [];
showOffers = true;
showProjects = false;
loadOffers();
"
v-bind:class="computedOfferTabClassNames()"
>
Offers
</a>
</li>
<li>
<a
href="#"
@click="
offers = [];
projects = [];
showOffers = false;
showProjects = true;
loadProjects();
"
v-bind:class="computedProjectTabClassNames()"
>
Projects
</a>
</li>
</ul>
</div>
<!-- Quick Search -->
<!--
<div id="QuickSearch" class="mb-4 flex">
<input
type="text"
@@ -20,10 +58,12 @@
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</div>
-->
<!-- New Project -->
<button
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
v-if="isRegistered && showProjects"
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
<fa icon="plus" class="fa-fw"></fa>
@@ -37,8 +77,123 @@
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<!-- Offer Results List -->
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
<div v-if="offers.length === 0" class="text-center py-4">
You have not offered anything.
<br />
<router-link to="/discover" class="text-blue-600">
Look for projects worth some of your time.
</router-link>
</div>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="offer in offers"
:key="offer.handleId"
>
<div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon
:entityId="offer.fulfillsPlanHandleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon
:entityId="offer.recipientDid"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
/>
</div>
<div>
<div>
{{ offer.objectDescription }}
</div>
<span class="text-sm">
<span v-if="offer.amount">
<fa
:icon="libsUtil.iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>
<span v-if="offer.amountGiven >= offer.amount">
<fa icon="check-circle" class="fa-fw text-green-500" />
All {{ offer.amount }} given
</span>
<span v-else>
<fa
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
{{ offer.amountGiven ? "" : "All" }}
{{ offer.amount - (offer.amountGiven || 0) }} remaining
</span>
<span v-if="offer.amountGiven > 0">
<span class="text-sm text-slate-400">
({{ offer.amountGiven }} given,
<span
v-if="offer.amountGivenConfirmed >= offer.amountGiven"
>
<!-- no need for green icon; unnecessary if there's already a green, confusing if there's a yellow -->
all
</span>
<span v-else>
<!-- only show icon if there's not already a warning -->
<fa
v-if="offer.amountGiven >= offer.amount"
icon="triangle-exclamation"
class="fa-fw text-yellow-300"
/>
{{ offer.amountGivenConfirmed || 0 }}
</span>
of that is confirmed)
</span>
</span>
</span>
<span v-else>
<!-- Non-amount offer -->
<span v-if="offer.nonAmountGivenConfirmed">
<fa icon="check-circle" class="fa-fw text-green-500" />
{{ offer.nonAmountGivenConfirmed }}
{{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }}
are confirmed.
</span>
<span v-else>
<fa
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
<span class="text-sm">Not confirmed by anyone</span>
</span>
</span>
<a @click="onClickLoadClaim(offer.jwtId)">
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
></fa>
</a>
</span>
</div>
</div>
</li>
</ul>
</InfiniteScroll>
<!-- Project Results List -->
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
<div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects.
<br />
Hit the big
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" />
button. You'll never know until you try.
</div>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
@@ -49,12 +204,13 @@
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
>
<div class="flex-none w-12">
<EntityIcon
<div class="flex-none">
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></EntityIcon>
:imageUrl="project.image"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div class="grow overflow-hidden">
@@ -71,38 +227,67 @@
</template>
<script lang="ts">
import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as libsUtil from "@/libs/util";
import { IIdentifier } from "@veramo/core";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue";
import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
import { ProjectData } from "@/libs/endorserServer";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { InfiniteScroll, QuickNav, EntityIcon },
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
})
export default class ProjectsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
apiServer = "";
projects: ProjectData[] = [];
current: IIdentifier;
projects: PlanData[] = [];
currentIid: IIdentifier;
isLoading = false;
isRegistered = false;
numAccounts = 0;
offers: OfferSummaryRecord[] = [];
showOffers = true;
showProjects = false;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
libsUtil = libsUtil;
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid: string = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) {
console.error("No accounts found.");
this.errNote("You need an identifier to load your projects.");
} else {
this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers();
}
} catch (err) {
console.error("Error initializing:", err);
this.errNote("Something went wrong loading your projects.");
}
}
/**
@@ -110,7 +295,7 @@ export default class ProjectsView extends Vue {
* @param url the url used to fetch the data
* @param token Authorization token
**/
async dataLoader(url: string, token: string) {
async projectDataLoader(url: string, token: string) {
const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
@@ -118,37 +303,32 @@ export default class ProjectsView extends Vue {
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 || !resp.data.data) {
const plans: ProjectData[] = resp.data.data;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
});
}
} else {
console.log("Bad server response & data:", resp.status, resp.data);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get projects from the server. Try again later.",
},
-1,
console.error(
"Bad server response & data for plans:",
resp.status,
resp.data,
);
this.errNote("Failed to get projects from the server.");
}
// 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,
);
console.error("Got error loading plans:", error.message || error);
this.errNote("Got an error loading projects.");
} finally {
this.isLoading = false;
}
@@ -158,15 +338,44 @@ export default class ProjectsView extends Vue {
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
const url = `${this.apiServer}/api/v2/report/plansByIssuer?beforeId=${latestProject.rowid}`;
const token = await accessToken(this.current);
await this.dataLoader(url, token);
await this.loadProjects(
this.currentIid,
`beforeId=${latestProject.rowid}`,
);
}
}
/**
* Load projects initially
* @param identifier of the user
* @param urlExtra additional url parameters in a string
**/
async loadProjects(identifier?: IIdentifier, urlExtra: string = "") {
const identity = identifier || this.currentIid;
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
const token: string = await accessToken(identity);
await this.projectDataLoader(url, token);
}
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
/**
* Handle clicking on a project entry found in the list
* @param id of the project
@@ -179,72 +388,6 @@ export default class ProjectsView extends Vue {
this.$router.push(route);
}
/**
* Load projects initially
* @param identity of the user
**/
async LoadProjects(identity: IIdentifier) {
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
const token: string = await accessToken(identity);
await this.dataLoader(url, token);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identity available.",
);
}
return identity;
}
/**
* 'created' hook runs when the Vue instance is first created
**/
async created() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
if (this.numAccounts === 0) {
console.error("No accounts found.");
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You need an identity to load your projects.",
},
-1,
);
} else {
const identity = await this.getIdentity(activeDid);
this.current = identity;
this.LoadProjects(identity);
}
} catch (err) {
console.log("Error initializing:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
}
}
/**
* Handling clicking on the new project button
**/
@@ -255,5 +398,120 @@ export default class ProjectsView extends Vue {
};
this.$router.push(route);
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
/**
* Core offer data loader
* @param url the url used to fetch the data
* @param token Authorization token
**/
async offerDataLoader(url: string, token: string) {
const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
this.offers = this.offers.concat(resp.data.data);
} else {
console.error(
"Bad server response & data for offers:",
resp.status,
resp.data,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get offers from the server. Try again later.",
},
-1,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading offers:", error.message || error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading offers.",
},
-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 loadMoreOfferData(payload: boolean) {
if (this.offers.length > 0 && payload) {
const latestOffer = this.offers[this.offers.length - 1];
await this.loadOffers(this.currentIid, `&beforeId=${latestOffer.jwtId}`);
}
}
/**
* Load offers initially
* @param identifier of the user
* @param urlExtra additional url parameters in a string
**/
async loadOffers(identifier?: IIdentifier, urlExtra: string = "") {
const identity = identifier || this.currentIid;
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${identity.did}${urlExtra}`;
const token: string = await accessToken(identity);
await this.offerDataLoader(url, token);
}
public computedOfferTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.showOffers,
"text-black": this.showOffers,
"border-black": this.showOffers,
"font-semibold": this.showOffers,
"text-blue-600": !this.showOffers,
"border-transparent": !this.showOffers,
"hover:border-slate-400": !this.showOffers,
};
}
public computedProjectTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.showProjects,
"text-black": this.showProjects,
"border-black": this.showProjects,
"font-semibold": this.showProjects,
"text-blue-600": !this.showProjects,
"border-transparent": !this.showProjects,
"hover:border-slate-400": !this.showProjects,
};
}
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4">
Beginning of BVC Saturday Meeting
</h1>
<div>
<h2 class="text-2xl m-2">You're Here</h2>
<div class="m-2 flex">
<input type="checkbox" v-model="attended" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Attended</span>
</div>
<div class="m-2 flex">
<input type="checkbox" v-model="gaveTime" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Spent Time</span>
<span v-if="gaveTime">
<input
type="text"
placeholder="How much time"
v-model="hoursStr"
size="1"
class="border border-slate-400 h-6 px-2"
/>
hour(s)
</span>
<!-- This is to match input height to avoid shifting when hiding & showing. -->
<span v-else class="h-6" />
</div>
</div>
<div
v-if="attended || (gaveTime && hoursStr && hoursStr != '0')"
class="flex justify-center mt-4"
>
<button
@click="record()"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
>
Sign & Send
</button>
</div>
<div v-else class="flex justify-center mt-4">
<button
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
>
Select Your Actions
</button>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { DateTime } from "luxon";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import {
BVC_MEETUPS_PROJECT_CLAIM_ID,
bvcMeetingJoinClaim,
createAndSubmitClaim,
createAndSubmitGive,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class QuickActionBvcBeginView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
attended = true;
gaveTime = true;
hoursStr = "1";
todayOrPreviousStartDate = "";
async mounted() {
// use the time zone for Bountiful
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
if (currentOrPreviousSat.weekday < 6) {
// it's not Saturday or Sunday,
// so move back one week before setting to the Saturday
currentOrPreviousSat = currentOrPreviousSat.minus({ week: 1 });
}
const eventStartDateObj = currentOrPreviousSat
.set({ weekday: 6 })
.set({ hour: 9 })
.startOf("hour");
// Hack, but full ISO pushes the length to 340 which crashes verifyJWT!
this.todayOrPreviousStartDate =
eventStartDateObj.toISO({
suppressMilliseconds: true,
}) || "";
}
async record() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
const activeDid = settings?.activeDid || "";
const apiServer = settings?.apiServer || "";
try {
const hoursNum = libsUtil.numberOrZero(this.hoursStr);
const identity = await libsUtil.getIdentity(activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// first send the claim for time given
let timeSuccess = false;
if (this.gaveTime && hoursNum > 0) {
const timeResult = await createAndSubmitGive(
axios,
apiServer,
identity,
activeDid,
undefined,
undefined,
hoursNum,
"HUR",
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
if (timeResult.type === "success") {
timeSuccess = true;
} else {
console.error("Error sending time:", timeResult);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
timeResult?.error?.userMessage ||
"There was an error sending the time.",
},
5000,
);
}
}
// now send the claim for attendance
let attendedSuccess = false;
if (this.attended) {
const attendResult = await createAndSubmitClaim(
bvcMeetingJoinClaim(activeDid, this.todayOrPreviousStartDate),
identity,
apiServer,
axios,
);
if (attendResult.type === "success") {
attendedSuccess = true;
} else {
console.error("Error sending attendance:", attendResult);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
attendResult?.error?.userMessage ||
"There was an error sending the attendance.",
},
5000,
);
}
}
if (timeSuccess || attendedSuccess) {
const actions =
timeSuccess && attendedSuccess
? "Your attendance and time have been recorded."
: timeSuccess
? "Your time has been recorded."
: "Your attendance has been recorded.";
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: actions,
},
3000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error sending claims.", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: error.userMessage || "There was an error sending the claims.",
},
5000,
);
}
}
}
</script>

View File

@@ -0,0 +1,391 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4">
End of BVC Saturday Meeting
</h1>
<div>
<h2 class="text-2xl m-2">Confirm</h2>
<div v-if="loadingConfirms" class="flex justify-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else-if="claimsToConfirm.length === 0">
There are no claims yet today for you to confirm.
</div>
<ul class="border-t border-slate-300 m-2">
<li
class="border-b border-slate-300 py-2"
v-for="record in claimsToConfirm"
:key="record.id"
>
<div class="grid grid-cols-12">
<span class="col-span-11 justify-self-start">
<span>
<input
type="checkbox"
:checked="claimsToConfirmSelected.includes(record.id)"
@click="
claimsToConfirmSelected.includes(record.id)
? claimsToConfirmSelected.splice(
claimsToConfirmSelected.indexOf(record.id),
1,
)
: claimsToConfirmSelected.push(record.id)
"
class="mr-2 h-6 w-6"
/>
</span>
{{
claimSpecialDescription(
record,
activeDid,
allMyDids,
allContacts,
)
}}
<a @click="onClickLoadClaim(record.id)">
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</a>
</span>
</div>
</li>
</ul>
</div>
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
<span>
{{
claimCountWithHidden === 1
? "There is 1 other claim with hidden details,"
: `There are ${claimCountWithHidden} other claims with hidden details,`
}}
so if you expected but do not see details from someone then ask them to
check that their activity is visible to you on their Contacts
<fa icon="users" class="text-slate-500" />
page.
</span>
</div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
<span>
{{
claimCountByUser === 1
? "There is 1 other claim by you"
: `There are ${claimCountByUser} other claims by you`
}}
which you don't need to confirm.
</span>
</div>
<div>
<h2 class="text-2xl m-2">Anything else?</h2>
<div class="m-2 flex">
<input type="checkbox" v-model="someoneGave" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Someone else gave</span>
<span v-if="someoneGave">
<input
type="text"
v-model="description"
size="20"
class="border border-slate-400 h-6 px-2"
/>
<br />
(Everyone likes personalized messages! 😁)
</span>
<!-- This is to match input height to avoid shifting when hiding & showing. -->
<span v-else class="h-6">...</span>
</div>
</div>
<div
v-if="claimsToConfirmSelected.length || (someoneGave && description)"
class="flex justify-center mt-4"
>
<button
@click="record()"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
>
Sign & Send
</button>
</div>
<div v-else class="flex justify-center mt-4">
<button
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
>
Choose What To Confirm
</button>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { DateTime } from "luxon";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
BVC_MEETUPS_PROJECT_CLAIM_ID,
claimSpecialDescription,
containsHiddenDid,
createAndSubmitConfirmation,
createAndSubmitGive,
ErrorResult,
GenericCredWrapper,
GenericVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
@Component({
methods: { claimSpecialDescription },
components: {
QuickNav,
TopMessage,
},
})
export default class QuickActionBvcBeginView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
claimCountByUser = 0;
claimCountWithHidden = 0;
claimsToConfirm: GenericCredWrapper[] = [];
claimsToConfirmSelected: string[] = [];
description = "breakfast";
loadingConfirms = true;
someoneGave = false;
async created() {
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();
}
async mounted() {
this.loadingConfirms = true;
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
if (currentOrPreviousSat.weekday < 6) {
// it's not Saturday or Sunday,
// so move back one week before setting to the Saturday
currentOrPreviousSat = currentOrPreviousSat.minus({ week: 1 });
}
const eventStartDateObj = currentOrPreviousSat
.set({ weekday: 6 })
.set({ hour: 9 })
.startOf("hour");
// Hack, but full ISO pushes the length to 340 which crashes verifyJWT!
const todayOrPreviousStartDate =
eventStartDateObj.toISO({
suppressMilliseconds: true,
}) || "";
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
const account: Account | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
const identity: IIdentifier = JSON.parse(
(account?.identity as string) || "null",
);
const headers = {
Authorization: "Bearer " + (await accessToken(identity)),
};
try {
const response = await fetch(
this.apiServer +
"/api/claim/?" +
"issuedAt_greaterThanOrEqualTo=" +
encodeURIComponent(todayOrPreviousStartDate) +
"&excludeConfirmations=true",
{ headers },
);
if (!response.ok) {
console.error("Bad response", response);
throw new Error("Bad response when retrieving claims.");
}
await response.json().then((data) => {
const dataByOthers = R.reject(
(claim: GenericCredWrapper) => claim.issuer === this.activeDid,
data,
);
const dataByOthersWithoutHidden = R.reject(
containsHiddenDid,
dataByOthers,
);
this.claimsToConfirm = dataByOthersWithoutHidden;
this.claimCountByUser = data.length - dataByOthers.length;
this.claimCountWithHidden =
dataByOthers.length - dataByOthersWithoutHidden.length;
});
} catch (error) {
console.error("Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error retrieving today's claims to confirm.",
},
5000,
);
}
this.loadingConfirms = false;
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
async record() {
try {
const identity = await libsUtil.getIdentity(this.activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId,
);
if (!record) {
return { type: "error", error: "Record not found." };
}
const identity = await libsUtil.getIdentity(this.activeDid);
return createAndSubmitConfirmation(
identity,
record.claim as GenericVerifiableCredential,
record.id,
record.handleId,
this.apiServer,
axios,
);
}),
);
// check for any rejected confirmations
const confirmsSucceeded = confirmResults.filter(
(result) =>
result.status === "fulfilled" && result.value.type === "success",
);
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
console.error("Error sending confirmations:", confirmResults);
const howMany = confirmsSucceeded.length === 0 ? "all" : "some";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was an error sending ${howMany} of the confirmations.`,
},
5000,
);
}
// now send the give for the description
let giveSucceeded = false;
if (this.someoneGave) {
const giveResult = await createAndSubmitGive(
axios,
this.apiServer,
identity,
undefined,
this.activeDid,
this.description,
undefined,
undefined,
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
giveSucceeded = giveResult.type === "success";
if (!giveSucceeded) {
console.error("Error sending give:", giveResult);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
(giveResult as ErrorResult)?.error?.userMessage ||
"There was an error sending that give.",
},
5000,
);
}
}
if (confirmsSucceeded.length > 0 || giveSucceeded) {
const confirms =
confirmsSucceeded.length === 1 ? "confirmation" : "confirmations";
const actions =
confirmsSucceeded.length > 0 && giveSucceeded
? `Your ${confirms} and that give have been recorded.`
: giveSucceeded
? "That give has been recorded."
: "Your " +
confirms +
" " +
(confirmsSucceeded.length === 1 ? "has" : "have") +
" been recorded.";
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: actions,
},
3000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error sending claims.", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: error.userMessage || "There was an error sending claims.",
},
5000,
);
}
}
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4">
Bountiful Voluntaryist Community Actions
</h1>
<div>
<router-link
:to="{ name: 'quick-action-bvc-begin' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Beginning of Meeting
</router-link>
<router-link
:to="{ name: 'quick-action-bvc-end' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
End of Meeting
</router-link>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class QuickActionBvcView extends Vue {}
</script>

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -22,8 +22,8 @@
</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.
This location is only stored on your device. It is sometimes sent from
your device to run searches but it is not stored on our servers.
</div>
<div>
@@ -64,10 +64,11 @@
</div>
</div>
<div style="height: 600px; width: 800px">
<div class="mb-4 aspect-video">
<l-map
ref="map"
:center="[localCenterLat, localCenterLong]"
class="!z-40 rounded-md"
v-model:zoom="localZoom"
@click="setMapPoint"
>
@@ -105,21 +106,15 @@ import {
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
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,
@@ -130,7 +125,7 @@ interface Notification {
},
})
export default class DiscoverView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
isChoosingSearchBox = false;
isNewMarkerSet = false;
@@ -214,9 +209,9 @@ export default class DiscoverView extends Vue {
group: "alert",
type: "success",
title: "Saved",
text: "That has been saved in your preferences.",
text: "That has been saved in your preferences. You can now filter by it on your home screen feed.",
},
-1,
7000,
);
this.$router.back();
} catch (err) {
@@ -252,6 +247,7 @@ export default class DiscoverView extends Vue {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [],
filterFeedByNearby: false,
});
this.searchBox = null;
this.localCenterLat = 0;

View File

@@ -1,7 +1,17 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<QuickNav selected="Profile" />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Seed Backup
@@ -12,7 +22,7 @@
<span>
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1"
>
Help
</router-link>
@@ -29,16 +39,16 @@
</p>
<p v-if="numAccounts > 1">
<b class="text-orange-600">Note:</b> You have more than one identity
<b class="text-orange-600">Note:</b> You have more than one identifier
stored in this browser. If they are all based on the same seed as the
current identity, this one backup is sufficient; however, if you have
different seeds for other identities, you will have to back them up
current identifier, this one backup is sufficient; however, if you have
different seeds for other identifiers, you will have to back them up
separately.
</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"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="showSeedPhrase"
>
Reveal my Seed Phrase
@@ -49,31 +59,23 @@
</p>
</div>
</div>
<div v-else>You do not have an active identity.</div>
<div v-else>You do not have an active identifier.</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db/index";
import * as R from "ramda";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
interface Account {
mnemonic: string;
}
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeAccount: Account | null | undefined = null;
numAccounts = 0;
@@ -91,7 +93,7 @@ export default class SeedBackupView extends Vue {
this.numAccounts = accounts.length;
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
} catch (err: unknown) {
console.error("Got an error loading an identity:", err);
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",

View File

@@ -0,0 +1,209 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Image
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
@click="recordGift"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
@click="recordProfile"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
@click="cancel"
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="ban" class="fa-fw" />
Cancel
</button>
</div>
<PhotoDialog ref="photoDialog" />
</div>
<div class="flex justify-center">
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded mt-4"
/>
</div>
</div>
<div v-else class="text-center mb-4">
<p>No image found.</p>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getIdentity } from "@/libs/util";
import { accessToken } from "@/libs/crypto";
@Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid: string | undefined = undefined;
imageBlob: Blob | undefined = undefined;
imageFileName: string | undefined = undefined;
uploading = false;
URL = window.URL || window.webkitURL;
// 'created' hook runs when the Vue instance is first created
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
this.imageBlob = temp.blob;
// clear the temp image
db.temp.delete("shared-photo");
this.imageFileName = this.$route.query.fileName as string;
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading this data.",
},
-1,
);
}
}
async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => {
if (url) {
this.$router.push({
name: "gifted-details",
query: {
destinationNameAfter: "home",
hideBackButton: true,
imageUrl: url,
recipientDid: this.activeDid,
},
});
}
});
}
recordProfile() {
(this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => {
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl,
});
this.$router.push({ name: "account" });
},
IMAGE_TYPE_PROFILE,
true,
this.imageBlob,
this.imageFileName,
);
}
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
this.$router.push({ name: "home" });
}
async sendToImageServer(imageType: string) {
this.uploading = true;
let result;
try {
// send the image to the server
const identifier = await getIdentity(this.activeDid as string);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
formData.append(
"image",
this.imageBlob as Blob,
this.imageFileName as string,
);
formData.append("claimType", imageType);
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
if (response?.data?.url) {
this.imageBlob = undefined;
this.imageFileName = undefined;
result = response.data.url as string;
} else {
console.error("Problem uploading the image", response.data);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
"There was a problem saving the picture. " +
(response?.data?.message || ""),
},
5000,
);
}
this.uploading = false;
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture.",
},
5000,
);
this.uploading = false;
}
return result;
}
}
</script>

View File

@@ -4,7 +4,7 @@
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
>
<!-- Breadcrumb -->
<div class="mb-8">
<div>
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
@@ -23,34 +23,47 @@
<!-- 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 have an identity to import?
</p>
<a
@click="onClickYes()"
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 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 class="max-w-3xl mx-auto">
<p class="text-center text-xl font-light">
Do you want a new identifier of your own?
</p>
<p class="text-center font-light">
If you haven't used this before, click "Yes" to generate a new
identifier.
</p>
<p class="text-center mb-4 font-light">
Only click "No" if you have a seed of 12 or 24 words generated
elsewhere.
</p>
<a
@click="onClickYes()"
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
>
Yes, generate one
</a>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<a
@click="onClickNo()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
No, I have a seed
</a>
<a
v-if="numAccounts > 0"
@click="onClickDerive()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Derive new address from existing seed
</a>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB } from "@/db/index";
@Component({

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -23,7 +23,7 @@
<div>
Here is a view of the activity you can see.
<ul class="list-disc list-inside">
<ul class="list-disc outside ml-4">
<li>Each identity and claim has a unique position.</li>
<!-- eslint-disable prettier/prettier --><!-- If we format prettier then there is extra space at the start of the line. -->
<li>Each will show at their time of appearance relative to all others.</li>
@@ -53,8 +53,10 @@
<script lang="ts">
import { SVGRenderer } from "three/examples/jsm/renderers/SVGRenderer.js";
import { Component, Vue } from "vue-facing-decorator";
import { World } from "@/components/World/World.js";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
interface RendererSVGType {
domElement: Element;
@@ -64,16 +66,9 @@ 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;
$notify!: (notification: NotificationIface, timeout?: number) => void;
world: World;
worldProperties: Dictionary<number> = {};

View File

@@ -2,7 +2,7 @@
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
@@ -21,8 +21,8 @@
</h1>
</div>
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
<div>
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button
@click="
@@ -153,13 +153,269 @@
Notif OFF
</button>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
name: 'shared-photo',
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Go to Shared Page
</router-link>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
See console for results.
<br/>
Active DID: {{ activeDid }}
{{ credIdHex ? "has passkey ID" : "has no passkey ID" }}
<div>
Register
<button
@click="register()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
</div>
<div>
Create
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
</div>
<div v-if="jwt">
Verify
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Verify Mine
</button>
</div>
</section>
</template>
<script lang="ts">
import { Buffer } from "buffer/";
import { Base64URLString } from "@simplewebauthn/types";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { accountsDB, db } from "@/db/index";
import {
createPeerDid,
PeerSetup,
registerCredential,
verifyJwtP256,
verifyJwtSimplewebauthn,
verifyJwtWebCrypto,
} from "@/libs/didPeer";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
const inputFileNameRef = ref<Blob>();
const TEST_PAYLOAD = {
vc: {
credentialSubject: {
"@context": "https://schema.org",
"@type": "GiveAction",
description: "pizza",
},
},
};
@Component({ components: { QuickNav } })
export default class Help extends Vue {}
export default class Help extends Vue {
// for file import
fileName?: string;
// for passkeys
credIdHex?: string;
activeDid?: string;
jwt?: string;
peerSetup?: PeerSetup;
userName?: string;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.userName = settings?.firstName as string;
await accountsDB.open();
const account: { identity?: string } | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
if (this.activeDid) {
if (account) {
this.credIdHex = account.passkeyCredIdHex as string;
} else {
alert("No account found for DID " + this.activeDid);
}
}
}
async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
await db.temp.update("shared-photo", { blob });
} else {
await db.temp.add({ id: "shared-photo", blob });
}
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
showFileNextStep() {
return !!inputFileNameRef.value;
}
public async register() {
const cred = await registerCredential(this.userName);
const publicKeyBytes = cred.publicKeyBytes;
this.activeDid = createPeerDid(publicKeyBytes as Uint8Array);
this.credIdHex = cred.credIdHex as string;
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
did: this.activeDid,
passkeyCredIdHex: this.credIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
});``
}
public async createJwtSimplewebauthn() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("simple jwt4url", this.jwt);
}
public async createJwtNavigator() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("lower jwt4url", this.jwt);
}
public async verifyP256() {
const decoded = await verifyJwtP256(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifySimplewebauthn() {
const decoded = await verifyJwtSimplewebauthn(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyWebCrypto() {
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyMyJwt() {
const jwt =
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
const pieces = jwt.split(".");
console.log("pieces", typeof pieces[1], pieces);
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
const clientJSON = Buffer.from(
payload["ClientDataJSONB64URL"],
"base64",
).toString();
const clientData = JSON.parse(clientJSON);
const challenge = clientData.challenge;
const signatureB64URL = pieces[2];
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
authData,
challenge,
payload["ClientDataJSONB64URL"],
signatureB64URL,
);
console.log("decoded", decoded);
}
}
</script>

29
sw_combine.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* We've seen cases where the functions inside safari-notifications.js are not found.
* This is our attempt to ensure that all the functions are available.
*/
const fs = require("fs");
const path = require("path");
const swScriptsDir = path.resolve(__dirname, "sw_scripts");
const outputFile = path.resolve(__dirname, "sw_scripts-combined.js");
// Read all files in the sw_scripts directory
fs.readdir(swScriptsDir, (err, files) => {
if (err) {
console.error("Error reading directory:", err);
return;
}
// Combine files content into one script
const combinedContent = files
.filter((file) => path.extname(file) === ".js")
.map((file) => fs.readFileSync(path.join(swScriptsDir, file), "utf8"))
.join("\n");
// Write the combined content to the output file
fs.writeFileSync(outputFile, combinedContent, "utf8");
console.log("Service worker files combined.");
});

Some files were not shown because too many files have changed in this diff Show More