Compare commits

..

206 Commits

Author SHA1 Message Date
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
70 changed files with 12375 additions and 14564 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

@@ -5,16 +5,127 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed in DB or environment
- Nothing
## [0.2.1] - 2024.01.05
## [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
@@ -23,6 +134,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Icon for Android
- More thorough messaging and testing for notifications
## [0.1.9] - 2024.01.01
### Added
- Import for contacts and settings

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).

142
README.md
View File

@@ -1,6 +1,14 @@
# 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.
## 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 have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
@@ -10,6 +18,11 @@ npm install
### Compiles and hot-reloads for development
```
npm run dev
```
### Builds the production app
```
npm run serve
```
@@ -20,27 +33,37 @@ npm run lint
### Compiles and minifies for production
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`, and commit.
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
* If production, change src/constants/app.ts DEFAULT_*_SERVER to be "PROD" and package.json to remove "_Test". Also record what version is on production.
* `npm run build`
* 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. (It's only important if you changed something in that directory.)
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
* `cp sw_scripts/[ns]* dist/`
* Record what version is currently on production.
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
* Run the correct 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
```
* Production
```
# This picks up values from .env.production
npm run build
```
* Get on the server and back up 3 DBs and the time-safari folder.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Revert src/constants/app.ts and package.json (if that was prod), edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
@@ -49,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
@@ -77,50 +93,67 @@ 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.
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
- Use a mobile user as well as a desktop user.
- Backup seed & data & get a CSV dump from Endorser Mobile.
- Check that the version is updated.
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
- 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.)
- On the contacts page, check that they can add User #0 even without their own ID.
- As User #0 in another browser on the test API, add a give & a project.
- `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- With the new user on the home page, see the feed that shows User #0 in network but without the name.
- As the new user on the contacts page, add User #0 as a contact.
- On the home page, see the feed that shows User #0 with a name.
- Generate an ID.
- On the home page, check that it now prompts them to get registered.
- 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.
- 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", 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`.)
* 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.
## Other
@@ -131,13 +164,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>

18136
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +1,93 @@
{
"name": "TimeSafari_Test",
"version": "0.2.1",
"name": "TimeSafari",
"version": "0.3.8-beta",
"private": true,
"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",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@tweenjs/tween.js": "^21.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/js-yaml": "^4.0.9",
"@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",
"@types/luxon": "^3.4.2",
"@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-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",
"axios": "^1.6.8",
"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",
"js-yaml": "^4.1.0",
"localstorage-slim": "^2.5.0",
"luxon": "^3.4.3",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"moment": "^2.30.1",
"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/leaflet": "^1.9.8",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@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,111 +1,4 @@
tasks:
tasks :
- show VC details... somehow:
- 01 show my VCs - most interesting, or via search
- 04 allow user to download chains of VCs, mine + ones I can see about me from others
- add VC confirmation
- look for "if is a new user" -- blank name
- copy button for seed
- .5 If notifications are not enabled, add message to front page with link/button to enable
- record donations vs gives
- make server endpoint for full English description of limits
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- 01 server - show all claim details when issued by the issuer
- bug - got error adding on Firefox user #0 as contact for themselves
- bug (that is hard to reproduce) - back-and-forth on discovery & project pages led to "You need an identity to load your projects." error on product page when I had an identity
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function"
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
- 01 send visibility signal as a VC and store it
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
- 04 look at other examples for better UI friend.tech
- 01 make the prod build copy the sw_scripts
- .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 remove references to localStorage for projectId (now that it's pulling from the path)
- switch some checks for activeDid to check for isRegistered
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
- warn if they're using the web (android only?)
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
https://web.dev/articles/get-installed-related-apps
- 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
- import project interactions from GitHub/GitLab and manage signing
- show total time offered to & fulfilled to a project
- show total time offered by & fulfilled by a contact
- linking between projects or plans :
- show total time given to & from a project
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 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)
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee
- pull instead of push, maybe via scheduled runs
- have a notification pop-up on Mac screen
- Connect with phone contacts
- 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.
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections
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

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,7 @@
</div>
</NotificationGroup>
<!-- These are general-purpose messages - except there are some for turning app notifications on and off. -->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
@@ -148,6 +149,39 @@
class="w-full"
role="alert"
>
<!-- type "confirm" will post a message and, with onYes function, show a "Yes" button to call that function -->
<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">
<p class="text-lg mb-4">
{{ notification.title }}
</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
@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"
>
{{ 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"
@@ -157,7 +191,7 @@
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p v-if="serviceWorkerReady" class="text-lg mb-4">
Would you like to <b>turn on</b> notifications for this app?
Would you like to be notified of new activity once a day?
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 10
@@ -165,22 +199,42 @@
<fa icon="spinner" spin />
</p>
<button
v-if="serviceWorkerReady"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="
close(notification.id);
turnOnNotifications();
"
>
Turn on Notifications
</button>
<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>
@@ -263,8 +317,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;
@@ -288,24 +345,23 @@ interface VapidResponse {
};
}
import { DEFAULT_PUSH_SERVER } 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";
import { sendTestThroughPushServer } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component
export default class App extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
b64 = "";
serviceWorkerReady = false;
hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
async mounted() {
try {
@@ -316,25 +372,29 @@ export default class App extends Vue {
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) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
-1,
);
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")) {
@@ -353,7 +413,7 @@ export default class App extends Vue {
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker.ready.then(() => {
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
}
@@ -434,6 +494,48 @@ export default class App extends Vue {
});
}
// this allows us to show an error without closing the dialog
checkHour() {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Number",
text: "The time must be an hour number.",
},
5000,
);
return false;
}
const hourNum = libsUtil.numberOrZero(this.hourInput);
if (!Number.isInteger(hourNum)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be a whole hour number.",
},
5000,
);
return false;
}
if (hourNum < 1 || 12 < hourNum) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be an hour between 1 and 12.",
},
5000,
);
return false;
}
return true;
}
public async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
@@ -443,7 +545,7 @@ 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();
@@ -459,13 +561,25 @@ export default class App extends Vue {
},
-1,
);
this.sendSubscriptionToServer(subscription);
return subscription;
// 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(async (subscription) => {
.then(async (subscription: PushSubscriptionWithTime) => {
console.log(
"Subscription data sent to server and all finished successfully.",
);
@@ -556,7 +670,7 @@ export default class App extends Vue {
}
private sendSubscriptionToServer(
subscription: PushSubscription,
subscription: PushSubscriptionWithTime,
): Promise<void> {
console.log("About to send subscription...", subscription);
return fetch("/web-push/subscribe", {
@@ -575,7 +689,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();
})
@@ -589,7 +703,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;
});
@@ -604,7 +718,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

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,33 @@
<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}" alt="avatar" width="${this.iconSize}" height="${this.iconSize}" />`;
} else {
const identifier = this.contact?.did || this.entityId;
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

@@ -10,23 +10,22 @@
placeholder="What was received"
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] }}
</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,88 +35,123 @@
<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,
message,
offerId,
projectId,
unitCode,
},
}"
class="text-blue-500"
>
Photo, ...
</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,
GiverInputInfo,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { Account } from "@/db/tables/accounts";
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
description = "";
givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
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?: GiverInputInfo, offerId?: string) {
this.description = "";
this.giver = giver || {};
// if we show "given to user" selection, default checkbox to true
this.givenToUser = this.showGivenToUser;
this.amountInput = "0";
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.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",
@@ -128,14 +162,6 @@ export default class GiftedDialog extends Vue {
-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;
}
@@ -146,7 +172,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,9 +198,49 @@ export default class GiftedDialog extends Vue {
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(
{
@@ -187,7 +253,7 @@ 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.description,
parseFloat(this.amountInput),
this.unitCode,
@@ -196,64 +262,21 @@ 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 description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive(
giverDid?: string,
description?: string,
amountInput?: number,
unitCode?: string,
giverDid: string | null,
description: string,
amountInput: number,
unitCode: string = "HUR",
) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an 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,
@@ -264,6 +287,8 @@ export default class GiftedDialog extends Vue {
amountInput,
unitCode,
this.projectId,
this.offerId,
this.isTrade,
);
if (
@@ -271,7 +296,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",
@@ -287,14 +312,14 @@ 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,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with give recordation caught:", error);
console.error("Error with give recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
@@ -334,6 +359,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,404 @@
<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-2 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 p-2 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="{
width: '100%',
height: '100%',
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="URL.createObjectURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 9 / 9,
}"
/>
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div>
<div v-else>
<div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
</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 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 GiftedPhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0;
activeDid = "";
blob: Blob | null = null;
claimType = "GiveAction";
crop = false;
mirror = false;
numDevices = 0;
setImage: (arg: string) => void = () => {};
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, crop?: boolean, claimType?: string) {
this.visible = true;
this.crop = !!crop;
this.claimType = claimType || "GiveAction";
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "none";
}
this.setImage = setImageFn;
}
close() {
this.visible = false;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = null;
}
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";
}
}
async switchCamera() {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
const devices = await cameraComponent?.devices(["videoinput"]);
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,
}); // png is default; if that changes, change extension in formData.append
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error taking the picture. Please try again.",
},
5000,
);
return;
}
}
async retryImage() {
this.blob = null;
}
/****
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();
}
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, "snapshot.png"); // png is set in snapshot()
formData.append("claimType", this.claimType);
try {
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.visible = false;
this.blob = null;
this.setImage(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. Please try again.",
},
5000,
);
this.uploading = false;
this.blob = null;
}
}
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,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

@@ -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"
>
@@ -45,40 +47,39 @@
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 { 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 = "";
@@ -86,12 +87,15 @@ export default class OfferDialog extends Vue {
activeDid = "";
apiServer = "";
amountInput = "0";
amountUnitCode = "HUR";
description = "";
expirationDateInput = "";
hours = "0";
visible = false;
async created() {
libsUtil = libsUtil;
async open() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@@ -99,7 +103,7 @@ export default class OfferDialog extends Vue {
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",
@@ -110,28 +114,41 @@ export default class OfferDialog extends Vue {
-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,
)}`;
}
cancel() {
this.close();
this.eraseValues();
}
eraseValues() {
this.description = "";
this.hours = "0";
this.amountInput = "0";
this.amountUnitCode = "HUR";
}
async confirm() {
@@ -148,38 +165,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) {
@@ -188,20 +192,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,
);
@@ -209,13 +213,14 @@ 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.projectId,
);
@@ -225,7 +230,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",
@@ -248,7 +253,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,32 @@
<template>
<div v-html="generateIdenticon()" class="w-fit"></div>
</template>
<script lang="ts">
import { toSvg } from "jdenticon";
import { Vue, Component, Prop } from "vue-facing-decorator";
const BLANK_CONFIG = {
lightness: {
color: [1.0, 1.0],
grayscale: [1.0, 1.0],
},
saturation: {
color: 0.0,
grayscale: 0.0,
},
backColor: "#0000",
};
@Component
export default class ProjectIcon extends Vue {
@Prop entityId = "";
@Prop iconSize = 0;
generateIdenticon() {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
}
</script>
<style scoped></style>

View File

@@ -4,20 +4,14 @@
<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";
import { AppString } from "@/constants/app";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component
export default class TopMessage extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop selected = "";

View File

@@ -11,9 +11,21 @@ export enum AppString {
PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
NO_CONTACT_NAME = "(no name)",
}
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
@@ -27,4 +39,5 @@ export interface NotificationIface {
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
onYes?: () => Promise<void>;
}

View File

@@ -2,6 +2,7 @@ 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;

View File

@@ -16,11 +16,16 @@ export type Settings = {
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
firstName?: string; // User's first name
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
firstName?: string; // user's full name
isRegistered?: boolean;
lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string; // Last notified claim ID
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
profileImageUrl?: string;
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
@@ -31,12 +36,17 @@ export type Settings = {
}>;
showContactGivesInline?: boolean; // Display contact inline or not
showShortcutBvc?: boolean; // Show shortcut for BVC actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
};
export function isAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
}
/**
* Schema for the Settings table in the database.
*/

View File

@@ -113,7 +113,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) => {
* ...
* })

View File

@@ -1,13 +1,17 @@
import { Axios, AxiosResponse, RawAxiosRequestHeaders } from "axios";
import * as didJwt from "did-jwt";
import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import { Axios, AxiosResponse } from "axios";
import { Contact } from "@/db/tables/contacts";
import { accessToken, SimpleSigner } from "@/libs/crypto";
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
// the header line for contacts exported via Endorser Mobile
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
@@ -20,7 +24,7 @@ export interface AgreeVerifiableCredential {
"@type": string;
// "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: Record<any, any>;
object: Record<string, any>;
}
export interface GiverInputInfo {
@@ -32,7 +36,8 @@ export interface GiverOutputInfo {
action: string;
giver?: GiverInputInfo;
description?: string;
hours?: number;
amount?: number;
unitCode?: string;
}
export interface ClaimResult {
@@ -43,37 +48,53 @@ export interface ClaimResult {
export interface GenericVerifiableCredential {
"@context": string;
"@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface GenericServerRecord extends GenericVerifiableCredential {
export interface GenericCredWrapper extends GenericVerifiableCredential {
handleId?: string;
id?: string;
issuedAt?: string;
issuer?: string;
id: string;
issuedAt: string;
issuer: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
claim: Record<any, any>;
claim: Record<string, any>;
claimType?: string;
}
export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
claim: {},
id: "",
issuedAt: "",
issuer: "",
};
export interface GiveServerRecord {
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsPlanHandleId: string;
handleId: string;
issuedAt: string;
jwtId: string;
recipientDid: string;
unit: string;
}
export interface OfferServerRecord {
// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fulfillsPlanHandleId: string;
handleId: string;
jwtId: string;
nonAmountGivenConfirmed: number;
objectDescription: string;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
@@ -81,6 +102,21 @@ export interface OfferServerRecord {
validThrough: string;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well
description: string;
endTime?: string;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;
locLat?: number;
locLon?: number;
name?: string;
startTime?: string;
url?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential {
@@ -88,8 +124,9 @@ export interface GiveVerifiableCredential {
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
recipient?: { identifier: string };
}
@@ -115,23 +152,70 @@ export interface PlanVerifiableCredential {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
description: string;
agent?: { identifier: string };
description?: string;
identifier?: string;
lastClaimId?: string;
location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
}
export interface PlanServerRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well
/**
* Represents data about a project
*
* @deprecated
* We should use PlanServerRecord instead.
**/
export interface PlanData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
description: string;
endTime?: string;
issuerDid: string;
/**
* URL referencing information about the project
**/
handleId: string;
locLat?: number;
locLon?: number;
/**
* The DID of the issuer
*/
issuerDid: string;
/**
* The Identier of the project -- different from jwtId, needs to be fixed
**/
rowid?: string;
}
export interface EndorserRateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
export interface ImageRateLimits {
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}
export interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
}
export interface WorldProperties {
startTime?: string;
url?: string;
endTime?: string;
}
export interface RegisterVerifiableCredential {
@@ -142,40 +226,76 @@ export interface RegisterVerifiableCredential {
participant: { identifier: string };
}
// now for some of the error & other wrapper types
export interface ResultWithType {
type: string;
}
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
}
export interface ErrorResponse {
error?: {
message?: string;
};
}
export interface InternalError {
error: string; // for system logging
userMessage?: string; // for user display
}
export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
export function isDid(did: string) {
return did.startsWith("did:");
}
export function isHiddenDid(did: string) {
return did === HIDDEN_DID;
}
export function isEmptyOrHiddenDid(did?: string) {
return !did || did === HIDDEN_DID; // catching empty string as well
}
/**
* @return true for any nested string where func(input) === true
* @return true for any string within this primitive/object/array where func(input) === true
*
* Similar logic is found in endorser-mobile.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function testRecursivelyOnString(func: (arg0: any) => boolean, input: any) {
function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) {
if (Object.prototype.toString.call(input) === "[object String]") {
return func(input);
} else if (input instanceof Object) {
if (!Array.isArray(input)) {
// it's an object
for (const key in input) {
if (testRecursivelyOnString(func, input[key])) {
if (testRecursivelyOnStrings(func, input[key])) {
return true;
}
}
} else {
// it's an array
for (const value of input) {
if (testRecursivelyOnString(func, value)) {
if (testRecursivelyOnStrings(func, value)) {
return true;
}
}
@@ -188,9 +308,15 @@ function testRecursivelyOnString(func: (arg0: any) => boolean, input: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function containsHiddenDid(obj: any) {
return testRecursivelyOnString(isHiddenDid, obj);
return testRecursivelyOnStrings(isHiddenDid, obj);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const containsNonHiddenDid = (obj: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return testRecursivelyOnStrings((s: any) => isDid(s) && !isHiddenDid(s), obj);
};
export function stripEndorserPrefix(claimId: string) {
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
@@ -228,7 +354,7 @@ export function addLastClaimOrHandleAsIdIfMissing(
}
// return clone of object without any nested *VisibleToDids keys
// similar logic is found in endorser-mobile
// similar code is also contained in endorser-mobile
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function removeVisibleToDids(input: any): any {
if (input instanceof Object) {
@@ -238,7 +364,6 @@ export function removeVisibleToDids(input: any): any {
const result: Record<string, any> = {};
for (const key in input) {
if (!key.endsWith("VisibleToDids")) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result[key] = removeVisibleToDids(R.clone(input[key]));
}
}
@@ -247,71 +372,160 @@ export function removeVisibleToDids(input: any): any {
// it's an array
return R.map(removeVisibleToDids, input);
}
return false;
} else {
return input;
}
}
/**
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
export function contactForDid(
did: string | undefined,
contacts: Contact[],
): Contact | undefined {
return isEmptyOrHiddenDid(did)
? undefined
: R.find((c) => c.did === did, contacts);
}
Similar logic is found in endorser-mobile.
/**
*
* Similar logic is found in endorser-mobile.
*
* @param did
* @param activeDid
* @param contact
* @param allMyDids
* @return { known: boolean, displayName: string } where known is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Anonymous"
*/
export function didInfoForContact(
did: string | undefined,
activeDid: string | undefined,
contact?: Contact,
allMyDids: string[] = [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string } {
if (!did) return { displayName: "Someone Anonymous", known: false };
if (contact) {
return {
displayName: contact.name || "Contact With No Name",
known: !!contact.name,
};
} else if (did === activeDid) {
return { displayName: "You", known: true };
} else {
const myId = R.find(R.equals(did), allMyDids);
return myId
? { displayName: "You (Alt ID)", known: true }
: isHiddenDid(did)
? { displayName: "Someone Totally Outside Your View", known: false }
: {
displayName: "Someone Visible But Outside Your Contact List",
known: false,
};
}
}
/**
always returns text, maybe something like "unnamed" or "unknown"
Now that we're using more informational didInfoForContact under the covers, we might want to consolidate.
**/
export function didInfo(
did: string,
activeDid: string,
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): string {
if (!did) return "Someone Anonymous";
const myId = R.find(R.equals(did), allMyDids);
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
const contact = R.find((c) => c.did === did, contacts);
return contact
? contact.name || "Contact With No Name"
: isHiddenDid(did)
? "Someone Not In Network"
: "Someone Not In Contacts";
const contact = contactForDid(did, contacts);
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
}
export interface ResultWithType {
type: string;
async function getHeaders(identity: IIdentifier | null) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
/**
* @param handleId nullable -- which means that "undefined" will be returned
* @param identity nullable -- which means no private info will be returned
* @param axios
* @param apiServer
*/
export async function getPlanFromCache(
handleId: string | null,
identity: IIdentifier | null,
axios: Axios,
apiServer: string,
) {
if (!handleId) {
return undefined;
}
let cred = planCache.get(handleId);
if (!cred) {
const url =
apiServer +
"/api/v2/report/plans?handleId=" +
encodeURIComponent(handleId);
const headers = await getHeaders(identity);
try {
const resp = await axios.get(url, { headers });
if (resp.status === 200 && resp.data?.data?.length > 0) {
cred = resp.data.data[0];
planCache.set(handleId, cred);
} else {
console.log(
"Failed to load plan with handle",
handleId,
" Got data:",
resp.data,
);
}
} catch (error) {
console.log(
"Failed to load plan with handle",
handleId,
" Got error:",
error,
);
}
}
return cred;
}
export interface ErrorResult {
type: "error";
error: InternalError;
export async function setPlanInCache(
handleId: string,
planSummary: PlanSummaryRecord,
) {
planCache.set(handleId, planSummary);
}
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
/**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or hours
* @param hours may be null; should have this or description
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
*/
export async function createAndSubmitGive(
axios: Axios,
apiServer: string,
identity: IIdentifier,
fromDid?: string,
fromDid?: string | null,
toDid?: string,
description?: string,
hours?: number,
amount?: number,
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org",
@@ -319,15 +533,30 @@ export async function createAndSubmitGive(
recipient: toDid ? { identifier: toDid } : undefined,
agent: fromDid ? { identifier: fromDid } : undefined,
description: description || undefined,
object: hours
? { amountOfThisGood: hours, unitCode: unitCode || "HUR" }
: undefined,
fulfills: fulfillsProjectHandleId
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
object: amount
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined,
fulfills: [{ "@type": isTrade ? "TradeAction" : "DonateAction" }],
};
if (fulfillsProjectHandleId) {
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this
vcClaim.fulfills.push({
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
});
}
if (fulfillsOfferHandleId) {
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this
vcClaim.fulfills.push({
"@type": "Offer",
identifier: fulfillsOfferHandleId,
});
}
if (imageUrl) {
vcClaim.image = imageUrl;
}
return createAndSubmitClaim(
vcClaim as GenericServerRecord,
vcClaim as GenericCredWrapper,
identity,
apiServer,
axios,
@@ -338,8 +567,8 @@ export async function createAndSubmitGive(
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param description may be null; should have this or hours
* @param hours may be null; should have this or description
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
*/
@@ -348,7 +577,8 @@ export async function createAndSubmitOffer(
apiServer: string,
identity: IIdentifier,
description?: string,
hours?: number,
amount?: number,
unitCode?: string,
expirationDate?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
@@ -358,10 +588,10 @@ export async function createAndSubmitOffer(
offeredBy: { identifier: identity.did },
validThrough: expirationDate || undefined,
};
if (hours) {
if (amount) {
vcClaim.includesObject = {
amountOfThisGood: hours,
unitCode: "HUR",
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
}
if (description) {
@@ -375,13 +605,35 @@ export async function createAndSubmitOffer(
};
}
return createAndSubmitClaim(
vcClaim as GenericServerRecord,
vcClaim as GenericCredWrapper,
identity,
apiServer,
axios,
);
}
// similar logic is found in endorser-mobile
export const createAndSubmitConfirmation = async (
identifier: IIdentifier,
claim: GenericVerifiableCredential,
lastClaimId: string, // used to set the lastClaimId
handleId: string | undefined,
apiServer: string,
axios: Axios,
) => {
const goodClaim = removeSchemaContext(
removeVisibleToDids(
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
),
);
const confirmationClaim: GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
return createAndSubmitClaim(confirmationClaim, identifier, apiServer, axios);
};
export async function createAndSubmitClaim(
vcClaim: GenericVerifiableCredential,
identity: IIdentifier,
@@ -431,80 +683,214 @@ export async function createAndSubmitClaim(
return { type: "success", response };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error creating claim:", error);
console.error("Error creating claim:", error);
const errorMessage: string =
error.response?.data?.error?.message || error.message || "Unknown error";
error.response?.data?.error?.message ||
error.message ||
"Got some error submitting the claim. Check your permissions, network, and error logs.";
return {
type: "error",
error: {
error: errorMessage,
userMessage: "Failed to create and submit the claim.",
},
};
}
}
// from https://stackoverflow.com/a/175787/845494
//
export function isNumeric(str: string): boolean {
return !isNaN(+str);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isAccept = (claim: Record<string, any>) => {
return (
claim &&
claim["@context"] === SCHEMA_ORG_CONTEXT &&
claim["@type"] === "AcceptAction"
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isOffer = (claim: Record<string, any>) => {
return (
claim &&
claim["@context"] === SCHEMA_ORG_CONTEXT &&
claim["@type"] === "Offer"
);
};
export function currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0;
export function displayAmount(code: string, amt: number) {
return "" + amt + " " + currencyShortWordForCode(code, amt === 1);
}
export interface ErrorResponse {
error?: {
message?: string;
};
}
export interface RateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
};
/**
* Represents data about a project
return readable summary of claim, or something generic
similar code is also contained in endorser-mobile
**/
export interface ProjectData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
/**
* The DID of the issuer
*/
issuerDid: string;
/**
* The Identier of the project
**/
rowid: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = (claim: Record<string, any>) => {
if (!claim) {
// to differentiate from "something" above
return "something";
}
if (claim.claim) {
// probably a Verified Credential
// eslint-disable-next-line @typescript-eslint/no-explicit-any
claim = claim.claim as Record<string, any>;
}
if (Array.isArray(claim)) {
if (claim.length === 1) {
claim = claim[0];
} else {
return "multiple claims";
}
}
const type = claim["@type"];
if (!type) {
return "a claim";
} else {
let typeExpl = capitalizeAndInsertSpacesBeforeCaps(type);
if (typeExpl === "Person") {
typeExpl += " claim";
}
return "a " + typeExpl;
}
};
export interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
}
/**
return readable description of claim if possible, as a past-tense action
export interface WorldProperties {
startTime?: string;
endTime?: string;
}
identifiers is a list of objects with a 'did' field, each representing the user
contacts is a list of objects with a 'did' field for others and a 'name' field for their name
similar code is also contained in endorser-mobile
**/
export const claimSpecialDescription = (
record: GenericCredWrapper,
activeDid: string,
identifiers: Array<string>,
contacts: Array<Contact>,
) => {
let claim = record.claim;
if (claim.claim) {
// it's probably a Verified Credential
claim = claim.claim;
}
const issuer = didInfo(record.issuer, activeDid, identifiers, contacts);
const type = claim["@type"] || "UnknownType";
if (type === "AgreeAction") {
return issuer + " agreed with " + claimSummary(claim.object);
} else if (isAccept(claim)) {
return issuer + " accepted " + claimSummary(claim.object);
} else if (type === "GiveAction") {
// agent.did is for legacy data, before March 2023
const giver = claim.agent?.identifier || claim.agent?.did;
const giverInfo = didInfo(giver, activeDid, identifiers, contacts);
let gaveAmount = claim.object?.amountOfThisGood
? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: "";
if (claim.description) {
if (gaveAmount) {
gaveAmount = gaveAmount + ", and also: ";
}
gaveAmount = gaveAmount + claim.description;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// recipient.did is for legacy data, before March 2023
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
: "";
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
} else if (type === "JoinAction") {
// agent.did is for legacy data, before March 2023
const agent = claim.agent?.identifier || claim.agent?.did;
const contactInfo = didInfo(agent, activeDid, identifiers, contacts);
let eventOrganizer =
claim.event && claim.event.organizer && claim.event.organizer.name;
eventOrganizer = eventOrganizer || "";
let eventName = claim.event && claim.event.name;
eventName = eventName ? " " + eventName : "";
let fullEvent = eventOrganizer + eventName;
fullEvent = fullEvent ? " attended the " + fullEvent : "";
let eventDate = claim.event && claim.event.startTime;
eventDate = eventDate ? " at " + eventDate : "";
return contactInfo + fullEvent + eventDate;
} else if (isOffer(claim)) {
const offerer = claim.offeredBy?.identifier;
const contactInfo = didInfo(offerer, activeDid, identifiers, contacts);
let offering = "";
if (claim.includesObject) {
offering +=
" " +
displayAmount(
claim.includesObject.unitCode,
claim.includesObject.amountOfThisGood,
);
}
if (claim.itemOffered?.description) {
offering += ", saying: " + claim.itemOffered?.description;
}
// recipient.did is for legacy data, before March 2023
const offerRecipientId =
claim.recipient?.identifier || claim.recipient?.did;
const offerRecipientInfo = offerRecipientId
? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
: "";
return contactInfo + " offered" + offering + offerRecipientInfo;
} else if (type === "PlanAction") {
const claimer = claim.agent?.identifier || record.issuer;
const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts);
return claimerInfo + " announced a project: " + claim.name;
} else if (type === "Tenure") {
// party.did is for legacy data, before March 2023
const claimer = claim.party?.identifier || claim.party?.did;
const contactInfo = didInfo(claimer, activeDid, identifiers, contacts);
const polygon = claim.spatialUnit?.geo?.polygon || "";
return (
contactInfo +
" possesses [" +
polygon.substring(0, polygon.indexOf(" ")) +
"...]"
);
} else {
return issuer + " declared " + claimSummary(claim as GenericCredWrapper);
}
};
export const BVC_MEETUPS_PROJECT_CLAIM_ID =
import.meta.env.VITE_BVC_MEETUPS_PROJECT_CLAIM_ID ||
"https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; // this won't resolve as a URL on production; it's a URN only found in the test system
export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
return {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "JoinAction",
agent: {
identifier: did,
},
event: {
organizer: {
name: "Bountiful Voluntaryist Community",
},
name: "Saturday Morning Meeting",
startTime: startTime,
},
};
};

View File

@@ -1,20 +1,244 @@
// 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 { db } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
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> = {
"BTC": "BTC",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
};
/* eslint-enable prettier/prettier */
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BTC": "Bitcoin",
"ETH": "Ethereum",
"HUR": "hours",
"USD": "dollars",
};
/* eslint-enable prettier/prettier */
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 giveIsConfirmable = (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 (
giveIsConfirmable(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
);
};
/**
* @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer
*/
export const offerGiverDid: (arg0: 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 Offer records for DID ${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 (
subscription: PushSubscription,
subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean,
): Promise<AxiosResponse> => {
await db.open();
@@ -29,28 +253,11 @@ export const sendTestThroughPushServer = async (
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const auth = Buffer.from(subscription.getKey("auth"));
const authB64 = auth
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const p256dh = Buffer.from(subscription.getKey("p256dh"));
const p256dhB64 = p256dh
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const newPayload = {
endpoint: subscription.endpoint,
keys: {
auth: authB64,
p256dh: p256dhB64,
},
message: `Test, where you will see this message ${
skipFilter ? "un" : ""
}filtered.`,
// 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);

View File

@@ -18,8 +18,12 @@ import {
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
@@ -36,15 +40,20 @@ import {
faFileLines,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingHeart,
faHouseChimney,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
@@ -72,8 +81,12 @@ library.add(
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
@@ -90,15 +103,20 @@ library.add(
faFileLines,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingHeart,
faHouseChimney,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
@@ -119,9 +137,11 @@ library.add(
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia())
.use(VueAxios, axios)
.use(router)

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,162 @@ 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: "/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"
),
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: "/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: "/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

@@ -18,33 +18,137 @@
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<h2 class="text-md font-bold">{{ 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"></fa>
</button>
<span v-show="showIdCopy">Copied ID</span>
</div>
<div>
<fa icon="message" class="fa-fw text-slate-400"></fa>
{{ veriClaim.claim?.description }}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuer }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
veriClaim.issuer as string,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
Fulfills a bigger plan...
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
detailsForGive?.fulfillsType &&
detailsForGive?.fulfillsType !== 'PlanAction' &&
detailsForGive?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<a
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
class="text-blue-500 mt-4"
>
Fulfills
{{
capitalizeAndInsertSpacesBeforeCaps(
detailsForGive.fulfillsType,
)
}}...
</a>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan...
</router-link>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="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="confirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<button
v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()"
class="col-span-1 block w-fit text-center text-md bg-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" message="Offer fulfilled by" />
<div v-if="libsUtil.giveIsConfirmable(veriClaim)">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
@@ -71,16 +175,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 +205,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,27 +246,120 @@
</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 uppercase text-xl mt-8 mb-2">Claim</h2>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
</h2>
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN". They
are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation)"
class="text-blue-500"
>share this page with them</a
>
and see if they are willing to make an introduction.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to some
of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw"></fa>
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="copyToClipboard('The DID of ' + visDid, visDid)"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400"></fa
>&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
@@ -154,8 +377,8 @@
</p>
<button
v-else
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
@click="showFullClaim(veriClaim.id)"
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>
@@ -167,7 +390,7 @@
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
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>
@@ -179,50 +402,69 @@ 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 { GiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
components: { GiftedDialog, OfferDialog, 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;
R = R;
yaml = yaml;
containsHiddenDid = serverUtil.containsHiddenDid;
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();
@@ -236,13 +478,14 @@ export default class ClaimView extends Vue {
const accountsArr = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
this.accountIdentityStr = account?.identity || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring("/claim/".length);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
this.loadClaim(claimId, identity);
await this.loadClaim(claimId, identity);
} else {
this.$notify(
{
@@ -254,6 +497,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() {
@@ -274,7 +529,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;
@@ -292,18 +547,20 @@ 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 {
@@ -311,39 +568,62 @@ export default class ClaimView extends Vue {
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,
});
@@ -369,16 +649,23 @@ 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");
@@ -393,7 +680,7 @@ export default class ClaimView extends Vue {
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",
@@ -429,6 +716,7 @@ export default class ClaimView extends Vue {
}
}
// 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
@@ -441,10 +729,7 @@ export default class ClaimView extends Vue {
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any;
} = {
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
@@ -466,7 +751,7 @@ export default class ClaimView extends Vue {
5000,
);
} else {
console.log("Got error submitting the confirmation:", result);
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
@@ -479,5 +764,49 @@ export default class ClaimView extends Vue {
}
}
}
showDifferentClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
});
}
openFulfillGiftDialog() {
const giver: GiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
this.veriClaim.handleId,
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation,
});
}
}
</script>

View File

@@ -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

@@ -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>
@@ -105,39 +105,33 @@
</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,
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 ContactAmountssView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = [];
giveRecords: Array<GiveSummaryRecord> = [];
numAccounts = 0;
async beforeCreate() {
@@ -155,7 +149,7 @@ export default class ContactAmountssView 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;
@@ -185,7 +179,7 @@ export default class ContactAmountssView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.log("Error retrieving settings or gives.", err);
console.error("Error retrieving settings or gives.", err);
this.$notify(
{
group: "alert",
@@ -193,7 +187,7 @@ export default class ContactAmountssView extends Vue {
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings and/or contacts and/or gives.",
"There was an error retrieving your settings or contacts or gives.",
},
-1,
);
@@ -203,7 +197,7 @@ export default class ContactAmountssView 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=" +
@@ -258,7 +252,7 @@ export default class ContactAmountssView 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,
@@ -277,7 +271,7 @@ export default class ContactAmountssView 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

@@ -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
/>
Anonymous/Unnamed
</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>
@@ -69,46 +69,73 @@
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
:projectId="projectId"
/>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { IIdentifier } from "@veramo/core";
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 { GiverInputInfo } from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { IIdentifier } from "@veramo/core";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class ContactGiftingView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$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 +148,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,30 +163,6 @@ 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) {
console.log("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.message ||
"There was an error retrieving your settings and/or contacts.",
},
-1,
);
}
}
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}

View File

@@ -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,19 +44,29 @@
: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>
@@ -66,25 +79,17 @@ import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
import {deriveAddress, getContactPayloadFromJwtUrl, nextDerivationPath, SimpleSigner} from "@/libs/crypto";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { Buffer } from "buffer/";
@Component({
components: {
@@ -94,7 +99,7 @@ interface Notification {
},
})
export default class ContactQRScanShow extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
@@ -112,7 +117,7 @@ export default class ContactQRScanShow extends Vue {
if (!identity) {
throw new Error(
"Attempted to show contact info with no identity available.",
"Attempted to show contact info with no identifier available.",
);
}
return identity;
@@ -149,6 +154,7 @@ export default class ContactQRScanShow extends Vue {
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
profileImageUrl: settings?.profileImageUrl,
},
};
@@ -173,10 +179,24 @@ export default class ContactQRScanShow extends Vue {
// 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" });
const url = content[0]?.rawValue;
if (url) {
try {
const fullData = getContactPayloadFromJwtUrl(url);
console.log("fullData", fullData);
localStorage.setItem("contactEndorserUrl", url);
this.$router.push({ name: "contacts" });
} catch (e) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Contact QR Code",
text: "The QR code isn't in the right format.",
},
5000,
);
}
} else {
this.$notify(
{
@@ -185,14 +205,14 @@ export default class ContactQRScanShow extends Vue {
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
-1,
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",
@@ -200,7 +220,7 @@ export default class ContactQRScanShow extends Vue {
title: "Invalid Scan",
text: "The scan was invalid.",
},
-1,
5000,
);
}

View File

@@ -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>

View File

@@ -9,27 +9,28 @@
<div class="flex justify-between py-2">
<span />
<span>
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
<a
href="/help-onboarding"
target="_blank"
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>
Onboarding Guide
</a>
</span>
</div>
<!-- New Contact -->
<div class="mb-4 flex items-stretch">
<div class="mt-4 mb-4 flex items-stretch">
<router-link
:to="{ name: 'contact-qr' }"
class="flex items-center bg-slate-500 text-white px-1.5 py-1 mr-1 rounded-md"
class="flex items-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-1 mr-1 rounded-md"
>
<fa icon="qrcode" class="fa-fw text-2xl" />
</router-link>
<input
<textarea
type="text"
placeholder="DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
placeholder="URL or DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
v-model="contactInput"
/>
<button
@@ -44,14 +45,14 @@
<div class="w-full text-right">
Hours to Add:
<input
class="border border rounded border-slate-400 w-24 text-right"
class="border rounded border-slate-400 w-24 text-right"
type="text"
placeholder="1"
v-model="hourInput"
/>
<br />
<input
class="border border rounded border-slate-400 w-48"
class="border rounded border-slate-400 w-48"
type="text"
placeholder="Description"
v-model="hourDescriptionInput"
@@ -73,9 +74,13 @@
}}
</button>
<br />
(Only hours shown)
<br />
(Only recent shown)
(Only most recent hours included. To see more, click
<span
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
>
<fa icon="file-lines" class="fa-fw" />
</span>
)
</div>
</div>
@@ -89,23 +94,38 @@
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
<EntityIcon
:entityId="contact.did"
:contact="contact"
:iconSize="24"
class="inline-block align-text-bottom border border-slate-300 rounded"
></EntityIcon>
{{ contact.name || "(no name)" }}
@click="showLargeIdenticon = contact"
/>
{{ contact.name || AppString.NO_CONTACT_NAME }}
<button
class="text-sm uppercase bg-slate-500 text-white px-1 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
@click="
contactEdit = contact;
contactNewName = contact.name;
contactNewName = contact.name || '';
"
title="Edit"
>
<fa icon="pen" class="fa-fw" />
</button>
</h2>
<div class="text-sm truncate">{{ contact.did }}</div>
<div class="text-sm truncate">
{{ contact.did }}
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.did,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }}
</div>
@@ -118,7 +138,7 @@
<div v-if="activeDid">
<button
v-if="contact.seesMe"
class="text-sm uppercase bg-slate-500 text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="setVisibility(contact, false, true)"
title="They can see you"
>
@@ -126,14 +146,14 @@
</button>
<button
v-else
class="text-sm uppercase bg-slate-500 text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="setVisibility(contact, true, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<button
class="text-sm uppercase bg-slate-500 text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)"
title="Check Visibility"
v-if="activeDid"
@@ -142,27 +162,22 @@
</button>
<button
@click="register(contact)"
class="text-sm uppercase bg-slate-500 text-white ml-6 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
v-if="activeDid"
title="Registration"
>
<fa
v-if="contact.registered"
icon="person-circle-check"
class="fa-fw"
title="Registered"
/>
<fa
v-else
icon="person-circle-question"
class="fa-fw"
title="Registration Unknown"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
</div>
<button
@click="deleteContact(contact)"
class="text-sm uppercase bg-red-600 text-white ml-24 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-24 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
@@ -173,7 +188,7 @@
class="ml-auto flex gap-1.5"
>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-l-md"
class="text-sm 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-1.5 rounded-l-md"
@click="onClickAddGive(activeDid, contact.did)"
:title="givenByMeDescriptions[contact.did] || ''"
>
@@ -215,8 +230,8 @@
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
title="See all given activity"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md"
title="See more given activity"
>
<fa icon="file-lines" class="fa-fw" />
</router-link>
@@ -226,6 +241,18 @@
</li>
</ul>
<p v-else>There are no contacts.</p>
<div v-if="showLargeIdenticon" 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
:contact="showLargeIdenticon"
:iconSize="512"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="showLargeIdenticon = undefined"
/>
</div>
</div>
<div v-if="contactEdit !== null" class="dialog-overlay">
<div class="dialog">
@@ -236,19 +263,21 @@
placeholder="Name"
v-model="contactNewName"
/>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactEdit, contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
<div class="flex justify-between">
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactEdit, contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</div>
</section>
@@ -256,11 +285,13 @@
<script lang="ts">
import { AxiosError } from "axios";
import { IndexableType } from "dexie";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { NotificationIface } from "@/constants/app";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { AppString, 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";
@@ -270,19 +301,20 @@ import {
SimpleSigner,
} from "@/libs/crypto";
import {
CONTACT_CSV_HEADER,
CONTACT_URL_PREFIX,
GiveServerRecord,
GiveSummaryRecord,
GiveVerifiableCredential,
isDid,
RegisterVerifiableCredential,
SERVICE_ID,
} from "@/libs/endorserServer";
import { Component, Vue } from "vue-facing-decorator";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
import { Buffer } from "buffer/";
@Component({
components: { QuickNav, EntityIcon },
@@ -312,9 +344,14 @@ export default class ContactsView extends Vue {
hourDescriptionInput = "";
hourInput = "0";
isRegistered = false;
showDidCopy = false;
showGiveNumbers = false;
showGiveTotals = true;
showGiveConfirmed = true;
showLargeIdenticon?: Contact;
AppString = AppString;
libsUtil = libsUtil;
async created() {
await db.open();
@@ -327,14 +364,16 @@ export default class ContactsView extends Vue {
if (this.showGiveNumbers) {
this.loadGives();
}
const allContacts = await db.contacts.toArray();
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
// .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.contacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
if (this.contactEndorserUrl) {
await this.newContactFromScan(this.contactEndorserUrl);
await this.addContactFromScan(this.contactEndorserUrl);
localStorage.removeItem("contactEndorserUrl");
this.contactEndorserUrl = "";
}
@@ -348,7 +387,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;
@@ -376,7 +415,7 @@ export default class ContactsView extends Vue {
}
const handleResponse = (
resp: { status: number; data: { data: GiveServerRecord[] } },
resp: { status: number; data: { data: GiveSummaryRecord[] } },
descriptions: Record<string, string>,
confirmed: Record<string, number>,
unconfirmed: Record<string, number>,
@@ -464,7 +503,7 @@ export default class ContactsView extends Vue {
this.givenToMeConfirmed = givenToMeConfirmed;
this.givenToMeUnconfirmed = givenToMeUnconfirmed;
} catch (error) {
console.log("Error loading gives", error);
console.error("Error loading gives", error);
this.$notify(
{
group: "alert",
@@ -486,13 +525,54 @@ export default class ContactsView extends Vue {
title: "No Contact",
text: "There was no contact info to add.",
},
-1,
3000,
);
return;
}
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.newContactFromScan(this.contactInput);
await this.addContactFromScan(this.contactInput);
return;
}
if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = this.contactInput.split(/\n/);
const lineAdded = [];
for (const line of lines) {
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
continue;
}
lineAdded.push(this.addContactFromEndorserMobileLine(line));
}
try {
await Promise.all(lineAdded);
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added",
text: "Each contact was added. Nothing was sent to the server.",
},
3000, // keeping it up so that the "visibility" message is seen
);
} catch (e) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Contacts Maybe Added",
text: "An error occurred. Some contacts may have been added.",
},
-1,
);
}
// .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.contacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
return;
}
@@ -533,7 +613,48 @@ export default class ContactsView extends Vue {
await this.addContact(newContact);
}
async newContactFromScan(url: string): Promise<void> {
async addContactFromEndorserMobileLine(line: string): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc.
let name = line;
let did = "";
let publicKeyInput, seesMe, registered;
const commaPos1 = line.indexOf(",");
if (commaPos1 > -1) {
name = line.substring(0, commaPos1).trim();
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = {
did,
name,
publicKeyBase64,
seesMe,
registered,
};
return db.contacts.add(newContact);
}
async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
@@ -543,7 +664,7 @@ export default class ContactsView extends Vue {
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
-1,
3000,
);
return;
} else {
@@ -551,6 +672,7 @@ export default class ContactsView extends Vue {
did: payload.iss,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
} as Contact);
}
@@ -565,7 +687,19 @@ export default class ContactsView extends Vue {
title: "Incomplete Contact",
text: "Cannot add a contact without a DID.",
},
-1,
5000,
);
return;
}
if (!isDid(newContact.did)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid DID",
text: "The DID is not valid. It must begin with 'did:'",
},
5000,
);
return;
}
@@ -582,10 +716,9 @@ export default class ContactsView extends Vue {
if (this.activeDid) {
this.setVisibility(newContact, true, false);
addedMessage =
newContact.name +
" was added, and your activity is visible to them.";
"They were added, and your activity is visible to them.";
} else {
addedMessage = newContact.name + " was added.";
addedMessage = "They were added.";
}
if (this.isRegistered) {
this.$notify(
@@ -593,10 +726,7 @@ export default class ContactsView extends Vue {
group: "alert",
type: "info",
title: "New User?",
text:
"If " +
newContact.name +
" is a new user, be sure to register them.",
text: "If they are a new user, be sure to register to onboard them.",
},
-1,
);
@@ -608,7 +738,7 @@ export default class ContactsView extends Vue {
title: "Contact Added",
text: addedMessage,
},
-1, // keeping it up so that the "visibility" message is seen
3000,
);
})
.catch((err) => {
@@ -620,6 +750,10 @@ export default class ContactsView extends Vue {
message =
"A contact with that DID is already in your contact list. Edit them directly below.";
}
if (err.name === "ConstraintError") {
message +=
" Check that the contact doesn't conflict with any you already have.";
}
this.$notify(
{
group: "alert",
@@ -653,7 +787,7 @@ export default class ContactsView extends Vue {
async register(contact: Contact) {
if (
confirm(
"Are you sure you want to use one of your registrations for " +
"Are you sure you want to register " +
this.nameForDid(this.contacts, contact.did) +
(contact.registered
? " -- especially since they are already marked as registered"
@@ -720,7 +854,7 @@ export default class ContactsView extends Vue {
title: "Registration Still Unknown",
text: message,
},
-1,
5000,
);
} else if (resp.data?.success?.handleId) {
contact.registered = true;
@@ -729,11 +863,13 @@ export default class ContactsView extends Vue {
this.$notify(
{
group: "alert",
type: "info",
type: "success",
title: "Registration Success",
text: contact.name + " has been registered.",
text:
(contact.name || "That unnamed person") +
" has been registered.",
},
-1,
5000,
);
}
} catch (error) {
@@ -741,7 +877,9 @@ export default class ContactsView extends Vue {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
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());
@@ -757,7 +895,7 @@ export default class ContactsView extends Vue {
title: "Registration Error",
text: userMessage,
},
-1,
5000,
);
}
}
@@ -774,7 +912,7 @@ export default class ContactsView extends Vue {
(visibility
? "Are you sure you want to make your activity visible to them?"
: "Are you sure you want to hide all your activity from them?");
if (visibilityPrompt && confirm(visibilityPrompt)) {
if (!visibilityPrompt || confirm(visibilityPrompt)) {
const url =
this.apiServer +
"/api/report/" +
@@ -798,7 +936,7 @@ export default class ContactsView extends Vue {
(visibility ? "" : "not ") +
"see your activity.",
},
-1,
3000,
);
}
contact.seesMe = visibility;
@@ -818,7 +956,7 @@ export default class ContactsView extends Vue {
title: "Error Setting Visibility",
text: message,
},
-1,
5000,
);
}
} catch (err) {
@@ -830,7 +968,7 @@ export default class ContactsView extends Vue {
title: "Error Setting Visibility",
text: "Check connectivity and try again.",
},
-1,
5000,
);
}
}
@@ -862,10 +1000,10 @@ export default class ContactsView extends Vue {
(visibility ? "" : "not ") +
"see your activity.",
},
-1,
3000,
);
} else {
console.log("Got bad server response when checking visibility: ", resp);
console.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
@@ -874,11 +1012,11 @@ export default class ContactsView extends Vue {
title: "Error Checking Visibility",
text: message,
},
-1,
5000,
);
}
} catch (err) {
console.log("Caught error from request to check visibility:", err);
console.error("Caught error from request to check visibility:", err);
this.$notify(
{
group: "alert",
@@ -886,24 +1024,18 @@ export default class ContactsView extends Vue {
title: "Error Checking Visibility",
text: "Check connectivity and try again.",
},
-1,
3000,
);
}
}
// from https://stackoverflow.com/a/175787/845494
//
private isNumeric(str: string): boolean {
return !isNaN(+str);
}
private nameForDid(contacts: Array<Contact>, did: string): string {
const contact = R.find((con) => con.did == did, contacts);
return this.nameForContact(contact);
}
private nameForContact(contact?: Contact, capitalize?: boolean): string {
return contact?.name || (capitalize ? "T" : "t") + "this unnamed user";
return contact?.name || (capitalize ? "T" : "t") + "his unnamed user";
}
async onClickAddGive(fromDid: string, toDid: string): Promise<void> {
@@ -932,7 +1064,7 @@ export default class ContactsView extends Vue {
return;
}
}
if (!this.isNumeric(this.hourInput)) {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
@@ -940,7 +1072,7 @@ export default class ContactsView extends Vue {
title: "Input Error",
text: "This is not a valid number of hours: " + this.hourInput,
},
-1,
3000,
);
} else if (parseFloat(this.hourInput) == 0 && !this.hourDescriptionInput) {
this.$notify(
@@ -948,9 +1080,9 @@ export default class ContactsView extends Vue {
group: "alert",
type: "danger",
title: "Input Error",
text: "Giving no hours or descrption does nothing.",
text: "Giving no hours or description does nothing.",
},
-1,
3000,
);
} else if (!identity) {
this.$notify(
@@ -958,9 +1090,9 @@ export default class ContactsView extends Vue {
group: "alert",
type: "danger",
title: "Status Error",
text: "No identity is available.",
text: "No identifier is available.",
},
-1,
3000,
);
} else {
// ask to confirm amount
@@ -988,7 +1120,7 @@ export default class ContactsView extends Vue {
"?",
)
) {
this.createAndSubmitGive(
this.createAndSubmitContactGive(
identity,
fromDid,
toDid,
@@ -1000,7 +1132,7 @@ export default class ContactsView extends Vue {
}
// similar function is in endorserServer.ts
private async createAndSubmitGive(
private async createAndSubmitContactGive(
identity: IIdentifier,
fromDid: string,
toDid: string,
@@ -1055,7 +1187,7 @@ export default class ContactsView extends Vue {
title: "Done",
text: "Successfully logged time to the server.",
},
-1,
5000,
);
if (fromDid === identity.did) {
@@ -1069,7 +1201,7 @@ export default class ContactsView extends Vue {
}
}
} catch (error) {
console.log("Error in createAndSubmitGive: ", error);
console.error("Error in createAndSubmitContactGive: ", error);
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
@@ -1089,7 +1221,7 @@ export default class ContactsView extends Vue {
title: "Error Sending Give",
text: userMessage,
},
-1,
5000,
);
}
}

View File

@@ -51,13 +51,13 @@
<li>
<a
href="#"
v-bind:class="computedRemoteTabClassNames()"
@click="
projects = [];
isRemoteActive = true;
isLocalActive = false;
searchAll();
"
v-bind:class="computedRemoteTabClassNames()"
>
Anywhere
<span
@@ -103,11 +103,11 @@
class="block py-4 flex gap-4"
>
<div class="w-12">
<EntityIcon
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="block border border-slate-300 rounded-md"
></EntityIcon>
></ProjectIcon>
</div>
<div class="grow">
@@ -129,40 +129,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";
import TopMessage from "@/components/TopMessage.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;
@@ -176,8 +170,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();
@@ -221,7 +215,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.",
);
}
@@ -258,7 +252,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",
@@ -274,7 +268,7 @@ 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, issuerDid, rowid } = plan;
@@ -286,7 +280,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",
@@ -341,7 +335,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",
@@ -358,7 +352,7 @@ 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, issuerDid, rowid } = plan;
this.projects.push({
@@ -378,7 +372,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",
@@ -426,13 +420,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,
};
}
@@ -442,13 +438,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,
};
}
}

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

@@ -0,0 +1,459 @@
<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="cancel()"
>
<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">
{{ message }} {{ giverName || "somebody not named" }}
</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] }}
</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="openPhotoDialog"
/>
</span>
</div>
<GiftedPhotoDialog ref="photoDialog" />
<div v-if="projectId" class="mt-4">
<fa
icon="check"
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
/>
<label class="text-sm">This is given to a project</label>
</div>
<div v-if="!projectId" class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</div>
<div class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm">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 { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({
components: {
GiftedDialog,
GiftedPhotoDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
givenToUser = false;
giverDid: string | undefined;
giverName = "";
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
projectId = "";
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
this.amountInput = this.$route.query.amountInput as string;
this.description = this.$route.query.description as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = this.$route.query.giverName as string;
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.unitCode = this.$route.query.unitCode as string;
this.imageUrl = localStorage.getItem("imageUrl") || "";
this.givenToUser = !this.projectId;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.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,
);
}
}
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
this.$router.back();
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
});
}
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("Non-success 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();
}
/**
*
* @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 result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
this.giverDid,
this.givenToUser ? this.activeDid : undefined,
this.description,
parseFloat(this.amountInput),
this.unitCode,
this.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");
this.$router.back();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

View File

@@ -31,7 +31,7 @@
If this works then you're all set.
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
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)
@@ -233,7 +233,7 @@
<h2 class="text-xl font-semibold mt-4">Tests</h2>
<button
@click="showTestNotification()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
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>
@@ -246,7 +246,7 @@
<button
@click="alertWebPushSubscription()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
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>
@@ -259,7 +259,7 @@
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
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)
@@ -272,7 +272,7 @@
<button
@click="sendTestWebPushMessage()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
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)
@@ -294,25 +294,20 @@
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { sendTestThroughPushServer } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class HelpNotificationsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
subscription: PushSubscription | null = null;
subscriptionJSON?: PushSubscriptionJSON;
async mounted() {
try {
const registration = await navigator.serviceWorker.ready;
this.subscription = await registration.pushManager.getSubscription();
const fullSub = await registration.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) {
console.error("Mount error:", error);
}
@@ -321,13 +316,13 @@ export default class HelpNotificationsView extends Vue {
alertWebPushSubscription() {
console.log(
"Web push subscription:",
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
);
alert(JSON.stringify(this.subscription));
alert(JSON.stringify(this.subscriptionJSON));
}
async sendTestWebPushMessage(skipFilter: boolean = false) {
if (!this.subscription) {
if (!this.subscriptionJSON) {
this.$notify(
{
group: "alert",
@@ -342,7 +337,7 @@ export default class HelpNotificationsView extends Vue {
}
try {
await sendTestThroughPushServer(this.subscription, skipFilter);
await sendTestThroughPushServer(this.subscriptionJSON, skipFilter);
this.$notify(
{

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

@@ -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>
@@ -39,22 +39,22 @@
and network.
</p>
<p>
You can show giving and also offer help to ideas, based on others'
willingness to help out, too. You can record your own ideas and invite
others to collaborate.
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
@@ -64,15 +64,28 @@
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">
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Click here to show an alert with the steps.
</button>
</a>
To start scanning, go
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
</p>
@@ -125,7 +138,7 @@
<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>
@@ -148,7 +161,7 @@
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for Data Import.
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
</li>
</ul>
</div>
@@ -185,9 +198,7 @@
<ul>
<li class="list-disc list-outside ml-4">
Chrome:
<a href="chrome://settings/content/all" class="text-blue-500"
>clear here</a
>
Clear at chrome://settings/content/all and
also clear under dev tools Application
</li>
<li class="list-disc list-outside ml-4">
@@ -205,30 +216,10 @@
<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.
</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
>
Let's talk. Contact us (below).
</p>
<h2 class="text-xl font-semibold">
@@ -246,9 +237,93 @@
</a>
</p>
<h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info?
</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 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" 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 marked with
This work is public domain, governed by
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
@@ -301,35 +376,17 @@
</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,10 +1,10 @@
<template>
<QuickNav selected="Home"></QuickNav>
<QuickNav selected="Home" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
<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>
@@ -15,7 +15,8 @@
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<p style="display: inline; align-items: center">
This app doesn't support notifications, so let's fix that. <br />
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'">
@@ -29,7 +30,7 @@
and go click on that new app.
</span>
<span
v-else-if="userAgentInfo.getBrowser().name.startsWith('Chrome')"
v-else-if="userAgentInfo.getBrowser()?.name?.startsWith('Chrome')"
>
You should see a prompt to install, or you can click on the
top-right dots
@@ -44,33 +45,49 @@
width="30"
style="display: inline; margin: 0 5px; vertical-align: middle"
/>
and go use that app.
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.
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 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"
>
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"></fa> Loading&hellip;
</p>
</div>
<div
v-if="!activeDid"
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 anyone's gives.
Want to connect with your contacts, or share contributions or
projects?
</p>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
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"
>
Create Your Identifier</router-link
Create An Identifier</router-link
>
</div>
@@ -78,33 +95,37 @@
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 anyone's gives.
To do this:
Someone must register your identifier before you can record anyone's
giving.
<router-link
:to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
1. Show Them Your Identity Info</router-link
Show Them Your Identifier Info</router-link
>
<router-link
:to="{ name: 'account' }"
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
>
2. Check Your Limits</router-link
<br />
To double-check that you're registered,
<br />
<router-link :to="{ name: 'account' }" class="text-blue-500">
see your Usage Limits on the Account
<fa icon="circle-user" /> page.</router-link
>
</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"
>
@@ -112,15 +133,15 @@
</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>
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
@@ -129,21 +150,20 @@
</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-gives' }"
class="block text-center text-md font-bold 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"
>
Choose From All Contacts
</router-link>
<button
@click="openGiftedPrompts()"
class="block text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
</div>
</div>
</div>
@@ -153,10 +173,30 @@
message="Received from"
showGivenToUser="true"
/>
<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="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 uppercase 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
@@ -168,59 +208,116 @@
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
>
You've seen all the following
You've already 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>
<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">
<span class="pl-2">
{{ giveDescription(record) }}
</span>
<a @click="onClickLoadClaim(record.jwtId)">
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
></fa>
</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"></fa>
</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 :class="{ hidden: isHiddenSpinner }">
<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;
</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>
</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 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 { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
BoundingBox,
isAnyFeedFilterOn,
MASTER_SETTINGS_KEY,
Settings,
} from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
didInfo,
contactForDid,
containsNonHiddenDid,
didInfoForContact,
getPlanFromCache,
GiverInputInfo,
GiveServerRecord,
GiveSummaryRecord,
} from "@/libs/endorserServer";
import { IIdentifier } from "@veramo/core";
import { generateSaveAndActivateIdentity } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
giver: {
displayName: string;
known: boolean;
};
image?: string;
recipientProjectName?: string;
receiver: {
displayName: string;
known: boolean;
};
}
@Component({
components: {
GiftedDialog,
GiftedPrompts,
FeedFilters,
QuickNav,
EntityIcon,
InfiniteScroll,
@@ -228,25 +325,28 @@ interface Notification {
},
})
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 = "";
feedData = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedClaimId?: string;
isHiddenSpinner = true;
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) {
await accountsDB.open();
const account = (await accountsDB.accounts
@@ -266,7 +366,7 @@ export default class HomeView extends Vue {
return headers;
}
async created() {
async mounted() {
try {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
@@ -278,14 +378,28 @@ export default class HomeView extends Vue {
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
this.isRegistered = !!settings?.isRegistered;
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;
}
// this returns a Promise but we don't need to wait for it
this.updateAllFeed();
await this.updateAllFeed();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.log("Error retrieving settings and/or feed.", err);
console.error("Error retrieving settings or feed.", err);
this.$notify(
{
group: "alert",
@@ -293,13 +407,17 @@ export default class HomeView extends Vue {
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings and/or the latest activity.",
"There was an error retrieving your settings or the latest activity.",
},
-1,
);
}
}
resultsAreFiltered() {
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
}
notificationsSupported() {
return "Notification" in window;
}
@@ -309,27 +427,34 @@ export default class HomeView extends Vue {
"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
@@ -340,12 +465,85 @@ export default class HomeView extends Vue {
}
}
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;
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);
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
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.
@@ -361,7 +559,7 @@ export default class HomeView extends Vue {
}
})
.catch((e) => {
console.log("Error with feed load:", e);
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -372,7 +570,11 @@ export default class HomeView extends Vue {
-1,
);
});
this.isHiddenSpinner = true;
if (this.feedData.length === 0 && !endOfResults) {
// repeat until there's at least some data
this.updateAllFeed();
}
this.isFeedLoading = false;
}
/**
@@ -384,14 +586,16 @@ export default class HomeView extends Vue {
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();
}
@@ -404,46 +608,68 @@ 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) {
@@ -461,8 +687,16 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
openDialog(giver: GiverInputInfo) {
openDialog(giver?: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
openGiftedPrompts() {
(this.$refs.giftedPrompts as GiftedPrompts).open();
}
openFeedFilters() {
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
}
}
</script>

View File

@@ -18,13 +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">
<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 -->
@@ -35,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">
@@ -50,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
@@ -65,41 +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 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();
@@ -109,19 +109,13 @@ export default class IdentitySwitcherView extends Vue {
this.apiServerInput = settings?.apiServer || "";
this.showContactGives = !!settings?.showContactGivesInline;
const identity = await this.getIdentity(this.activeDid);
if (identity) {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
}
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"];
if (did && this.activeDid !== did) {
this.otherIdentities.push({ did: did });
this.otherIdentities.push({ did: did });
if (did && this.activeDid === did) {
this.activeDidInIdentities = true;
}
}
} catch (err) {
@@ -147,16 +141,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

@@ -10,12 +10,12 @@
>
<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
@@ -25,7 +25,7 @@
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

@@ -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>

View File

@@ -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 max-w-3xl mx-auto">
<!-- 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

@@ -29,6 +29,23 @@
v-model="fullClaim.name"
/>
<input
type="text"
placeholder="Other Authorized Representative"
class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid"
/>
<div class="mb-4">
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
<span class="text-red-500">Beware!</span>
If you save this, the original project owner will no longer be able to
edit it.
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
Click here to make the original owner an authorized representative.
</button>
</p>
</div>
<textarea
placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
@@ -43,6 +60,7 @@
<input
v-model="fullClaim.url"
placeholder="Website"
autocapitalize="none"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
@@ -55,16 +73,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;
@@ -86,28 +105,30 @@
</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>
@@ -120,6 +141,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
@@ -127,20 +149,14 @@ 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;
}
@Component({
components: { LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
agentDid = "";
apiServer = "";
errorMessage = "";
fullClaim: PlanVerifiableCredential = {
@@ -150,9 +166,14 @@ export default class NewEditProjectView extends Vue {
description: "",
}; // this default is only to avoid errors before plan is loaded
includeLocation = false;
isHiddenSave = false;
isHiddenSpinner = true;
lastClaimJwtId = "";
latitude = 0;
longitude = 0;
numAccounts = 0;
projectId = localStorage.getItem("projectId") || "";
projectIssuerDid = "";
zoom = 2;
async beforeCreate() {
@@ -166,11 +187,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,15 +206,11 @@ export default class NewEditProjectView extends Vue {
return headers;
}
projectId = localStorage.getItem("projectId") || "";
isHiddenSave = false;
isHiddenSpinner = true;
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) {
@@ -202,15 +219,15 @@ export default class NewEditProjectView extends Vue {
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 +241,35 @@ 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.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;
}
}
} catch (error) {
console.error("Got error retrieving that project", error);
}
}
private async SaveProject(identity: IIdentifier) {
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.includeLocation) {
vcClaim.location = {
@@ -250,6 +279,8 @@ export default class NewEditProjectView extends Vue {
longitude: this.longitude,
},
};
} else {
delete vcClaim.location;
}
// Make a payload for the claim
const vcPayload = {
@@ -283,25 +314,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 +343,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,7 +397,7 @@ 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);
}
}

View File

@@ -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,28 +62,7 @@ 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: "home" });

View File

@@ -23,11 +23,11 @@
<div>
<div class="block pb-4 flex gap-4">
<div class="flex-none w-16 pt-1">
<EntityIcon
<ProjectIcon
:entityId="projectId"
:iconSize="64"
class="block border border-slate-300 rounded-md"
></EntityIcon>
></ProjectIcon>
</div>
<div class="overflow-hidden">
@@ -35,7 +35,23 @@
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ issuer }}
{{
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
}}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
issuer,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div v-if="timeSince">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
@@ -80,11 +96,16 @@
>
</div>
</div>
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</div>
<button
v-if="issuer == activeDid"
v-if="activeDid === issuer || activeDid === agentDid"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onEditClick()"
>
Edit
@@ -94,32 +115,41 @@
<div v-if="activeDid" class="mb-4">
<div class="text-center">
<button
@click="openOfferDialog({ name: 'you', did: activeDid })"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
@click="openOfferDialog()"
class="block w-full 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"
>
I offer&hellip;
Offer (maybe with conditions)...
</button>
</div>
</div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid">
<GiftedDialog
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<div class="text-center">
<button
@click="openGiftDialog({ name: 'you', did: activeDid })"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
I gave&hellip;
</button>
<p class="mt-2 mb-4 text-center">Or, record a contribution from:</p>
<p class="mt-2 mb-4 text-center">Record a contribution from:</p>
</div>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
>
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
<h3
class="mt-5 text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
You
</h3>
</li>
<li @click="openGiftDialog()">
<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"
>
@@ -127,15 +157,15 @@
</h3>
</li>
<li
v-for="contact in allContacts"
v-for="contact in allContacts.slice(0, 6)"
:key="contact.did"
@click="openGiftDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
@@ -145,13 +175,13 @@
</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
<a
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"
@click="onClickAllContactsGifting()"
class="block text-center text-md font-bold 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"
>
Show More Contacts&hellip;
</router-link>
</a>
</div>
<!-- Gifts to & from this -->
@@ -162,7 +192,10 @@
</h3>
<div v-if="offersToThis.length === 0">
(None yet. Record one above.)
(None yet. Wanna
<span @click="openOfferDialog()" class="cursor-pointer text-blue-500"
>offer something... especially if others join you</span
>?)
</div>
<ul v-else class="text-sm border-t border-slate-300">
@@ -174,22 +207,43 @@
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
{{
serverUtil.didInfo(
offer.offeredByDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<a @click="onClickLoadClaim(offer.jwtId)">
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
</a>
<span v-if="offer.amount">
<span v-if="offer.amount" class="whitespace-nowrap">
<fa
:icon="iconForUnitCode(offer.unit)"
:icon="libsUtil.iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>{{ offer.amount }}
</span>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
<fa icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }}
</div>
<div class="flex justify-between">
<a
@click="onClickLoadClaim(offer.jwtId as string)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
<a
v-if="checkIsFulfillable(offer)"
@click="onClickFulfillGiveToOffer(offer)"
>
<fa
icon="hand-holding-heart"
class="text-blue-500 cursor-pointer"
/>
</a>
</div>
</li>
</ul>
</div>
@@ -197,7 +251,10 @@
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
<div v-if="givesToThis.length === 0">
(None yet. If you've seen something, say something by clicking a
contact above.)
</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
@@ -208,22 +265,38 @@
<div class="flex justify-between gap-4">
<span
><fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
</a>
<span v-if="give.amount">
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="iconForUnitCode(give.unit)"
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
<a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
</div>
</li>
</ul>
</div>
@@ -251,7 +324,7 @@
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions By This Idea
Contributions From This Idea
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
@@ -265,15 +338,6 @@
</div>
</div>
</div>
<GiftedDialog
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
</OfferDialog>
</section>
</template>
@@ -286,55 +350,64 @@ import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import ProjectIcon from "@/components/ProjectIcon.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 { isGlobalUri } from "@/libs/util";
import * as libsUtil from "@/libs/util";
import {
didInfo,
BLANK_GENERIC_SERVER_RECORD,
GenericCredWrapper,
GiverInputInfo,
GiveServerRecord,
OfferServerRecord,
PlanServerRecord,
GiveSummaryRecord,
OfferSummaryRecord,
PlanSummaryRecord,
} from "@/libs/endorserServer";
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 * as serverUtil from "@/libs/endorserServer";
@Component({
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav, TopMessage },
components: {
EntityIcon,
GiftedDialog,
OfferDialog,
ProjectIcon,
QuickNav,
TopMessage,
},
})
export default class ProjectViewView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
agentDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
description = "";
expanded = false;
fulfilledByThis: PlanServerRecord | null = null;
fulfillersToThis: Array<PlanServerRecord> = [];
givesToThis: Array<GiveServerRecord> = [];
fulfilledByThis: PlanSummaryRecord | null = null;
fulfillersToThis: Array<PlanSummaryRecord> = [];
givesToThis: Array<GiveSummaryRecord> = [];
issuer = "";
latitude = 0;
longitude = 0;
name = "";
offersToThis: Array<OfferServerRecord> = [];
offersToThis: Array<OfferSummaryRecord> = [];
projectId = localStorage.getItem("projectId") || ""; // handle ID
showDidCopy = false;
timeSince = "";
truncatedDesc = "";
truncateLength = 40;
url = "";
libsUtil = libsUtil;
serverUtil = serverUtil;
async created() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@@ -344,7 +417,7 @@ export default class ProjectViewView extends Vue {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
const accountsArr: 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");
@@ -353,7 +426,7 @@ export default class ProjectViewView extends Vue {
if (pathParam) {
this.projectId = decodeURIComponent(pathParam);
}
this.LoadProject(this.projectId, identity);
this.loadProject(this.projectId, identity);
}
public async getIdentity(activeDid: string) {
@@ -366,15 +439,6 @@ export default class ProjectViewView extends Vue {
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
onEditClick() {
localStorage.setItem("projectId", this.projectId as string);
const route = {
@@ -384,15 +448,6 @@ export default class ProjectViewView 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 didInfo(did, activeDid, dids, contacts);
}
expandText() {
this.expanded = true;
}
@@ -401,7 +456,7 @@ export default class ProjectViewView extends Vue {
this.expanded = false;
}
async LoadProject(projectId: string, identity: IIdentifier) {
async loadProject(projectId: string, identity: IIdentifier) {
this.projectId = projectId;
const url =
@@ -423,6 +478,7 @@ export default class ProjectViewView extends Vue {
const now = moment.now();
this.timeSince = moment.utc(now).to(eventDate);
}
this.agentDid = resp.data.claim?.agent?.identifier;
this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
@@ -432,7 +488,7 @@ export default class ProjectViewView extends Vue {
this.url = resp.data.claim?.url || "";
} else {
// actually, axios throws an error on 404 so we probably never get here
console.log("Error getting project:", resp);
console.error("Error getting project:", resp);
this.$notify(
{
group: "alert",
@@ -471,7 +527,7 @@ export default class ProjectViewView extends Vue {
const givesInUrl =
this.apiServer +
"/api/v2/report/givesForPlans?planIds=" +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
@@ -624,7 +680,7 @@ export default class ProjectViewView extends Vue {
path: "/project/" + encodeURIComponent(projectId),
};
this.$router.push(route);
this.LoadProject(projectId, await this.getIdentity(this.activeDid));
this.loadProject(projectId, await this.getIdentity(this.activeDid));
}
getOpenStreetMapUrl() {
@@ -641,7 +697,7 @@ export default class ProjectViewView extends Vue {
);
}
openGiftDialog(contact: GiverInputInfo) {
openGiftDialog(contact?: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
}
@@ -649,6 +705,14 @@ export default class ProjectViewView extends Vue {
(this.$refs.customOfferDialog as OfferDialog).open();
}
onClickAllContactsGifting() {
localStorage.setItem("projectId", this.projectId);
const route = {
name: "contact-gives",
};
this.$router.push(route);
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
@@ -656,28 +720,31 @@ export default class ProjectViewView extends Vue {
this.$router.push(route);
}
UNIT_CODES: Record<string, Record<string, string>> = {
BTC: {
name: "Bitcoin",
faIcon: "bitcoin-sign",
},
HUR: {
name: "hours",
faIcon: "clock",
},
USD: {
name: "US Dollars",
faIcon: "dollar",
},
};
checkIsFulfillable(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper = {
...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
claimType: "Offer",
issuer: offer.offeredByDid,
};
return libsUtil.canFulfillOffer(offerRecord);
}
iconForUnitCode(unitCode: string) {
return this.UNIT_CODES[unitCode]?.faIcon || "question";
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper = {
...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giver: GiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord),
};
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
}
// return an HTTPS URL if it's not a global URL
addScheme(url: string) {
if (!isGlobalUri(url)) {
if (!libsUtil.isGlobalUri(url)) {
return "https://" + url;
}
return url;
@@ -702,5 +769,67 @@ export default class ProjectViewView extends Vue {
return url;
}
}
checkIsConfirmable(give: GiveSummaryRecord) {
const giveDetails: GenericCredWrapper = {
...BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim,
claimType: "GiveAction",
issuer: give.agentDid,
};
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
}
// similar code is found in ClaimView
async confirmClaim(give: GiveSummaryRecord) {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
give.fullClaim,
give.jwtId,
give.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
}
}
</script>

View File

@@ -8,8 +8,44 @@
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"
@@ -22,9 +58,11 @@
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</div>
-->
<!-- New Project -->
<button
v-if="showProjects"
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
@@ -39,8 +77,108 @@
<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">
<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 w-12">
<ProjectIcon
:entityId="offer.fulfillsPlanHandleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></ProjectIcon>
</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">
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
@@ -52,11 +190,11 @@
class="block py-4 flex gap-4"
>
<div class="flex-none w-12">
<EntityIcon
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></EntityIcon>
></ProjectIcon>
</div>
<div class="grow overflow-hidden">
@@ -74,38 +212,76 @@
<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 { 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 EntityIcon from "@/components/EntityIcon.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue";
import { ProjectData } from "@/libs/endorserServer";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
components: { InfiniteScroll, QuickNav, EntityIcon, TopMessage },
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
})
export default class ProjectsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
apiServer = "";
projects: ProjectData[] = [];
current: IIdentifier;
projects: PlanData[] = [];
currentIid: IIdentifier;
isLoading = false;
numAccounts = 0;
offers: OfferSummaryRecord[] = [];
showOffers = true;
showProjects = false;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
libsUtil = libsUtil;
/**
* '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: string = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) {
console.error("No accounts found.");
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You need an identifier to load your projects.",
},
-1,
);
} else {
this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers();
}
} catch (err) {
console.error("Error initializing:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
}
}
/**
@@ -113,7 +289,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}`,
@@ -123,13 +299,17 @@ export default class ProjectsView extends Vue {
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 plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid });
}
} else {
console.log("Bad server response & data:", resp.status, resp.data);
console.error(
"Bad server response & data for plans:",
resp.status,
resp.data,
);
this.$notify(
{
group: "alert",
@@ -142,7 +322,7 @@ export default class ProjectsView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading projects:", error.message || error);
console.error("Got error loading plans:", error.message || error);
this.$notify(
{
group: "alert",
@@ -161,15 +341,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
@@ -182,72 +391,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
**/
@@ -258,5 +401,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 });
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,220 @@
<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() {
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);
// 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.",
},
-1,
);
}
}
// 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.",
},
-1,
);
}
}
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,
},
-1,
);
}
// 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.",
},
-1,
);
}
}
}
</script>

View File

@@ -0,0 +1,377 @@
<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>
<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 = "";
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.log("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.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.",
},
-1,
);
}
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);
// 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.`,
},
-1,
);
}
// 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.",
},
-1,
);
}
}
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,
},
-1,
);
}
// 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.",
},
-1,
);
}
}
}
</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

@@ -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 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,26 @@
</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";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
interface Account {
mnemonic: string;
}
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@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 +96,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

@@ -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,28 +23,40 @@
<!-- 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>

View File

@@ -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> = {};

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.");
});

View File

@@ -1,5 +1,6 @@
/* eslint-env serviceworker */
/* global workbox */
/* eslint-disable */ /* ... because old-browser-compatible files in this directory are combined into a single script during `npm run build` */
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
);
@@ -7,7 +8,9 @@ importScripts(
function logConsoleAndDb(message, arg1, arg2) {
// in chrome://serviceworker-internals note that the arg1 and arg2 here will show as "[object Object]" in that page but will show as expandable objects in the console
console.log(`${new Date().toISOString()} ${message}`, arg1, arg2);
if (self.appendDailyLog) {
// appendDailyLog is injected at build time by the vue.config.js configureWebpack apply plugin
// eslint-disable-next-line no-undef
if (appendDailyLog) {
let fullMessage = `${new Date().toISOString()} ${message}`;
if (arg1) {
fullMessage += `\n${JSON.stringify(arg1)}`;
@@ -15,25 +18,19 @@ function logConsoleAndDb(message, arg1, arg2) {
if (arg2) {
fullMessage += `\n${JSON.stringify(arg2)}`;
}
self.appendDailyLog(fullMessage);
// appendDailyLog is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// eslint-disable-next-line no-undef
appendDailyLog(fullMessage);
} else {
// sometimes we get the error: "Uncaught TypeError: self.appendDailyLog is not a function"
// sometimes we get the error: "Uncaught TypeError: appendDailyLog is not a function"
console.log(
"Not logging to DB (often because self.appendDailyLog doesn't exist).",
"Not logging to DB (often because appendDailyLog doesn't exist).",
);
}
}
self.addEventListener("install", async (event) => {
console.log("Service worker got install event. Importing scripts...", event);
await importScripts(
"safari-notifications.js",
"nacl.js",
"noble-curves.js",
"noble-hashes.js",
);
// this should now be available
logConsoleAndDb("Service worker imported all scripts.");
self.addEventListener("install", async (/* event */) => {
logConsoleAndDb("Service worker finished installation.");
});
self.addEventListener("activate", (event) => {
@@ -84,7 +81,9 @@ self.addEventListener("push", function (event) {
} else {
title = payload.title || "Update";
}
message = await self.getNotificationCount();
// getNotificationCount is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// eslint-disable-next-line no-undef
message = await getNotificationCount();
}
if (message) {
const options = {
@@ -113,6 +112,25 @@ self.addEventListener("message", (event) => {
logConsoleAndDb("Service worker posted a message.");
});
self.addEventListener("notificationclick", (event) => {
logConsoleAndDb("Notification got clicked.", event);
event.notification.close();
// from https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event
// ... though I don't see any benefit over just "clients.openWindow"
event.waitUntil(
clients
.matchAll({
type: "window",
})
.then((clientList) => {
for (const client of clientList) {
if (client.url === "/" && "focus" in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow("/");
}),
);
});
self.addEventListener("fetch", (event) => {
logConsoleAndDb("Service worker got fetch event.", event);
});

View File

@@ -547,7 +547,11 @@ async function getNotificationCount() {
newClaims++;
}
if (newClaims > 0) {
result = `There are ${newClaims} new activities on Time Safari`;
if (newClaims === 1) {
result = "There is 1 new activity on Time Safari";
} else {
result = `There are ${newClaims} new activities on Time Safari`;
}
}
const most_recent_notified = claims[0]["id"];
await setMostRecentNotified(most_recent_notified);

View File

@@ -1,47 +1,35 @@
{
"compilerOptions": {
"allowJs": true,
"resolveJsonModule": true,
"target": "esnext",
"module": "esnext",
"strict": true,
"strictPropertyInitialization": false,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": "./src",
"types": [
"webpack-env"
],
"paths": {
"@/components/*": ["components/*"],
"@/views/*": ["views/*"],
"@/db/*": ["db/*"],
"@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"],
"@/store/*": ["store/*"],
"compilerOptions": {
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers
"module": "ESNext", // Use ES modules
"strict": true, // Enable all strict type checking options
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler
"moduleResolution": "node", // Use Node.js style module resolution
"experimentalDecorators": true,
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": "./src", // Base directory to resolve non-relative module names
"paths": {
"@/components/*": ["components/*"],
"@/views/*": ["views/*"],
"@/db/*": ["db/*"],
"@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"],
"@/store/*": ["store/*"]
},
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}

30
vite.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import * as path from 'path';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 8080
},
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: '.',
filename: 'sw_scripts-combined.js',
manifest: {
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
buffer: path.resolve(__dirname, 'node_modules', 'buffer'),
'dexie-export-import/dist/import': 'dexie-export-import/dist/import/index.js',
},
},
});

View File

@@ -1,23 +0,0 @@
const { defineConfig } = require("@vue/cli-service");
const { gitDescribeSync } = require("git-describe");
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash;
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
devtool: "source-map",
experiments: {
topLevelAwait: true,
},
},
pwa: {
iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg",
},
workboxPluginMode: "InjectManifest",
workboxOptions: {
swSrc: "./sw_scripts/additional-scripts.js",
},
},
});