Compare commits

...

482 Commits

Author SHA1 Message Date
Trent Larson fd50fd0034 fix problem not showing user's projects on project page 3 days ago
Trent Larson ab84d90ba6 fix linting problem (NOW we'll deploy 0.3.33) 3 days ago
Trent Larson f3761c98fb bump all files to 0.3.33 3 days ago
Trent Larson e0771a642b bump version to 0.3.33 3 days ago
Trent Larson 2d900b315a fix problem with "Affirm Delivery" on offer claim page, plus other look-and-feel tweaks 3 days ago
Trent Larson 9a3fa38a3f bump version to 0.3.32 5 days ago
Trent Larson d260defb05 add pages to see all the offers to user and offers to user's projects 5 days ago
Trent Larson 64bc47e13e add "+" to numbers if hit limit (>50), fix linting 5 days ago
Trent Larson f01ddce1f6 add test for user-project offers on front page 5 days ago
Trent Larson 94d64eb93a add new projects to front page 6 days ago
Trent Larson bbad1a6650 add tests for new activity of offers-directly-to-user 7 days ago
Trent Larson 4874cf9675 add better verbiage when an offer has both description and amount 7 days ago
Trent Larson 9327d7ee03 mark new-activity offers as seen, and mark them unseen again 7 days ago
Trent Larson 93ed4657ad fix tests (from project-page switch 4 commits ago) and fix linting 7 days ago
Trent Larson 3c790dceb7 add large notice when user has a new offer to them 7 days ago
Trent Larson 63bd38766a various look-and-feel improvements 1 week ago
Trent Larson 07c02ab98a still 0.3.31, fix linting 2 weeks ago
Trent Larson e00d22017a bump to version 0.3.31, tweak messaging to include offers 2 weeks ago
Trent Larson edafa21092 tweak onboarding messages 2 weeks ago
Trent Larson 51149ebb04 suggest new user going to the front page 2 weeks ago
Trent Larson b0bbc39af8 adjust tests for new onboarding messages 3 weeks ago
Trent Larson 1bc58ae928 add basic page-by-page onboarding help 3 weeks ago
Trent Larson b786f0d76c fix bad link to project page, fix improper action on invite-add-contact cancel 4 weeks ago
Trent Larson f036a0df91 enhance help & help onboarding 1 month ago
Trent Larson babd3832bd bump to version 0.3.29 1 month ago
Trent Larson b9115357c3 show more redeemed info & action on the invites, refactor onboarding instructions 1 month ago
Trent Larson f4dbebb8de improve messages on invite page 1 month ago
Trent Larson fd6a43d3b5 add an invite-delete function 1 month ago
Trent Larson d82e0db951 refactor invite link & add test 1 month ago
Trent Larson c286012674 fix linting 1 month ago
Trent Larson 3b5983fa7c finish the loading of an invite RegisterAction when clicking on a link 1 month ago
Trent Larson cd9f097180 add page for one-on-one invites (incomplete) 1 month ago
Trent Larson ebfcfa0a2d update nostr message to include signature for public key 1 month ago
Trent Larson 84720b9404 bump to version 3.0.28 1 month ago
Trent Larson 31a05f3c42 fix verbiage for recipient on home page 1 month ago
Trent Larson 7febdc276d change give provider to a single value 1 month ago
Trent Larson 625acb66f0 allow details on a give for a providing project (so we can attach a picture) 1 month ago
Trent Larson 36e5d0a6f5 switch BVC-meeting-end gift to be from the plan, and add display of providers on claim-view page 1 month ago
Trent Larson 2d316b67f9 add link directly into contact page to add a new contact via "contactJwt" query parameter 1 month ago
Trent Larson 15a779bfd9 fix another vulnerability 1 month ago
Trent Larson 9efb35b07c fix some vulnerabilities 1 month ago
Trent Larson c9b92857a9 bump version and add "-beta" 2 months ago
Trent Larson d0c3b97946 Merge branch 'nostr' 2 months ago
Trent Larson cf797c7702 disable checkboxes for nostr partner messages; adjust linting warnings 2 months ago
Trent Larson 011870e85f support TripHopping on nostr as well 2 months ago
Trent Larson d8456788cb send all info needed to create a Trustroots event 2 months ago
Trent Larson ee23e6f005 bump to version 0.3.27 2 months ago
Trent Larson f2ba702355 update caniuse-lite 2 months ago
Trent Larson 270c9067fc add more specific check to avoid complaint about multiple matches 2 months ago
Trent Larson f2446b0b83 add nostr Trustroots partner as an option when submitting a project 2 months ago
Trent Larson 7e6ef1470c only show the "raw edit" when advanced options are turned on 2 months ago
Trent Larson 3d5a0fc0b4 fix problem where mounted ran before create and didn't load any claims 2 months ago
Trent Larson 866a1d740c allow bulk-imported contacts to have visibility set 2 months ago
Trent Larson 8263ed2b29 bump to version 0.3.26 2 months ago
Trent Larson ecc455939b fix error is OfferDialog where assignment to a project was missed, plus some refactors 2 months ago
Trent Larson 9b86af8508 fix alert when looking at one's own activity 2 months ago
Trent Larson 023c8f6321 modify the settings to allow account-specific settings, eg. for "isRegistered" 2 months ago
Trent Larson e021808aa2 include some DID info on the contact list page 2 months ago
Trent Larson f5e8ccaeb3 bump version and add "-beta" 2 months ago
Trent Larson dcbe02d877 bump version to 0.3.25 2 months ago
Trent Larson 353726596d remove the last of the localStorage for passing parameters 2 months ago
Trent Larson e62b52095c bump version to 0.3.24 2 months ago
Trent Larson fee8a78bdd fix so "not named" shows on detail screen for anonymous 2 months ago
Trent Larson 174c37169f jump from ideas directly into giving dialog choice 2 months ago
Trent Larson f5cbeafd5b add message when no projects are found in a search, and bump to version 0.3.23 2 months ago
Trent Larson 0e7da5a9a4 fix test BVC setting, remove stray console.outs 2 months ago
Trent Larson 7704d0bd18 bump to version 0.3.22 2 months ago
Jose Olarte III 891d71814c Variable website, date and time 2 months ago
Jose Olarte III 03a3964cbd Merge branch 'master' into test-playwright 2 months ago
Trent Larson 46ebd95394 add wording in help page 2 months ago
Trent Larson acf04792be change tests assuming result will be at top 2 months ago
Trent Larson 06774469be add blurbs for different audiences in Help, and allow a link for direct search on project discovery page 2 months ago
Jose Olarte III 3262f0ee64 Improved create project test 2 months ago
Jose Olarte III 69473a826f Removed test.slow() 2 months ago
Jose Olarte III 51eb45353e Moved common functions to testUtils 2 months ago
Jose Olarte III b1875b5812 Remove unneeded timeouts 2 months ago
Jose Olarte III bd27d8a10f Merge branch 'master' into test-playwright 2 months ago
Jose Olarte III c076ce5b44 Remove PWA test 2 months ago
Trent Larson a7b89f4bb6 bump to version 0.3.21 3 months ago
Trent Larson e9c5bd8e99 after copying personal data, add a message to copy contacts for them 3 months ago
Trent Larson 5d47eab6d8 add test for new name-entry & copy-to-clipboard flow 3 months ago
Trent Larson e24cd06e3d make the user-name pop-up the preferred way to set the name 3 months ago
Trent Larson 767d33c0a0 prompt for name when showing info, and provide a "copy" page when remote 3 months ago
Trent Larson 22c3d28405 move some buttons to take less space at the top of Home 3 months ago
Trent Larson 7a25892472 add test for registration of new user 3 months ago
Jose Olarte III 915d51dc2f In-progress: PWA install test 3 months ago
Jose Olarte III b1d61251dc Merge branch 'master' into test-playwright 3 months ago
Trent Larson 8d0cc8e0d1 fix linting 3 months ago
Trent Larson c5c687a9c5 add tests for importing multiple records, fix other confirmation tests 3 months ago
Jose Olarte III a9aeeeb51e Playwright: Record 10 gives 3 months ago
Jose Olarte III d679d0c804 Merge branch 'master' into test-playwright 3 months ago
Trent Larson 7cba232e44 fix tests 3 months ago
Trent Larson c95b2178ef copy a list of contacts and then import 3 months ago
Trent Larson 511be5f9a2 move contact actions into the details page (prepping for checkboxes) 3 months ago
Trent Larson 8b4f46d07b bump verison and add "-beta" 3 months ago
Trent Larson 4064eb75a9 bump to version 0.3.20 3 months ago
Trent Larson d96aa01107 update bad verbiage on offer page, fix offer test 3 months ago
Trent Larson 6e89271616 bump verison and add "-beta" 3 months ago
Trent Larson ee9c14942c bump to version 0.3.19 3 months ago
Trent Larson a8bb1b46c2 fix error editing an offer, tweak tests to fix red in IntelliJ 3 months ago
Trent Larson a8b82037b9 bump to version 0.3.18 3 months ago
trentlarson 5811dacb84 Merge pull request 'offer editing' (#123) from offer-edit into master 3 months ago
Trent Larson 1a4052d1a0 fix tests, add test for offer update 3 months ago
Trent Larson a9b12f4d7c allow editing of an offer 3 months ago
Trent Larson 269d00a096 start with offer-edit 3 months ago
Trent Larson 05f898d462 put BTC before BX in unit rotation 3 months ago
Trent Larson 2c2c95a824 fix destination page after photo is shared 3 months ago
Trent Larson 4244e6b279 add recipient description to offers in user's list 3 months ago
Trent Larson 56e3440875 misc commentary 3 months ago
Trent Larson 1fe540d5a8 fix list of offers (and some other lists), and add tests for offers 3 months ago
Trent Larson 089d4f0733 change back the check for adding a service worker because tests would get constant errors 3 months ago
Trent Larson da79d581b7 bump version and add "-beta" 3 months ago
Trent Larson cefa384ff1 bump to version 0.3.17 3 months ago
Jose Olarte III c4a8026276 DONE: create 10 projects 3 months ago
Jose Olarte III 10fad9c167 Merge branch 'master' into test-playwright 3 months ago
Trent Larson 5849ae2de4 remove "export" that's not available in raw JS 3 months ago
Trent Larson 60ed21c0d9 fix image shared with web share 3 months ago
Trent Larson 3f77f9b3ff record some info on my attempt to test a service worker 3 months ago
Trent Larson 6728cbe93e bump version and add "-beta" 3 months ago
Trent Larson cfb8b7841e bump to version 0.3.16 3 months ago
Trent Larson 01a8814b4e fix linting, and give instructions for current test suite 3 months ago
Trent Larson 70a1ea362f fix a test, add potential-failing comment 3 months ago
Jose Olarte III 084fb09971 IN-PROGRESS: create 10 projects 3 months ago
Trent Larson fb0219d1d7 add image on entries in a project 3 months ago
Trent Larson 024fc6be06 show image on the view-claim screen 3 months ago
Trent Larson 041cc30eb8 refactor confirmation section to show together and more cleanly 3 months ago
Trent Larson b8181f6ae3 fix error sharing image and failing to upload, fix upload in webkit/safari, and test it 3 months ago
Jose Olarte III bcc0fac0a8 Playwright: added ID to spinbutton 3 months ago
Trent Larson f724476ed6 tweak instructions for minimal test data 3 months ago
Jose Olarte III 6ca5bf754b (Switch back to test server) 3 months ago
Jose Olarte III 25eaff62d8 Playwright: expended contact test 3 months ago
Jose Olarte III dd1532e2f4 Playwright: test against created records 3 months ago
Jose Olarte III 7033d259e1 Playwright: added import 3 months ago
Jose Olarte III aae2e62177 Playwright: removed redundant tests 3 months ago
Jose Olarte III 9a9c2b1813 Playwright: combined no-ID tests 3 months ago
Jose Olarte III ee75576cda Playwright: implemented importUser 3 months ago
Jose Olarte III 88efa36542 Playwright: importUser function 3 months ago
Trent Larson fe1cd32be1 bump version and add "-beta" 3 months ago
Trent Larson c8f0f2c2b1 bump to version 0.3.15, fix a README instruction 3 months ago
Trent Larson 7aaf981b71 remove unused ethr-did-resolver (since it has vulerabilities and we're not using it and we can use the local one) 3 months ago
Trent Larson ca8da9fd5e add 'isRegistered' check to guard against many buttons 3 months ago
Trent Larson ff3d397150 move pointers to other projects up in the project view 3 months ago
Trent Larson 052d5c5bd1 add a test for empty ID, fix some linting 3 months ago
Trent Larson 9213ad1f4a remove unused code 3 months ago
Trent Larson 98f4665465 comment out a breaking test on local data & enhance those instructions 3 months ago
Jose Olarte III c5f5e81f2c Playwright: check ID generation 3 months ago
Jose Olarte III 26f6eb66fe Playwright: additional checks to add contact 3 months ago
Jose Olarte III 28dd602d9f ID-specific locators 3 months ago
Jose Olarte III fb0a64c0ab Mirrored browser selection 3 months ago
Jose Olarte III 88f41b885c Added IDs for Playwright targeting 3 months ago
Trent Larson bbf621fb18 make instructions for an Endorser server started from scratch 3 months ago
Jose Olarte III a0adc1517c Playwright: check usage limits (no-ID and with-ID) 3 months ago
Jose Olarte III 812c8a418e Playwright: confirm contact appears on home feed 3 months ago
Jose Olarte III 2f0326f182 Corrected some test labels 3 months ago
Trent Larson 1fbd1da87d add visibility flag set, refactor to see results, and add copy icons for contact info 3 months ago
Trent Larson d96770a351 move copy icon for DIDs on contact screen 3 months ago
Trent Larson 8d684f1b29 tweak verbiage and make other UI tweaks 3 months ago
Trent Larson 6272b3045b fix where it doesn't remove the plan when editing and removing it 3 months ago
Trent Larson 375d6ddbe2 fix problem detecting plans when editing gifts 3 months ago
Trent Larson f497c53294 hide the details of a claim by default 4 months ago
Trent Larson cb8aeeac1b show full contact details, plus other tweaks 4 months ago
Trent Larson 6191a4893f add a config for local testing, plus add mobile testing and some instructions 4 months ago
Trent Larson 791a35d97c fix one linting error 4 months ago
Trent Larson 5647c4627f import & update selected contacts 4 months ago
Jose Olarte III 7dfc377610 Playwright: check no-ID messaging 4 months ago
Jose Olarte III 11a3e981a6 Playwright: check test API 4 months ago
Trent Larson cd04f35224 remove example test file 4 months ago
Jose Olarte III 59f97ffc28 Optimize tests 4 months ago
Jose Olarte III e20cd2aac1 Merge branch 'master' into test-playwright 4 months ago
Trent Larson 3f2f334424 add help text, both in general and for download 4 months ago
Trent Larson 3b05ae7d9d enhance seed-backup with clipboard copy & more info 4 months ago
Jose Olarte III 1973ca1977 New test 4 months ago
Trent Larson 81c96a5cd1 make the list of all claims show a link to each specific claim 4 months ago
Trent Larson c0df24fe01 add more type casts 4 months ago
Trent Larson c16c1689a3 add ability to edit a GiveAction 4 months ago
Jose Olarte III 262e5cc30f More tests added 4 months ago
Trent Larson 6ae2329317 refactor out unused DB reference 4 months ago
Trent Larson f362f19cbb await all of the db.settings updates 4 months ago
Jose Olarte III c037f95b5e Filename-based sequence 4 months ago
Jose Olarte III de88626239 Switched to baseURL 4 months ago
Jose Olarte III 3f2163c30a Simplify 4 months ago
Jose Olarte III 538345c07b Check activity feed 4 months ago
Jose Olarte III df07607b47 Cleanup 4 months ago
Trent Larson e51a7b84a4 fix linting 4 months ago
Trent Larson bfa30d691b Merge branch 'passkey-cache' 4 months ago
Trent Larson 403327c25a create an identifier by default, while letting them choose if passkeys are enabled 4 months ago
Kent Bull 9361f68888 Merge pull request 'docs: add tlmgr font packages' (#122) from kentbull/crowd-funder-for-time-pwa:kent/docs-update-tlmgr-packages into master 4 months ago
Kent Bull f1f98417cd docs: add tlmgr font packages 4 months ago
Trent Larson fcef84bc82 rename "docs" directory to "doc" 4 months ago
Trent Larson 285f0a5e0e add instructions to run tests, and fix linting (for WebStorm) 4 months ago
Trent Larson bc59cd5c41 cache the passkey JWANT access token for multiple signatures 4 months ago
Kent Bull 1172aad318 Merge pull request 'docs: basic pandoc setup' (#118) from kentbull/crowd-funder-for-time-pwa:kb/add-usage-guide into master 4 months ago
Jose Olarte III 81e9da5eb0 Create Project automation + test 4 months ago
Jose Olarte III 38ce988b9e Cleanup 4 months ago
Jose Olarte III 2fe865d4ad Updated test directory 4 months ago
Jose Olarte III c3cf382dbf Tesdt: validate copy contact info to clipboard 4 months ago
Jose Olarte III 4c8846d65a Test: new ID from seed phrase 4 months ago
Jose Olarte III 9536fc9b5e Playwright install 4 months ago
Trent Larson 9b65fb7ef9 remove remaining getIdentity calls & fix QR code for did:peer 4 months ago
Trent Larson f74b399871 reword some things in help 4 months ago
Trent Larson 05398b4de7 add BTC donation address 4 months ago
trentlarson 2aedf6c185 move low-level DID-related create & decode into separate folder (#120) 4 months ago
trentlarson bc00eac143 Merge pull request 'Refactor JWT-creation calls through single function' (#119) from passkey-all into master 4 months ago
Trent Larson 925f3e90bb change first page back to prompts without passkey 4 months ago
Trent Larson bc1846a95a consolidate getIdentity & remove dups 4 months ago
Trent Larson 674ca1d63c replace remaining didJwt.createJwt calls with one that checks for did:peer 4 months ago
Trent Larson f184fe4d51 linting cleanup 4 months ago
Trent Larson c67ceebc67 change accessToken to take a DID 4 months ago
Trent Larson c200cdbead add expiration inside JWANT & refactor getHeaders to move toward supporting did:peer 4 months ago
Trent Larson 2dd6e9b07a make a passkey-generator in start & home pages, and make that the default 4 months ago
Trent Larson 33d6b9df96 misc tweaks and linting clean-up 4 months ago
Trent Larson 63d0f3c748 misc syntactic & type-checking clean-up 4 months ago
Trent Larson 54d14324a1 allow deletion of an identity 4 months ago
Trent Larson 05cc5b011d show a loading indicator on the claim-confirmation screen 4 months ago
Trent Larson a3b0993855 fill in the "Load More" links for plan linkages 4 months ago
Trent Larson 596454fc3d add section for gives provided by a plan 4 months ago
Trent Larson 5e39b91ee5 fix type of the raw claim sent 4 months ago
Trent Larson dffa007a74 add advanced page & flag for editing raw claims, and fix recipient assignment in detail screen 4 months ago
Kent Bull 2a8aa8be78 Merge branch 'master' into kb/add-usage-guide 5 months ago
Kent Bull 23cc923144 docs: finish initial boostrapping dev guide 5 months ago
Kent Bull 38ec7320bb docs: add more docs on local run 5 months ago
Kent Bull 316e4be25a docs: basic pandoc setup 5 months ago
Trent Larson 1c0e0aeeba modify & explain icons next to feed 5 months ago
Trent Larson 1147ee4707 refactor display logic a bit (no flow changes intended) 5 months ago
trentlarson e68d4fbe6d passkey test (#116) 5 months ago
Trent Larson c3a1571c2f move & resize the contact edit & info buttons 5 months ago
Trent Larson fafdccae66 bump version and add "-beta" 5 months ago
Trent Larson 1611d22892 bump to v 0.3.14 5 months ago
Trent Larson 4c6c85983c fix checkbox verbiage when no project is chosen for a give 5 months ago
Trent Larson 978a31a34e fix prompt for already-registered contacts (plus some verbiage) 5 months ago
Trent Larson c7c6b7c071 add BX currency, add link for user's activity, tweak verbiage 5 months ago
Trent Larson daf692537c improve messaging when user has no offers or projects 5 months ago
Trent Larson 6bc7dfd76d fix justification of checkboxes and text so they don't move 5 months ago
Trent Larson b87142d3ed give-detail page: add more-correct parameters from confirm-give page, and allow toggling of project & user-recipient 5 months ago
Trent Larson 15256bf698 tweak UI for give-confirmation screen 5 months ago
Trent Larson 886e22ba88 add Confirm Gift screen for simpler confirmation 5 months ago
Trent Larson a85aac9630 fix dependency vulnerabilities 6 months ago
Trent Larson 766727c799 bump version and add -beta; enhance help 6 months ago
Trent Larson 08b67984e4 bump to verson 0.3.13 6 months ago
Trent Larson 50bba70e1f allow link to the large version of a project image 6 months ago
Trent Larson 64f5656f41 add an image to projects (which shows on all ProjectIcons except for offers) 6 months ago
Trent Larson e3e2031bd8 bump version and add -beta 6 months ago
Trent Larson 3cd164b09d update CHANGELOG 6 months ago
Trent Larson 141fb39ad1 bump to version 0.3.12 6 months ago
Trent Larson 11b662e326 fix the photo share_target, and tweak other verbiage 6 months ago
Trent Larson 2de254f9a1 bump version and add -beta 6 months ago
Trent Larson 1bf57d3228 add a global error handler 6 months ago
Trent Larson 567bcad88d bump to version 0.3.11 (and enhance warning on profile deletion) 6 months ago
Trent Larson 9bdb87e9ef set the correct active camera number when it starts 6 months ago
Trent Larson e7e1176a83 bump version and add -beta 6 months ago
Trent Larson 7b6afe25c5 allow any image URL for gifts & profiles 6 months ago
Trent Larson a8ef530d58 allow file choice for gift, plus other UI fixes 6 months ago
Trent Larson ee3d4acb58 fix cropping problem where long images go off the screen 6 months ago
Trent Larson 36d2e41fea bump to v 0.3.10, fix image upload on Chrome 6 months ago
trentlarson 03ac31d981 Merge pull request 'add a share_target for people to add a photo' (#115) from share-photo into master 6 months ago
Trent Larson b81c096fe4 add file-chooser to the profile image selection 6 months ago
Trent Larson 6bcc0023cd style the sharing screen (plus other fixes) 6 months ago
Trent Larson aa7d82c531 add a share_target for people to add a photo 6 months ago
Trent Larson a95c398e81 increment version and add "-beta" 7 months ago
Trent Larson 874e717e69 bump to version 0.3.9 7 months ago
Trent Larson c107073592 disallow new-project page if not registered 7 months ago
Trent Larson 3ea5c42769 remove verbiage on front page that's now extra 7 months ago
Trent Larson 9157837586 show something to indicate claims were sent (mostly in BVC screens) 7 months ago
Trent Larson 751c066bd0 constantly recheck on home screen if not registered 7 months ago
Trent Larson c403356055 add registration inside contact import, with flag to hide it 7 months ago
Trent Larson 009a7ecdf8 add 'registered' flag in contact info 7 months ago
Trent Larson bd148e88a3 for scan on QR code screen, import and keep on that screen 7 months ago
Trent Larson bca5adecc9 add tweaks to testing instructions 7 months ago
Trent Larson 73f9d7f9e9 add page to view all claims about a DID (which we'll have to restrict to visible people soon) 7 months ago
Trent Larson 6f4876e32b fix problem with duplicates in feed, plus some other UI tweaks 7 months ago
Trent Larson c1fe8216f6 allow loading more gives & offers & plans when limits are hit on project view 7 months ago
Trent Larson 56523da11e remove some 'uppercase' CSS markers 7 months ago
Trent Larson 35ec7fd43c put button directly on contacts page to show the given totals 7 months ago
Trent Larson 94b5389ce9 change remainder of "confirm" calls to better UX 7 months ago
Trent Larson 421b4c1719 replace many of the javascript "confirm" calls with the nicer UX version 7 months ago
Trent Larson 6ce3a0703c remove 'moment' library that's no longer used 7 months ago
Trent Larson 79b14355d9 add choice of a start date for a project 7 months ago
Trent Larson c5102f89b5 add note about confirming your own, plus other helpful verbiage, plus notify messages that don't linger 7 months ago
Trent Larson ab6d2e3d4b remove message confusion, add project name during give-details 7 months ago
Trent Larson d1a285d659 change the "give" action on contact page to use dialog box 7 months ago
Trent Larson bba183dc46 add 'offer' on contact screen 7 months ago
Trent Larson 676882978a add code to display profiles in feed, but deactivate it for now 7 months ago
Trent Larson dc9720560e increment version and add "-beta" 7 months ago
Trent Larson 15c026c80c bump to v 0.3.8 7 months ago
Trent Larson 03f722f38a make so cropping isn't behind header; delete profile image from storage when deleted 7 months ago
trentlarson f14c3de0ef Merge pull request 'profile-pic' (#114) from profile-pic into master 7 months ago
Trent Larson 606f21faec make the home screen elements load more quickly 7 months ago
Trent Larson 5a9958cb4f show contact's or user's icon in more places 7 months ago
Trent Larson b11cf81bf9 crop the image and store online and in settings 7 months ago
Trent Larson 734e28667d add photo to profile page (not yet saved) 7 months ago
Trent Larson 405bc22dae fix contact sorting to show those without names 7 months ago
Trent Larson 1758cbee98 update ClickUp link to a public link 7 months ago
Trent Larson 5359b241f7 remove tasks here in favor of ClickUp 7 months ago
Trent Larson 555ac34d18 note that tasks have moved 7 months ago
Trent Larson acbbdf0e8b bump version and add "-beta" 7 months ago
Trent Larson cf18f1543a bump to v 0.3.7 7 months ago
Trent Larson df829778da open the app when notification is clicked 7 months ago
Trent Larson 7ae431a9e7 fix PWA creation & service-worker registration, plus some commentary tweaks 7 months ago
Trent Larson 77becf8673 remove non-working interests, enhance error messages, update tasks & changelog 7 months ago
trentlarson a0ef8b6fd3 Merge pull request 'vitejs refactor' (#110) from jsnbuchanan/crowd-funder-for-time-pwa:feat/vitejs into master 7 months ago
jsnbuchanan 1befff0abd Merge pull request 'misc tweaks for new vite build' (#4) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent3 into feat/vitejs 7 months ago
Trent Larson 0b446ec134 misc tweaks for new vite build 7 months ago
jsnbuchanan 333ac773f6 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 7 months ago
Trent Larson 1459719a47 remove a lingering debug console.log 7 months ago
Trent Larson 5994365a6c fix title of the test app 7 months ago
Trent Larson 28f72640d7 add linting before any build 7 months ago
Trent Larson ab81648aca fix linting 7 months ago
Trent Larson 5828a290c7 Merge remote-tracking branch 'original-origin/master' into feat/vitejs-trent 7 months ago
Trent Larson 78fab735e6 avoid a huge error message in a likely-well-known scenario 7 months ago
Trent Larson 2ae165d56f reorder home page vapid check to avoid an error on localhost 7 months ago
Trent Larson 0fbd1ad51a add missing Dexie import (which causes failure upon download click) 7 months ago
Trent Larson d49bf61524 on home page, change the filtered button color 7 months ago
trentlarson 1a80bbb714 Merge pull request 'ui-additions-2024-03' (#113) from ui-additions-2024-03 into master 7 months ago
Trent Larson 5a2a8659f7 Merge branch 'master' into ui-additions-2024-03 7 months ago
Trent Larson 0632fb9b39 show in description when recipient is a project (not just Anonymous) 7 months ago
Trent Larson 640d273646 filter by selections (now all working), add cache for plans 7 months ago
trentlarson 8f4289c14d Merge pull request 'send a time for notifications to the push server' (#112) from notify-time into master 7 months ago
trentlarson 7f56c90d97 Merge pull request 'ui-fixes-2024-03' (#111) from ui-fixes-2024-03 into master 7 months ago
Trent Larson 8e1daf7015 feed filter: save the changed values to the DB, go to map if no location chosen, reload if necessary 7 months ago
Jose Olarte III e90a0be6d9 Names and variables for filter toggles 7 months ago
Trent Larson 489bb76a60 adjust more code to the PushSubscriptionJSON 7 months ago
Trent Larson 2ca33bb9eb adjust the notification-subscription objects to try and send correct info 7 months ago
Trent Larson 121181c6a1 add adjustment to UTC hour for notification time 7 months ago
Trent Larson e4cf79b558 update tasks 7 months ago
Trent Larson 7692cc2b35 add logic to send a time for notifications 7 months ago
Jose Olarte III 0b4f2484f7 Additions to Account View 8 months ago
Jose Olarte III cfd53bc186 Removed one more 8 months ago
Jose Olarte III 7f66addfe3 Filter options reduced for release 8 months ago
Jose Olarte III 4635c1ac48 Feed filters dialog 8 months ago
Jose Olarte III 55da3d0b1c Map fix #2 8 months ago
Jose Olarte III dce4d3cc72 Button width changes 8 months ago
Jose Olarte III e028197a2a Optimized grid space for wider screens 8 months ago
Jose Olarte III 0dab475d8b Fixed map z-index 8 months ago
Jose Olarte III 4e227fc07a Added close icon to gifted prompts dialog 8 months ago
Trent Larson 2dfc8fedaa refactor tasks 8 months ago
Jason Buchanan 035f2a5b04
docs: adding do for updated development server run command 8 months ago
Jason Buchanan 09dccc34d6
fix: buffer typescript error in util.ts when parsing ArrayBuffer 8 months ago
Trent Larson b28104af5b bump version and add -beta 8 months ago
Trent Larson 3a07e31d63 bump to version 0.3.6 8 months ago
Trent Larson 35455e6648 fix check for more camera-device options 8 months ago
Trent Larson 0e2c5af16e add onboarding help instructions as separate page 8 months ago
trentlarson 1fc5b0ea2b Merge pull request 'add button during photo to switch to mirror mode' (#109) from photo-reverse into master 8 months ago
Jason Buchanan ca240ab795
fix: es modules syntax for buffer deps instead of commonjs require 8 months ago
Jason Buchanan 01b5ca6ec8
chore: update vitejs config to deploy on the same default port as the @vue/cli-service 8 months ago
Jason Buchanan 6f49260c1e
fix: AccountViewView.vue template not resolving dep for dexie-export-import/dist/import 8 months ago
Jason Buchanan 38f44771e9
Initial stab at vitejs update 8 months ago
Trent Larson be2154f847 change icon for detail view (from circle-info to file-lines) 8 months ago
Trent Larson 4ad4cab25e add blurb explaining what data is shared with the world 8 months ago
Trent Larson e020caaa50 show warnings before dismissing prompt, and add to tasks and help 8 months ago
Trent Larson 40d12b1f9c add button on photo to switch to mirror mode 8 months ago
Trent Larson 28754bdfb1 bump version to 0.3.5 8 months ago
Trent Larson 2b8f9579f1 fix so that project agent & location removals get saved 8 months ago
Trent Larson 6dc0c2cd58 add a camera-switch button 8 months ago
trentlarson cc6d0958dc 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 8 months ago
Jason Buchanan 7ce00b86e8
deps: npm audit fix to resolve vulnerabilities 1 low, 3 moderate, 1 high 8 months ago
Trent Larson 5e771e4a24 refactor tasks 8 months ago
Trent Larson 4dd2c044d5 bump to v 0.3.4 8 months ago
trentlarson 3bfd54362e Merge pull request 'Sample button visual enhancement' (#104) from button-visual-enhancement into master 8 months ago
Jose Olarte III b6e344a15e Propagated button improvements across views 8 months ago
Jose Olarte III 3d1c46aef8 Merge branch 'master' into button-visual-enhancement 8 months ago
trentlarson ce05f7d003 Merge pull request 'tweak imagery so that it doesn't get stretched on a mobile device' (#107) from photo-ratio into master 8 months ago
Trent Larson 313cd79e60 finalize the photo-taking code, adding comments and removing logging 8 months ago
Trent Larson 121991b53a Merge branch 'gifted-camera-improvements' into photo-ratio 8 months ago
Jose Olarte III cbf8cb9f46 Fixed placement of upload/retry buttons 8 months ago
Jose Olarte III fe0668e4b3 Improved Camera Popup 8 months ago
Trent Larson a230506d96 change the X and picture button so that landscape is all functional (if not great-looking) 8 months ago
Trent Larson c49c55d394 change the photo ratios to fix all but portrait-orientation on mobile-emulation 8 months ago
Trent Larson ae572afff6 add help for when service workers get stuck; bump to version 0.3.2 8 months ago
Trent Larson ccea2486e4 change build for test servers, bump version to 0.3.1 8 months ago
Trent Larson 155343a9d7 bump to v 0.3.0 8 months ago
trentlarson 85ad295eb9 Merge pull request 'photo-upload' (#105) from photo-upload into master 8 months ago
Trent Larson 64322b2804 change the default image server port 8 months ago
Trent Larson 3e556dfa52 move the "part of project" text in giving-details screen 8 months ago
Trent Larson 252952e017 show the image rate limits 8 months ago
Trent Larson 251986d2bc make the photo show in a pop-up dialog 8 months ago
Trent Larson 49bb1c07b7 fix file extension 8 months ago
Trent Larson 67f34f9826 on "give details" page, distinguish between project & user destination 8 months ago
Trent Larson 476d35452a send the claim type with an image 8 months ago
Trent Larson 26582030df add another check when deleting an image 8 months ago
Trent Larson ae857f4c8f guard against another set of errors when deleting an image 8 months ago
Trent Larson c602c5ce50 add some other image deletions in other cases 8 months ago
Trent Larson e4543457e2 add image onto give claim, then display on feel (full round-trip, baby!) 8 months ago
Trent Larson c58f012d2c allow viewing and deletion of an image 8 months ago
Trent Larson 792e9cb648 separate picture taking from uploading 8 months ago
Trent Larson acee761906 add page for extended details of gifts including pic (not fully tested) 8 months ago
Trent Larson cae2bbc4ff make styled button to take picture 8 months ago
Jose Olarte III a5c3600673 Sample button visual enhancement 8 months ago
Trent Larson 0eb64ed716 add authentication token for image server, change default image server to localhost 8 months ago
Trent Larson f1bb1b51aa Merge branch 'master' into photo-upload 8 months ago
Trent Larson 92b924643e fix camera resolution, parameterize image API server 8 months ago
Trent Larson ca90447700 fix different "environment" variables for prod & dev 8 months ago
Trent Larson 750700e75e bump version and add '-beta' 8 months ago
Trent Larson 3612ea4224 bump to v 0.2.17; add "personalized" message and better confirmation-result messages 8 months ago
Trent Larson dbccbf7e4a fix: show on the confirmation page when there are hidden claims 8 months ago
Trent Larson 1258cf02a1 bump to v 0.2.15 8 months ago
trentlarson a488a36bc0 Merge pull request 'Shortcut page for BVC assertions & confirmations' (#103) from bvc-shortcut into master 8 months ago
Trent Larson a93b556e0c doc: refactor tasks 9 months ago
Trent Larson 2c28913d97 for BVC: finish submission of confirmations & final give 9 months ago
Trent Larson 0b24d7bbd8 for BVC: fix the attendee & show appropriate success message 9 months ago
Trent Larson 2058205150 for BVC shortcut: send attend & give actions, and list actions to confirm 9 months ago
Trent Larson 866dcb3a2a add screens for the shortcuts for the BVC group (doesn't submit yet) 9 months ago
Trent Larson 6aab1ff49d consolidate interface and remove copies of code 9 months ago
Trent Larson c696de33f3 add page to take a picture and upload to an image server 9 months ago
Trent Larson c239db6a4f doc: update tasks 9 months ago
Trent Larson 3eda5f6b5d show more succinct info in feed, targeted toward user's visibility 9 months ago
Trent Larson 783b38df65 order contacts by name & note outside network as "outside your network" 9 months ago
Trent Larson 3475c32e1f update onboarding hint message, justify text on QR page 9 months ago
Trent Larson dcd881adae make the name-setting prompt yellow 9 months ago
Trent Larson 37690cc855 increment versiona and add "-beta" 9 months ago
Trent Larson 5f9edea116 bump version to 0.2.14 9 months ago
Trent Larson f517b09ed7 combine all service-worker scripts into a single file to try and ensure included scripts aren't lost 9 months ago
Trent Larson ca70b19831 fix claim-view page when the claim argument is not a global ID 9 months ago
Trent Larson f41e541fe2 send the last JWT instead of the identifier for plan edits 9 months ago
Trent Larson 5c547783a7 remove unused page; tweak task list 9 months ago
Trent Larson 8d2dd6357a update readme 9 months ago
Trent Larson 189261e991 update messaging for contact registration 9 months ago
Trent Larson 15464602f9 bump to version 0.2.14-beta 9 months ago
Trent Larson 331c4f64d6 add check for valid "did:" DIDs 9 months ago
Trent Larson 28ae317958 refactor tasks & add more estimates 9 months ago
Trent Larson 643718619e remove unnecessary logic in account switcher; refactor task list 9 months ago
Trent Larson c3819ec919 don't autocapitalize website input; refactor tasks 9 months ago
Trent Larson 719e3a467d make a number input targeted towards numbers 9 months ago
Trent Larson b251d7e4fd change project icon to a hammer 9 months ago
Trent Larson 61c3a0e30b avoid error on browsers without a service worker 9 months ago
Trent Larson a76df55224 add display of my own offers 9 months ago
Trent Larson e140da081f fix name derivation on give dialog 9 months ago
Trent Larson 1be899c48d ensure error message shows, and unset register flag if there's an API error 9 months ago
Trent Larson 6aee93ca6c update tasks; enhance an error message & some typescripts 9 months ago
Trent Larson 5412625d05 increment version and add -beta; tweak tasks & tests 9 months ago
Trent Larson 8f579b40a9 bump to verson 0.2.12 9 months ago
Trent Larson e8a907c63a add more thankfulness prompts 9 months ago
Trent Larson f53a6f3045 tweak the prompt for contacts to be able to skip them 9 months ago
Trent Larson b38ebc45e1 add a prompt for things for which to express gratitude 9 months ago
Trent Larson c51d2629b3 bump version and add -beta 10 months ago
Trent Larson e642b99ff5 bump version to 0.2.11 10 months ago
Trent Larson 26f1e88f5a doc: update tests & tasks 10 months ago
Trent Larson 2e164dfeff tweak messages for missing identifier 10 months ago
Trent Larson d7530ff56b adjust more UI on the Advanced section, and make other small code & UI tweaks 10 months ago
Trent Larson 2db52cb72e fix default server display in advanced section & refactor UI 10 months ago
Trent Larson c8eb3bfbc0 move save & cancel buttons further apart 10 months ago
Trent Larson 71b210d541 add to manual tests & changelog 10 months ago
Trent Larson 66289ec206 update tasks 10 months ago
Trent Larson 639dc7b4e5 add instruction to error output 10 months ago
Trent Larson 4fe072f19e move DB logic out of 'created' in components since it's not needed yet 10 months ago
Trent Larson f253f0af0f add ability to import from Endorser Mobile CSV 10 months ago
Trent Larson 2d95a35905 add date to project give record list; don't wrap icon & amount 10 months ago
Trent Larson 88f869d600 lower project "I Gave" button into list of contact, and tweak other wording 10 months ago
Trent Larson a0911bb0fd add copy-paste icon next to non-anonymous, non-hidden DIDs on details page 10 months ago
Trent Larson 1053b78ab8 add sharing & copying instructions when asking contacts for help, and list all the visibleTo DIDs with an English description of their path 10 months ago
Trent Larson dcfa8d9451 add first stab at showing how the contact is visible in my network 10 months ago
Trent Larson dd38f76ee1 increment version and add -beta; add to tasks and tests 10 months ago
Trent Larson 667e1e8890 bump version to 0.2.10 10 months ago
Trent Larson 1731f2443b update offer dialog to allow other units 10 months ago
Trent Larson e1cffcda2d fix problem where extended screen of contacts didn't pass project 10 months ago
Trent Larson a5b1b97012 show the identicon in large size on the contacts screen 10 months ago
Trent Larson 563b5793a9 add different identicons for people (and increment version & add -beta) 10 months ago
Trent Larson 660436c8fa add copy-did-to-clipboard on contact list 10 months ago
Trent Larson 31a7752168 add link to project from gives on front page 10 months ago
Trent Larson 3ebe7bc156 put didInfo names in more places and add copy icons for DIDs & IDs 10 months ago
Trent Larson 0eb16d5661 add links for give & offer when they fulfill other things 10 months ago
Trent Larson edb09da10f add detailed-info button for a project 10 months ago
Trent Larson be6ec6745a show a 'give' button directly on offers in the ProjectView 10 months ago
Trent Larson b79c5fcf91 move info button for offer & add cursor for hover 10 months ago
Trent Larson 9dea4066c9 add ability to confirm give directly from a project 10 months ago
Trent Larson 9b586566f0 increment version and add "-beta" 10 months ago
Trent Larson e5e702f8a5 bump version to 0.2.9 10 months ago
Trent Larson 32c9076c39 fix visibility after adding contact, and some messaging 10 months ago
Trent Larson 6ab4c40fd0 bump to version 0.2.8 10 months ago
Trent Larson d7ef07c2e2 automatically create an identity on the first page (and other UI tweaks) 10 months ago
Trent Larson 9f595040d8 fix problem with anonymous contributor; refine tasks 10 months ago
Trent Larson 40a8794649 remove checks on old fullIri field 10 months ago
Trent Larson fa72d38d18 allow an agent to edit a project 10 months ago
Trent Larson 31aacb286f reword prompt for creating an identifier on the start screen 10 months ago
Trent Larson 2511f18fa7 enhance (& fix for mobile) styling and verbiage 10 months ago
Trent Larson febfa8b098 bump version to 0.2.7 10 months ago
Trent Larson e0fcb1f67b fix various verbiage 10 months ago
Trent Larson 9183092325 fix the name of the offerer 10 months ago
Trent Larson a87179d127 change wording from "identity" to "identifier" in many places 10 months ago
Trent Larson 14e203dd74 bump to version 0.2.6 10 months ago
Trent Larson acaaf8776d add ability to give to fulfill an offer; adjust visibility of claim actions 10 months ago
Trent Larson cb1f38c182 bump to verson 0.2.5, and edit tasks 10 months ago
Trent Larson cfa7466b94 show users when there's an error on the import page 10 months ago
  1. 3
      .env.development
  2. 6
      .env.production
  3. 20
      .eslintrc.js
  4. 27
      .github/workflows/playwright.yml
  5. 6
      .gitignore
  6. 295
      CHANGELOG.md
  7. 11
      CONTRIBUTING.md
  8. 207
      README.md
  9. 3
      babel.config.js
  10. 76
      doc/README.md
  11. BIN
      doc/images/01_infura-api-keys.png
  12. BIN
      doc/images/02-infura-key-detail.png
  13. BIN
      doc/images/03-infura-api-key-id.png
  14. BIN
      doc/images/04-pwa-chrome-devtools.png
  15. BIN
      doc/images/05-pwa-account-button.png
  16. BIN
      doc/images/06-pwa-account-page.png
  17. BIN
      doc/images/07-pwa-did-copied.png
  18. BIN
      doc/images/08-endorser-sqlite-row-added.png
  19. BIN
      doc/images/09-pwa-second-profile-first-open.png
  20. BIN
      doc/images/10-pwa-second-user-did.png
  21. BIN
      doc/images/11-pwa-first-user-add-contact.png
  22. BIN
      doc/images/12-pwa-first-user-contact-added.png
  23. BIN
      doc/images/13-pwa-first-user-register-second-user-btn.png
  24. BIN
      doc/images/14-pwa-first-user-register-yes.png
  25. BIN
      doc/images/timesafari-logo-binoculars.png
  26. BIN
      doc/images/timesafari-logo.png
  27. 316
      doc/usage-guide.md
  28. 17
      index.html
  29. 28903
      package-lock.json
  30. 130
      package.json
  31. 98
      playwright.config-local.ts
  32. 82
      playwright.config.ts
  33. 120
      project.task.yaml
  34. 17
      public/index.html
  35. 248
      src/App.vue
  36. 3
      src/assets/blank-square.svg
  37. 3
      src/assets/styles/tailwind.css
  38. 99
      src/components/ContactNameDialog.vue
  39. 45
      src/components/EntityIcon.vue
  40. 218
      src/components/FeedFilters.vue
  41. 292
      src/components/GiftedDialog.vue
  42. 260
      src/components/GiftedPrompts.vue
  43. 177
      src/components/ImageMethodDialog.vue
  44. 118
      src/components/InviteDialog.vue
  45. 167
      src/components/OfferDialog.vue
  46. 281
      src/components/OnboardingDialog.vue
  47. 439
      src/components/PhotoDialog.vue
  48. 50
      src/components/ProjectIcon.vue
  49. 10
      src/components/QuickNav.vue
  50. 33
      src/components/TopMessage.vue
  51. 95
      src/components/UserNameDialog.vue
  52. 25
      src/components/World/components/objects/landmarks.js
  53. 42
      src/constants/app.ts
  54. 108
      src/db/index.ts
  55. 1
      src/db/tables/README.md
  56. 29
      src/db/tables/accounts.ts
  57. 5
      src/db/tables/contacts.ts
  58. 40
      src/db/tables/settings.ts
  59. 14
      src/db/tables/temp.ts
  60. 93
      src/libs/crypto/index.ts
  61. 46
      src/libs/crypto/vc/did-eth-local-resolver.ts
  62. 96
      src/libs/crypto/vc/didPeer.ts
  63. 200
      src/libs/crypto/vc/index.ts
  64. 549
      src/libs/crypto/vc/passkeyDidPeer.ts
  65. 105
      src/libs/crypto/vc/passkeyHelpers.ts
  66. 11
      src/libs/crypto/vc/util.ts
  67. 1274
      src/libs/endorserServer.ts
  68. 390
      src/libs/util.ts
  69. 77
      src/main.ts
  70. 5
      src/registerServiceWorker.ts
  71. 210
      src/router/index.ts
  72. 20
      src/store/app.ts
  73. 17
      src/test/index.ts
  74. 1
      src/util.d.ts
  75. 1189
      src/views/AccountViewView.vue
  76. 97
      src/views/ClaimAddRawView.vue
  77. 726
      src/views/ClaimView.vue
  78. 6
      src/views/ConfirmContactView.vue
  79. 871
      src/views/ConfirmGiftView.vue
  80. 131
      src/views/ContactAmountsView.vue
  81. 125
      src/views/ContactGiftingView.vue
  82. 233
      src/views/ContactImportView.vue
  83. 398
      src/views/ContactQRScanShowView.vue
  84. 6
      src/views/ContactScanView.vue
  85. 1474
      src/views/ContactsView.vue
  86. 753
      src/views/DIDView.vue
  87. 173
      src/views/DiscoverView.vue
  88. 844
      src/views/GiftedDetailsView.vue
  89. 33
      src/views/HelpNotificationsView.vue
  90. 115
      src/views/HelpOnboardingView.vue
  91. 444
      src/views/HelpView.vue
  92. 867
      src/views/HomeView.vue
  93. 159
      src/views/IdentitySwitcherView.vue
  94. 86
      src/views/ImportAccountView.vue
  95. 24
      src/views/ImportDerivedAccountView.vue
  96. 392
      src/views/InviteOneView.vue
  97. 335
      src/views/NewActivityView.vue
  98. 29
      src/views/NewEditAccountView.vue
  99. 67
      src/views/NewEditCommitmentView.vue
  100. 552
      src/views/NewEditProjectView.vue

3
.env.development

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

6
.env.production

@ -0,0 +1,6 @@
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_APP_SERVER=https://timesafari.app
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
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.timesafari.app

20
.eslintrc.js

@ -2,6 +2,7 @@ module.exports = {
root: true, root: true,
env: { env: {
node: true, node: true,
es2022: true,
}, },
extends: [ extends: [
"plugin:vue/vue3-essential", "plugin:vue/vue3-essential",
@ -9,12 +10,25 @@ module.exports = {
"@vue/typescript/recommended", "@vue/typescript/recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended",
], ],
parserOptions: { // parserOptions: {
ecmaVersion: 2020, // ecmaVersion: 2020,
}, // },
rules: { rules: {
"max-len": [
"warn",
{
code: 120,
ignoreComments: true, // why does this not make it allow comment of any length?
ignorePattern: '^\\s*class="[^"]*"$',
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
},
],
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// "prettier/prettier": ["warn", { printWidth: 120 }], // removes errors but adds thousands of warnings
"@typescript-eslint/no-unnecessary-type-constraint": "off", "@typescript-eslint/no-unnecessary-type-constraint": "off",
}, },
}; };

27
.github/workflows/playwright.yml

@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

6
.gitignore

@ -2,6 +2,8 @@
node_modules node_modules
/dist /dist
signature.bin signature.bin
# generated during `npm run build`
sw_scripts-combined.js
*.pem *.pem
verified.txt verified.txt
myenv myenv
@ -25,3 +27,7 @@ pnpm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

295
CHANGELOG.md

@ -6,14 +6,302 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [0.3.33] - 2024.11.07
### Fixed
- Affirm Delivery button on offer claim page didn't work.
- Plans were not showing by default on project page.
## [0.3.32] - 2024.11.06 - 9a3fa38a3fd28f977e06f0265fc39e635c9c5ccd
### Added
- New offers to user & to user's projects
## [0.3.31] - 2024.10.25 - 07c02ab98a09d293dd90d9289a7872e7d681d296
### Changed
- Onboarding messages about offers
## [0.3.30]
### Added
- Onboarding messages
## [0.3.29] - 2024.10.09 - babd3832bdfe0c40eaa3869de1b41399a51713c1
### Added
- Invite for a contact to join immediately
### Changed
- Send signed data to nostr endpoints to verify public key ownership.
- Enhanced help & help onboarding.
### Changed in DB or environment
- Uses Endorser.ch version 4.1.1
## [0.3.28] - 2024.09.30 - 84720b94049d29cc0ddd99c50cef2e7176130133
### Added
- Posting to nostr apps Trustroots & TripHopping
- Display of providers on claim view page
### Changed
- Switched BVC-meeting-ending gift to be a gift from the group.
### Changed in DB or environment
- Requires Endorser.ch version 4.1.0
## [0.3.27] - 2024.09.22 - ee23e6f005e47f5bd6f04d804599f6395371b0e4
### Fixed
- Error loading BVC claims to confirm
- Really allow visibility of bulk-imported contacts
## [0.3.26] - 2024.09.16 - 8263ed2b29947b3ccc6f3133bbc9454c222bce28
### Added
- Separate 'isRegistered' flag for each account
### Fixed
- Failure to assign offers to their project
- Alert when looking at one's own activity if not in contacts.
## [0.3.25] - 2024.08.30 - dcbe02d877aecb4cdef2643d90e6595d246a9f82
### Added
- "Ideas" now jumps directly to giving prompt or contact list.
### Fixed
- Empty giver name on gifted-details view
- Previously visited project would show up on the giving-details page.
### Removed
- All unnecessary localStorage for project IDs
## [0.3.23] - 2024.08.30
### Added
- Sections in Help for different kinds of users
- Discovery page parameters so that links with search text work
- Message when no projects are found
## [0.3.21] - 2024.08.24 - a7b89f4bb6da928d56daeffaae7741fa74cc80bf
### Added
- Send list of contacts to someone, and move individual contact actions to detail page.
- Prompt for name in pop-up, and send to different contact-sharing screens.
### Changed
- Moved contact actions from list onto detail page
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
### Fixed
- Bad "give" verbiage on offer page
- Failing offer test
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
### Added
- Update of an offer
- Recipient description in offer list
### Fixed
- List of offers wasn't showing.
- Destination page after sharing photo was wrong.
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
### Added
- Photos on more screens
### Fixed
- Share of a photo, including sharing a photo from webkit/Safari which never worked
### Changed in DB or environment
- Nothing (though there's a new temp field in IndexedDB)
## [0.3.15] - 2024.08.04 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
### Added
- Edit gives
- Page to edit claim JSON before submitting
- Update of imported contacts
- Improve messaging on give dialog
- Section for gives provided by plan
- Deletion of an identity
- UI for choosing a passkey creation (not enabled on prod)
- Cache signatures for reports for passkey-signed requests
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
- Playwright tests
### Changed
- Linked projects display below description (instead of at bottom)
### Fixed
- Visibility toggle appearance
### Changed in DB or environment
- Nothing
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/
- Deselection of project on gifted details page
### Fixed
- Don't show registration pop-up for a new contact that is registered
### Changed in DB or environment
- Nothing
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
### Added
- Photos on projects
### Changed in DB or environment
- Nothing
## [0.2.4] - 2024.01.09 ## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
### Fixed
- Photo share (share_target) failed because requests were sent to server
### Changed in DB or environment
- Nothing
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
### Added
- Choose a file for gifts, and a URL for gifts & profiles
### Fixed
- Multiple button pushes were required to switch camera
### Changed in DB or environment
- Nothing
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
### Added
- Share an image
- Choose a file on the device for a profile image
### Changed in DB or environment
- Nothing
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
### Added
- Offers on contacts page
- Checks on front page until they show as registered
### Changed
- Scanned contacts now add immediately and prompt for registration.
- Better UI for gives on contact page
- Better UI for all confirmation messages
### Fixed
- Repeated elements at top of main feed
### Changed in DB or environment
- Nothing
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
### Added
- Profile image for user
### Fixed
- Slow loading of home page feed
### Changed in DB or environment
- Nothing
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
### Added
- Filter on home page feed
- Ability to set time of daily notification
- Jump to app on click of notification
### Changed
- Built with vite
- Descriptions on home page to include projects
### Changed in DB or environment
- Nothing
## [0.3.6] - 2024.03.24 - 3a07e31d6313ab95711265562d9023c42916e141
### Added
- Button to mirror photo during video
- More detailed onboarding help screen
- Public-data blurb
### Changed in DB or environment
- Nothing
## [0.3.5] - 2024.03.23 - 28754bdfb1e11aa221dd49a5dce4219b69cf6a9d
### Added
- Photo on gift records
### Fixed
- Environment variable for BVC meetings project
- Environment variables and build enhancements for test vs prod
### Changed in DB or environment
- New environment variable for image API server
- Test that a new browser session will get the right default APIs.
- Test that a new browser session will send the right BVC meetings project.
## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36
### Added ### Added
- Mark for a trade as opposed to a donation - 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 ### Changed
- Library security updates - Library security updates
- Visibility of actions & confirmations on claim page
### Fixed
- Name of offerer
## [0.2.2] - 2024.01.05 ## [0.2.2] - 2024.01.05
@ -31,6 +319,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Icon for Android - Icon for Android
- More thorough messaging and testing for notifications - More thorough messaging and testing for notifications
## [0.1.9] - 2024.01.01 ## [0.1.9] - 2024.01.01
### Added ### Added
- Import for contacts and settings - Import for contacts and settings

11
CONTRIBUTING.md

@ -0,0 +1,11 @@
# Contributing
Welcome! We are happy to have your help with this project.
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
Note that some previous features don't have tests and adding more will make you friends quick.
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
If you want to see a code of conduct, we're probably not the people you want to hang with.
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.

207
README.md

@ -1,67 +1,137 @@
# TimeSafari.app - Crowd-Funder for Time - PWA # TimeSafari.app - Crowd-Funder for Time - PWA
## Project setup [Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
and expand to crowd-fund with time & money, then record and see the impact of contributions.
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment. ## Roadmap
See [project.task.yaml](project.task.yaml) for current priorities.
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
``` ```
npm install npm install
``` ```
### Compiles and hot-reloads for development ### Compile and hot-reloads for development
```
npm run dev
```
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch
### Build the test & production app
``` ```
npm run serve npm run serve
``` ```
### Lints and fixes files ### Lint and fix files
``` ```
npm run lint npm run lint
``` ```
### Compiles and minifies for production ### Run all important tests
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",` ... including automated UI tests (see below for details)
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`, and commit. ```
npm run test-all
* [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. ### Compile and minify for test & 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/` * `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 ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Record what version is currently on production in docs.
* `cp sw_scripts/[ns]* dist/` * Run the correct build:
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js. * Staging
```
# (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_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
* Get on the server and back up the time-safari folder. * Production
```
# This picks up values from .env.production
npm run build
```
* Get on the server and back up the time-safari/dist folder.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari` * `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Revert src/constants/app.ts and package.json (if that was prod), edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production. * 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)
## Tests ## Tests
### Register new user on test server ### Automated
Use the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
test/test.sh
NODE_ENV=test-local npm run dev
```
* Now run the local tests:
```
npm run test-all
```
Note that a test will sometimes fail and rerunning may succeed (and repeat if a different test fails).
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: It's possible to use the global test Endorser (ledger) server (but currently the tests don't all succeed):
`npx playwright test`
* In the `src/views/AccountViewView.vue` file, uncomment the lines referring to "testServerRegisterUser".
* Visit the `/account` page.
It's possible to run with a minimal set of data; the following starts with the bare minimum of test data:
```
rm ../endorser-ch-test-local.sqlite3
NODE_ENV=test-local npm run flyway migrate
NODE_ENV=test-local npm run test test/controller0
NODE_ENV=test-local npm run dev
```
To run a single test like above with the screenshots, use the following:
```
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts
```
### Register new user on test server
On the test server, User #0 has rights to register others, so you can start
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 ### Create multiple identifiers
@ -79,50 +149,72 @@ 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. 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. - Backup seed & data & get a CSV dump from Endorser Mobile.
- See that it's using the test API. - If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
- On each page, verify the messaging. - Use a mobile user as well as a desktop user.
- On the home page, see the feed without names, and see a message prompting to generate an ID. - 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. - 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 a contact even without their own ID.
- With the new user on the home page, see the feed that shows User #0 in network but without the name. - Install the PWA.
- As the new user on the contacts page, add User #0 as a contact. - As User 0 in another browser on the test API, add a give & a project.
- On the home page, see the feed that shows User #0 with a name. - Note that some combinations of desktop with mobile emulation stretch the image.
- Generate an ID. - Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- On the home page, check that it now prompts them to get registered. - Add new user as a contact (which allows them to see User 0).
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
- As the new user, import contacts & identifiers.
- As the new user on the contacts page, add User 0 as a contact.
- On the home page, see the feed that shows User 0 with a name.
- Switch back to the generated identifier.
- On the account page, check that they see messages on limits. - On the account page, check that they see messages on limits.
- Register the ID from User #0. - As User 0, register the ID.
- As the new user on the home page, check that they can now record a gift. - As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
- On the contacts page, check that they cannot register someone else yet. - On the contacts page, check that they cannot register someone else yet.
- Walk through the functions on each page. - Walk through the functions on each page.
- Set and run notifications.
- Export & import, both seed and contacts & settings.
- Choose location on the search map.
## Scenarios - Offer, deliver a give, and confirm. Create a third user and test connections.
- On mobile, share an image with the app.
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard. - Switch to "no identifier" to see that things look OK without any ID.
- 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.
### Clear/Reset data & restart ### Clear/Reset data & restart
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.) * Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".) * Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers`.) * Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals`; in Firefox, go to `about:serviceworkers`.)
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.) * Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
(If you find more, add them to the HelpNotificationsView.vue file.) (If you find more, add them to the HelpNotificationsView.vue file.)
## 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 ## Other
@ -133,13 +225,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/). * [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 ### Kudos
Gifts make the world go 'round! Gifts make the world go 'round!
* [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80) * [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
* [Many tools & libraries]() such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org * [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439) * [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg) * [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e) * 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/)

3
babel.config.js

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

76
doc/README.md

@ -0,0 +1,76 @@
# TimeSafari Docs
## Generating PDF from Markdown on OSx
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
### Set Up
```bash
brew install pandoc
brew install basictex
# Setting up LaTex packages
# First update tlmgr
sudo tlmgr update --self
# Then install LaTex packages
sudo tlmgr install bbding
sudo tlmgr install enumitem
sudo tlmgr install environ
sudo tlmgr install fancyhdr
sudo tlmgr install framed
sudo tlmgr install import
sudo tlmgr install lastpage # Enables Page X of Y
sudo tlmgr install mdframed
sudo tlmgr install multirow
sudo tlmgr install needspace
sudo tlmgr install ntheorem
sudo tlmgr install tabu
sudo tlmgr install tcolorbox
sudo tlmgr install textpos
sudo tlmgr install titlesec
sudo tlmgr install titling # Required for the fancy headers used
sudo tlmgr install threeparttable
sudo tlmgr install trimspaces
sudo tlmgr install tocloft # Required for \tableofcontents generation
sudo tlmgr install varwidth
sudo tlmgr install wrapfig
# Install fonts
sudo tlmgr install cmbright
sudo tlmgr install collection-fontsrecommended # And set up fonts
sudo tlmgr install fira
sudo tlmgr install fontaxes
sudo tlmgr install libertine # The main font the doc uses
sudo tlmgr install opensans
sudo tlmgr install sourceserifpro
```
#### References
The following guide was adapted to this project except that we install with Brew and have a few more packages.
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
### Usage
Use the `pandoc` command to generate a PDF.
```bash
pandoc usage-guide.md -o usage-guide.pdf
```
And you can open the PDF with the `open` command.
```bash
open usage-guide.pdf
```
Or use this one-liner
```bash
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf
```

BIN
doc/images/01_infura-api-keys.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
doc/images/02-infura-key-detail.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
doc/images/03-infura-api-key-id.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
doc/images/04-pwa-chrome-devtools.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
doc/images/05-pwa-account-button.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
doc/images/06-pwa-account-page.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
doc/images/07-pwa-did-copied.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
doc/images/08-endorser-sqlite-row-added.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
doc/images/09-pwa-second-profile-first-open.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
doc/images/10-pwa-second-user-did.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
doc/images/11-pwa-first-user-add-contact.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
doc/images/12-pwa-first-user-contact-added.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
doc/images/13-pwa-first-user-register-second-user-btn.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
doc/images/14-pwa-first-user-register-yes.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
doc/images/timesafari-logo-binoculars.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
doc/images/timesafari-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

316
doc/usage-guide.md

@ -0,0 +1,316 @@
---
geometry: margin=1in
header-includes:
- \usepackage{graphicx}
- \usepackage{titling}
- \usepackage{fancyhdr}
- \usepackage{lastpage}
- \pagestyle{fancy}
- \fancyhead[L]{Time Safari Usage Guide}
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
- \fancyhead[R]{}
- \fancyfoot[L]{}
- \fancyfoot[C]{}
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}
- \usepackage{tocloft}
- \usepackage{libertine}
- \renewcommand{\familydefault}{\sfdefault}
- \fancypagestyle{tocstyle}{
\fancyhead[L]{Time Safari Usage Guide}
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
\fancyhead[R]{}
\fancyfoot[L]{}
\fancyfoot[C]{}
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}}
---
\begin{titlepage}
\centering
\vspace*{\fill}
{\huge\textbf{TimeSafari Usage guide}}
\vspace{1cm}
{\Large Signing up users, adding contacts, and adding gifts.}
\vspace{1cm}
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png}
\vspace*{\fill}
\vspace{1cm}
{\Large Trent Larson, Kent Bull}
\vspace{0.5cm}
{\large 2024-06-25}
\end{titlepage}
\clearpage
\begin{center}
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png}
\end{center}
\tableofcontents
\clearpage
# Purpose of Document
Both end-users and development team members need to know how to use TimeSafari.
This document serves to show how to use every feature of the TimeSafari platform.
Sections of this document are geared specifically for software developers and quality assurance
team members.
Companion videos will also describe end-to-end workflows for the end-user.
# TimeSafari
## Overview
\pagebreak
# 1 - End Users
This section covers application usage for people who will use TimeSafari as intended. It is a
simplified guide illustrating how to gain value from using TimeSafari.
\pagebreak
# 2 - Software Developers
This section is tailored for software developers seeking to use the application during development,
quality assurance, and testing.
# Bootstrapping a local development environment
The first concern a software developer has when working on TimeSafari is to set up a local
development environment. This section will guide you through the process.
## Prerequisites
1. Have the following installed on your local machine:
- Node.js and NPM
- A web browser. For this guide, we will use Google Chrome.
- Git
- A code editor
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum
blockchain.
- You can create an account on Infura [here](https://infura.io/).\
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to
be taken back to the list of keys.
Click "VIEW STATS" on the key you want to use.
![](images/01_infura-api-keys.png){ width=550px }
- Go to the key detail page. Then click "MANAGE API KEY".
![](images/02-infura-key-detail.png){ width=550px }
- Click the copy and paste button next to the string of alphanumeric characters.\
This is your API, also known as your project ID.
![](images/03-infura-api-key-id.png){width=550px }
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID`
environment variable.
## Setup steps
### 1. Clone the following repositories from their respective Git hosts:
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\
This is a Progressive Web App (PWA) built with VueJS and TypeScript.
Note that the clone command here is different from the one you would use for GitHub.
```bash
git clone git clone \
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git
```
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\
This is a NodeJS service providing the backend for TimeSafari.
```bash
git clone git@github.com:trentlarson/endorser-ch.git
```
\pagebreak
### 2. Database creation
#### Alternative 1 - use test data
To generate a development database and perform user setup you can run a local test with instructions
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90)
#### Alternative 2 - boostrap single seed user
In this method you will end up with two accounts in the database, one for the first boostrap user,
and the second as the primary user you will use during testing. The first user will invite the
second user to the app.
1. Install dependencies and environment variables.\
In endorser-ch install dependencies and set up environment variables to allow starting it up in
development mode.
```bash
cd endorser-ch
npm clean install # or npm ci
cp .env.local .env
```
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the
prerequisites.\
Then create the SQLite database by running `npm run flyway migrate` with environment variables
set correctly to select the default SQLite development user as follows.
```bash
export NODE_ENV=dev
export DBUSER=sa
export DBPASS=sasa
npm run flyway migrate
```
The first run of flyway migrate may take some time to complete because the entire Flyway
distribution must be downloaded prior to executing migrations.
Successful output looks similar to the following:
```
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41)
Schema history table "main"."flyway_schema_history" does not exist yet
Successfully validated 10 migrations (execution time 00:00.034s)
Creating Schema History table "main"."flyway_schema_history" ...
Current version of schema "main": << Empty Schema >>
Migrating schema "main" to version "1 - initial-anew"
Migrating schema "main" to version "2 - registration"
Migrating schema "main" to version "3 - plan project"
Migrating schema "main" to version "4 - offer gave"
Migrating schema "main" to version "5 - more confirmations"
Migrating schema "main" to version "6 - providers urls"
Migrating schema "main" to version "7 - hash nonce"
Migrating schema "main" to version "8 - project location"
Migrating schema "main" to version "9 - plan links"
Migrating schema "main" to version "10 - gift or trade"
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s)
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html
```
\pagebreak
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since
no other users exist to be able to invite the first user. This first user must be added manually
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA.
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that
user is required so that this first user can register other users.
- Change directories into `crowd-funder-for-time-pwa`
```bash
cd ..
cd crowd-funder-for-time-pwa
```
- Ensure the `.env.development` file exists and has the following values:
```env
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000
```
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we
need is to generate the first root user and this happens automatically on app startup.
```bash
npm clean install # or npm ci
npm run dev
```
- Open the app in a browser and go to the developer tools. It is recommended to use a completely
separate browser profile so you do not clear out your existing user account. We will be
completely resetting the PWA app state prior to generating the first user.
In the Developer Tools go to the Application tab.
![](images/04-pwa-chrome-devtools.png){width=350px}
Click the "Clear site data" button and then refresh the page.
- Click the account button in the bottom right corner of the page.
![](images/05-pwa-account-button.png){width=150px}
- This will take you to the account page titled "Your Identity" on which you can see your DID,
a `did:ethr` DID in this case.
![](images/06-pwa-account-page.png){width=350px}
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste
button as shown in the image.
![](images/07-pwa-did-copied.png){width=200px}
In our case this DID is:\
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6`
- Add that DID to the following echoed SQL statement where it says `YOUR_DID`
```bash
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch)
VALUES ('YOUR_DID', 100, 10000, 1719348718092);"
| sqlite3 ./endorser-ch-dev.sqlite3
```
and run this command in the parent directory just above the `endorser-ch` directory.
It needs to be the parent directory of your `endorser-ch` repository because when
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory
of `endorser-ch`.
- You can verify with an SQL browser tool that your record has been added to the `registration`
table.
![](images/08-endorser-sqlite-row-added.png){width=350px}
3. Then start the Endorser service in development mode with the following commands.
```bash
cd ./endorser-ch
export NODE_ENV=dev
npm run dev
```
This starts the Endorser service on port 3000.
4. Create the second user by opening up a separate browser profile or incognito session, opening the
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must
register you before you can give or offer."
![](images/09-pwa-second-profile-first-open.png){width=350px}
- If you want to ensure you have a fresh user account then open the developer tools, clear the
Application data as before, and then refresh the page. This will generate a new user in the
browser's IndexedDB database.
5. Go to the second users' account page to copy the DID.
![](images/10-pwa-second-user-did.png){width=350px}
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account
![](images/11-pwa-first-user-add-contact.png){width=350px}
7. Click the "+" plus icon to add the user.
![](images/12-pwa-first-user-contact-added.png){width=350px}
8. Then click the register button to register the second user.
![](images/13-pwa-first-user-register-second-user-btn.png){width=350px}
9. Click "YES" on the dialog that shows up.
![](images/14-pwa-first-user-register-yes.png){width=350px}
After this a notification will pop up indicating whether registration was successful or not.
10. You have finished the initial set up of users.

17
index.html

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

28903
package-lock.json

File diff suppressed because it is too large

130
package.json

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

98
playwright.config-local.ts

@ -0,0 +1,98 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./test-playwright",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
permissions: ["clipboard-read"],
},
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: "Google Chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
],
/* Configure global timeout; default is 30000 milliseconds */
// the image upload will often not succeed at 5 seconds
// timeout: 10000,
/* Run your local dev server before starting the tests */
/**
* This could be an array of servers, meaning we could start the Endorser server as well:
* {
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI,
* },
*
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
* in the user's settings so that it can be blanked out and the default is used.
*/
webServer: {
command:
"VITE_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev",
url: "http://localhost:8080",
reuseExistingServer: !process.env.CI,
},
});

82
playwright.config.ts

@ -0,0 +1,82 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'https://test.timesafari.app',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI,
// },
});

120
project.task.yaml

@ -1,122 +1,4 @@
tasks : tasks :
- add registration step to onboard help - This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d
- update dependencies, especially Veramo
- record donations vs gives
- deploy & migrate
- in mobile - change give & fulfills to array of objects?
- update docs
- show VC details... somehow:
- 01 show my VCs - most interesting, or via search
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others
- on gives feed - link to project
- show feed of offers, new projects, etc -- maybe limited to my search area
- revenue
- copy button for seed
- .5 If notifications are not enabled, add message to front page with link/button to enable
- make server endpoint for full English description of limits
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- create a help-desk document & add screenshots
- 01 server - show all claim details when issued by the issuer
- .1 update "offer" units to have same functionality as "give" units
- on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed)
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list)
- bug - 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, eg friend.tech
- .5 Add inactive flag / end date, start date to project
- .3 check that Android shows "back" buttons on screens without bottom tray
- .1 Make give description text box into something that expands as they type?
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
- switch some checks for activeDid to check for isRegistered
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
- .1 remove 2 second setTimeout in NewEditProjectView.vue
- warn if they're using the web (android only?)
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
https://web.dev/articles/get-installed-related-apps
- .5 fix masked icon (because some of the top-right of the binoculars is cut off)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
- .5 don't show "Offer" on project screen if they aren't registered
- 24 Move to Vite
- 32 accept images for projects
- 32 accept images for contacts
- import project interactions from GitHub/GitLab and manage signing
- show total time offered to & fulfilled to a project
- show total time offered by & fulfilled by a contact
- linking between projects or plans :
- show total time given to & from a project
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 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

17
public/index.html

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

248
src/App.vue

@ -1,7 +1,7 @@
<template> <template>
<router-view /> <router-view />
<!-- https://github.com/emmanuelsw/notiwind --> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert"> <NotificationGroup group="alert">
<div <div
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end" class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
@ -129,6 +129,10 @@
</div> </div>
</NotificationGroup> </NotificationGroup>
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
-->
<NotificationGroup group="modal"> <NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full"> <div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification <Notification
@ -142,12 +146,98 @@
move="transition duration-500" move="transition duration-500"
move-delay="delay-300" move-delay="delay-300"
> >
<!-- see NotificationIface in constants/app.ts -->
<div <div
v-for="notification in notifications" v-for="notification in notifications"
:key="notification.id" :key="notification.id"
class="w-full" class="w-full"
role="alert" role="alert"
> >
<!--
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">
{{ notification.title }}
</span>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button
v-if="notification.onYes"
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes{{
notification.yesText ? ", " + notification.yesText : ""
}}
</button>
<button
v-if="notification.onNo"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No{{ notification.noText ? ", " + notification.noText : "" }}
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
{{ notification.onYes ? "Cancel" : "Close" }}
</button>
</div>
</div>
</div>
<div <div
v-if="notification.type === 'notification-permission'" v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@ -157,7 +247,7 @@
> >
<div class="w-full px-6 py-6 text-slate-900 text-center"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<p v-if="serviceWorkerReady" class="text-lg mb-4"> <p v-if="serviceWorkerReady" class="text-lg mb-4">
Would you like to <b>turn on</b> notifications for this app? Would you like to be notified of new activity once a day?
</p> </p>
<p v-else class="text-lg mb-4"> <p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 10 Waiting for system initialization, which may take up to 10
@ -165,22 +255,42 @@
<fa icon="spinner" spin /> <fa icon="spinner" spin />
</p> </p>
<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 <button
v-if="serviceWorkerReady" class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
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=" @click="
() => {
if (checkHour()) {
close(notification.id); close(notification.id);
turnOnNotifications(); turnOnNotifications();
}
}
" "
> >
Turn on Notifications Turn on Daily Message
</button> </button>
</div>
<button <button
@click="close(notification.id)" @click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
> >
Maybe Later No, Not Now
</button> </button>
</div> </div>
</div> </div>
@ -263,8 +373,14 @@
<style></style> <style></style>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import axios from "axios"; import axios from "axios";
import { Vue, Component } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import * as libsUtil from "@/libs/util";
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util";
interface ServiceWorkerMessage { interface ServiceWorkerMessage {
type: string; type: string;
data: string; data: string;
@ -288,34 +404,31 @@ interface VapidResponse {
}; };
} }
import { DEFAULT_PUSH_SERVER } from "@/constants/app"; interface PushSubscriptionWithTime extends PushSubscriptionJSON {
import { db } from "@/db/index"; notifyTime: { utcHour: number };
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { sendTestThroughPushServer } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
} }
@Component @Component
export default class App extends Vue { export default class App extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = ""; b64 = "";
serviceWorkerReady = false; hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
async mounted() { async mounted() {
try { try {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl = DEFAULT_PUSH_SERVER; let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;
} }
if (pushUrl.startsWith("http://localhost")) {
console.log("Not checking for VAPID in this local environment.");
} else {
await axios await axios
.get(pushUrl + "/web-push/vapid") .get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => { .then((response: VapidResponse) => {
@ -336,6 +449,7 @@ export default class App extends Vue {
-1, -1,
); );
} }
}
} catch (error) { } catch (error) {
if (window.location.host.startsWith("localhost")) { if (window.location.host.startsWith("localhost")) {
console.log("Ignoring the error getting VAPID for local development."); console.log("Ignoring the error getting VAPID for local development.");
@ -353,7 +467,7 @@ export default class App extends Vue {
} }
} }
// there may be a long pause here on first initialization // there may be a long pause here on first initialization
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true; this.serviceWorkerReady = true;
}); });
} }
@ -434,6 +548,48 @@ export default class App extends Vue {
}); });
} }
// this allows us to show an error without closing the dialog
checkHour() {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Number",
text: "The time must be an hour number.",
},
5000,
);
return false;
}
const hourNum = libsUtil.numberOrZero(this.hourInput);
if (!Number.isInteger(hourNum)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be a whole hour number.",
},
5000,
);
return false;
}
if (hourNum < 1 || 12 < hourNum) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be an hour between 1 and 12.",
},
5000,
);
return false;
}
return true;
}
public async turnOnNotifications() { public async turnOnNotifications() {
return this.askPermission() return this.askPermission()
.then((permission) => { .then((permission) => {
@ -443,7 +599,7 @@ export default class App extends Vue {
this.subscribeToPush() this.subscribeToPush()
.then(() => { .then(() => {
console.log("Subscribed successfully."); console.log("Subscribed successfully.");
return navigator.serviceWorker.ready; return navigator.serviceWorker?.ready;
}) })
.then((registration) => { .then((registration) => {
return registration.pushManager.getSubscription(); return registration.pushManager.getSubscription();
@ -459,17 +615,29 @@ export default class App extends Vue {
}, },
-1, -1,
); );
this.sendSubscriptionToServer(subscription); // we already checked that this is a valid hour number
return subscription; const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
const hourNum = adjHourNum % 24;
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour },
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
return subscriptionWithTime;
} else { } else {
throw new Error("Subscription object is not available."); throw new Error("Subscription object is not available.");
} }
}) })
.then(async (subscription) => { .then(async (subscription: PushSubscriptionWithTime) => {
console.log( console.log(
"Subscription data sent to server and all finished successfully.", "Subscription data sent to server and all finished successfully.",
); );
await sendTestThroughPushServer(subscription, true); await libsUtil.sendTestThroughPushServer(subscription, true);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -499,20 +667,6 @@ export default class App extends Vue {
}); });
} }
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private subscribeToPush(): Promise<void> { private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) { if (!("serviceWorker" in navigator && "PushManager" in window)) {
@ -527,7 +681,7 @@ export default class App extends Vue {
return reject(new Error(errorMsg)); return reject(new Error(errorMsg));
} }
const applicationServerKey = this.urlBase64ToUint8Array(this.b64); const applicationServerKey = urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = { const options: PushSubscriptionOptions = {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: applicationServerKey, applicationServerKey: applicationServerKey,
@ -556,7 +710,7 @@ export default class App extends Vue {
} }
private sendSubscriptionToServer( private sendSubscriptionToServer(
subscription: PushSubscription, subscription: PushSubscriptionWithTime,
): Promise<void> { ): Promise<void> {
console.log("About to send subscription...", subscription); console.log("About to send subscription...", subscription);
return fetch("/web-push/subscribe", { return fetch("/web-push/subscribe", {
@ -575,7 +729,7 @@ export default class App extends Vue {
async turnOffNotifications() { async turnOffNotifications() {
let subscription; let subscription;
const pushProviderSuccess = await navigator.serviceWorker.ready const pushProviderSuccess = await navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
return registration.pushManager.getSubscription(); return registration.pushManager.getSubscription();
}) })
@ -589,7 +743,7 @@ export default class App extends Vue {
} }
}) })
.catch((error) => { .catch((error) => {
console.log("Push provider server communication failed:", error); console.error("Push provider server communication failed:", error);
return false; return false;
}); });
@ -604,7 +758,7 @@ export default class App extends Vue {
return response.ok; return response.ok;
}) })
.catch((error) => { .catch((error) => {
console.log("Push server communication failed:", error); console.error("Push server communication failed:", error);
return false; return false;
}); });

3
src/assets/blank-square.svg

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

3
src/assets/styles/tailwind.css

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

99
src/components/ContactNameDialog.vue

@ -0,0 +1,99 @@
<!-- similar to UserNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
{{ message }}
Note that their name is only stored on this device.
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="newText"
/>
<div class="mt-8">
<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
</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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
@Component
export default class ContactNameDialog extends Vue {
cancelCallback: () => void = () => {};
saveCallback: (name?: string) => void = () => {};
message = "";
newText = "";
title = "Contact Name";
visible = false;
async open(
title?: string,
message?: string,
saveCallback?: (name: string) => void,
cancelCallback?: () => void,
) {
this.cancelCallback = cancelCallback || this.cancelCallback;
this.saveCallback = saveCallback || this.saveCallback;
this.message = message ?? this.message;
this.title = title ?? this.title;
this.visible = true;
}
async onClickSaveChanges() {
this.visible = false;
if (this.saveCallback) {
this.saveCallback(this.newText);
}
}
onClickCancel() {
this.visible = false;
if (this.cancelCallback) {
this.cancelCallback();
}
}
}
</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>

45
src/components/EntityIcon.vue

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

218
src/components/FeedFilters.vue

@ -0,0 +1,218 @@
<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, retrieveSettingsForActiveAccount } 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;
const settings = await retrieveSettingsForActiveAccount();
this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true;
}
this.settingChanged = false;
this.visible = true;
}
async toggleHasVisibleDid() {
this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid,
});
}
async toggleNearby() {
this.settingChanged = true;
this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
async clearAll() {
if (this.hasVisibleDid || this.isNearby) {
this.settingChanged = true;
}
await 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;
}
await 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>

292
src/components/GiftedDialog.vue

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

260
src/components/GiftedPrompts.vue

@ -0,0 +1,260 @@
<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="currentCategory === CATEGORY_IDEAS">
<p class="text-center text-lg font-bold">
{{ IDEAS[currentIdeaIndex] }}
</p>
</span>
<div v-if="currentCategory === CATEGORY_CONTACTS">
<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="proceed"
>
That's it!
</button>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { GiverReceiverInputInfo } from "@/libs/util";
@Component
export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0;
IDEAS = [
"What food did someone fix for you?",
"What did a family member do for you?",
"What compliment did someone give you?",
"Who is someone you can always rely on, and how did they demonstrate that?",
"What did you see someone give to someone else?",
"What is a way that someone helped you even though you have never met?",
"How did a musician or author or artist inspire you?",
"What inspiration did you get from someone who handled tragedy well?",
"What is something worth respect that an organization gave you?",
"Who last gave you a good laugh?",
"What do you recall someone giving you while you were young?",
"Who forgave you or overlooked a mistake?",
"What is a way an ancestor contributed to your life?",
"What kind of help did someone at work give you?",
"How did a teacher or mentor or great example help you?",
];
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string,
) => void;
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined;
currentIdeaIndex = 0;
numContacts = 0;
shownContactDbIndices: Array<boolean> = [];
visible = false;
AppString = AppString;
async open(
callbackOnFullGiftInfo: (
contactInfo: GiverReceiverInputInfo,
description: string,
) => void,
) {
this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open();
this.numContacts = await db.contacts.count();
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
}
cancel() {
this.currentCategory = this.CATEGORY_IDEAS;
this.currentContact = undefined;
this.currentIdeaIndex = 0;
this.numContacts = 0;
this.shownContactDbIndices = [];
this.visible = false;
}
proceed() {
// proceed with logic but don't change values (just in case some actions are added later)
this.visible = false;
if (this.currentCategory === this.CATEGORY_IDEAS) {
(this.$router as Router).push({
name: "contact-gift",
query: {
prompt: this.IDEAS[this.currentIdeaIndex],
},
});
} else {
// must be this.CATEGORY_CONTACTS
this.callbackOnFullGiftInfo?.(
this.currentContact as GiverReceiverInputInfo,
);
}
}
/**
* Get the next idea.
* If it is a contact prompt, loop through.
*/
async nextIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex++;
if (this.currentIdeaIndex === this.IDEAS.length) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact();
}
} else {
// must be this.CATEGORY_CONTACTS
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
}
}
/**
* Get the previous idea.
* If it is a contact prompt, loop through.
*/
async prevIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex--;
if (this.currentIdeaIndex < 0) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact();
}
} else {
// must be this.CATEGORY_CONTACTS
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
}
}
nextIdeaPastContacts() {
this.currentContact = undefined;
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
this.currentCategory = this.CATEGORY_IDEAS;
// look at the previous idea and switch to the other side of the list
this.currentIdeaIndex =
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1;
}
async findNextUnshownContact() {
if (this.currentCategory === this.CATEGORY_IDEAS) {
// we're not in the contact prompts, so reset index array
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
}
this.currentCategory = this.CATEGORY_CONTACTS;
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0;
// as long as the index has an entry, loop
while (
this.shownContactDbIndices[someContactDbIndex] != null &&
count++ < this.numContacts
) {
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
}
if (count >= this.numContacts) {
// all contacts have been shown
this.nextIdeaPastContacts();
} else {
// get the contact at that offset
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
this.shownContactDbIndices[someContactDbIndex] = true;
}
}
}
</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>

177
src/components/ImageMethodDialog.vue

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

118
src/components/InviteDialog.vue

@ -0,0 +1,118 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Invitation & Notes</h1>
These are optional notes for your use; they are comments to help you
recall who it is when they accept it. These notes are sent to the server.
If you want to store your own way, the invitation ID is:
{{ inviteIdentifier }}
<input
type="text"
placeholder="Notes"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="text"
/>
<!-- Add date selection element -->
Expiration
<input
type="date"
class="block rounded border border-slate-400 mb-4 px-3 py-2"
v-model="expiresAt"
/>
<div class="mt-8">
<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
</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>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (text: string, expiresAt: string) => void = () => {};
inviteIdentifier = "";
text = "";
visible = false;
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
.toISOString()
.substring(0, 10);
async open(
inviteIdentifier: string,
aCallback: (text: string, expiresAt: string) => void,
) {
this.callback = aCallback;
this.inviteIdentifier = inviteIdentifier;
this.visible = true;
}
async onClickSaveChanges() {
if (!this.expiresAt) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Needs Expiration",
text: "You must select an expiration date.",
},
5000,
);
} else {
this.callback(this.text, this.expiresAt);
this.visible = false;
}
}
onClickCancel() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

167
src/components/OfferDialog.vue

@ -4,26 +4,30 @@
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1> <h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input <input
type="text" type="text"
data-testId="inputDescription"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Description, prerequisites, terms, etc." placeholder="Description of what is offered"
v-model="description" v-model="description"
/> />
<div class="flex flex-row mb-6"> <div class="flex flex-row mt-2">
<span <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> </span>
<div <div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()" @click="decrement()"
v-if="amountInput !== '0'"
> >
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
type="text" data-testId="inputOfferAmount"
type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center" class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="hours" v-model="amountInput"
/> />
<div <div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2" class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@ -32,74 +36,89 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="flex flex-row mb-6"> <div class="mt-4 flex justify-center">
<span <span>
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" <router-link
:to="{
name: 'offer-details',
query: {
amountInput,
description,
offererDid: activeDid,
projectId,
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
> >
Expiration Conditions & more options...
</router-link>
</span> </span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
v-model="expirationDateInput"
/>
</div> </div>
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p> <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 <button
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2" class="block w-full text-center text-lg font-bold uppercase bg-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" @click="confirm"
> >
Sign &amp; Send Sign &amp; Send
</button> </button>
<button <button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel" @click="cancel"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; 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 { import { NotificationIface } from "@/constants/app";
group: string; import { createAndSubmitOffer } from "@/libs/endorserServer";
type: string; import * as libsUtil from "@/libs/util";
title: string; import { retrieveSettingsForActiveAccount } from "@/db/index";
text: string;
}
@Component @Component
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = ""; @Prop projectId?;
@Prop projectId = ""; @Prop projectName?;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
amountInput = "0";
amountUnitCode = "HUR";
description = ""; description = "";
expirationDateInput = ""; expirationDateInput = "";
hours = "0"; recipientDid? = "";
recipientName? = "";
visible = false; visible = false;
async created() { libsUtil = libsUtil;
async open(recipientDid?: string, recipientName?: string) {
try { try {
await db.open(); this.recipientDid = recipientDid;
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.recipientName = recipientName;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || ""; const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
console.log("Error retrieving settings from database:", err); console.error("Error retrieving settings from database:", err);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -110,28 +129,41 @@ export default class OfferDialog extends Vue {
-1, -1,
); );
} }
}
open() {
this.visible = true; this.visible = true;
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false; this.visible = false;
} }
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.amountUnitCode);
this.amountUnitCode = units[(index + 1) % units.length];
}
increment() { increment() {
this.hours = `${(parseFloat(this.hours) || 0) + 1}`; this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
} }
decrement() { decrement() {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`; this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
} }
cancel() { cancel() {
this.close(); this.close();
this.eraseValues();
}
eraseValues() {
this.description = ""; this.description = "";
this.hours = "0"; this.amountInput = "0";
this.amountUnitCode = "HUR";
} }
async confirm() { async confirm() {
@ -148,38 +180,25 @@ export default class OfferDialog extends Vue {
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
this.recordOffer( this.recordOffer(
this.description, this.description,
parseFloat(this.hours), parseFloat(this.amountInput),
this.amountUnitCode,
this.expirationDateInput, this.expirationDateInput,
).then(() => { ).then(() => {
this.description = ""; 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 description may be an empty string
* @param hours may be 0 * @param hours may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/ */
public async recordOffer( public async recordOffer(
description?: string, description: string,
hours?: number, amount: number,
unitCode: string = "HUR",
expirationDateInput?: string, expirationDateInput?: string,
) { ) {
if (!this.activeDid) { if (!this.activeDid) {
@ -190,18 +209,18 @@ export default class OfferDialog extends Vue {
title: "Error", title: "Error",
text: "You must select an identity before you can record an offer.", text: "You must select an identity before you can record an offer.",
}, },
-1, 7000,
); );
return; return;
} }
if (!description && !hours) { if (!description && !amount) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", 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, -1,
); );
@ -209,14 +228,16 @@ export default class OfferDialog extends Vue {
} }
try { try {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitOffer( const result = await createAndSubmitOffer(
this.axios, this.axios,
this.apiServer, this.apiServer,
identity, this.activeDid,
description, description,
hours, amount,
unitCode,
"",
expirationDateInput, expirationDateInput,
this.recipientDid,
this.projectId, this.projectId,
); );
@ -225,7 +246,7 @@ export default class OfferDialog extends Vue {
this.isOfferCreationError(result.response) this.isOfferCreationError(result.response)
) { ) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = this.getOfferCreationErrorMessage(result);
console.log("Error with offer creation result:", result); console.error("Error with offer creation result:", result);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -243,12 +264,12 @@ export default class OfferDialog extends Vue {
title: "Success", title: "Success",
text: "That offer was recorded.", text: "That offer was recorded.",
}, },
10000, 5000,
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
console.log("Error with offer recordation caught:", error); console.error("Error with offer recordation caught:", error);
const message = const message =
error.userMessage || error.userMessage ||
error.response?.data?.error?.message || error.response?.data?.error?.message ||

281
src/components/OnboardingDialog.vue

@ -0,0 +1,281 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div v-if="page === OnboardPage.Home" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari
<br />
- Showcasing Gratitude & Magnifing Time
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<p v-if="isRegistered" class="mt-4">
You can now log things that you've received or witnessed:
<span v-if="numContacts > 0">
click on {{ firstContactName }}'s name or
</span>
click on "Unnamed" to express your appreciation for... whatever -- like
thanks for showing you all these fascinating stories of
<em>gratitude</em>.
</p>
<p v-else class="mt-4">
The feed underneath this pop-up shows the latest gifts recognized by
others. Once someone registers you, you'll be able to log your
appreciation, too.
</p>
<p class="mt-4">
The more you illuminate cool things people are doing, the more you
attract people to work together with you.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="house-chimney"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you back to this feed screen.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true)"
>
That's enough help, thanks.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'discover' })"
>
Show me more!
</button>
</div>
</div>
<p class="mt-4 flex items-center">
To see these instructions and more, click above on
<span
class="ml-1 mr-1 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"
>
Help
</span>
</p>
</div>
<div v-if="page === OnboardPage.Discover" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Offer to Interesting Events & People
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<p>
Once you've seen things that others have given or done, you may find
ways you want to contribute, too. It turns out others have proposed
activities together, and this page is where you find projects.
</p>
<p class="mt-4">
Search for a topic, or search around your neighborhod under "Nearby".
</p>
<p class="mt-4">
When you find some that seem interesting, you can offer your help. You
are welcome to make your offer conditional, for example if they get 2
other people, too.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="magnifying-glass"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you to this discovery screen.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true)"
>
No more help, thanks.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'projects' })"
>
Show me even more.
</button>
</div>
</div>
</div>
<div v-if="page === OnboardPage.Create" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Fish for Others with Your Projects
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<p class="relative">
Now you can take a turn: click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw"></fa>
</span>
button to throw out projects of your own... anything you'd like to see
happen. If your first idea doesn't catch anyone, try, try again... and
let others know that this is a good place to find help.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="hand"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you here to see your ideas.
</p>
<p class="mt-4">
By the way, one good way to get to know your neighbors and their
interests is to offer time directly to them. You can do this on the
contacts screen
<fa icon="users" class="text-slate-500" />
which is a great way to get to know a neighbor's interests.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true, true)"
>
Let's go!
<br />
See & record gratitude.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'help' })"
>
I want to read more Help.
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
import { OnboardPage } from "@/libs/util";
@Component({
computed: {
OnboardPage() {
return OnboardPage;
},
},
components: { OnboardPage },
})
export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
firstContactName = null;
givenName = "";
isRegistered = false;
numContacts = 0;
page = OnboardPage.Home;
visible = false;
async open(page: OnboardPage) {
this.page = page;
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name;
}
this.visible = true;
if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
}
async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false;
if (done) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (goHome) {
(this.$router as Router).push({ name: "home" });
}
}
}
}
</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>

439
src/components/PhotoDialog.vue

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

50
src/components/ProjectIcon.vue

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

10
src/components/QuickNav.vue

@ -12,7 +12,7 @@
}" }"
> >
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"> <router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa> <fa icon="house-chimney" class="fa-fw" />
</router-link> </router-link>
</li> </li>
<!-- Search --> <!-- Search -->
@ -28,7 +28,7 @@
:to="{ name: 'discover' }" :to="{ name: 'discover' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="magnifying-glass" class="fa-fw"></fa> <fa icon="magnifying-glass" class="fa-fw" />
</router-link> </router-link>
</li> </li>
<!-- Projects --> <!-- Projects -->
@ -44,7 +44,7 @@
:to="{ name: 'projects' }" :to="{ name: 'projects' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="folder-open" class="fa-fw"></fa> <fa icon="hand" class="fa-fw" />
</router-link> </router-link>
</li> </li>
<!-- Contacts --> <!-- Contacts -->
@ -60,7 +60,7 @@
:to="{ name: 'contacts' }" :to="{ name: 'contacts' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="users" class="fa-fw"></fa> <fa icon="users" class="fa-fw" />
</router-link> </router-link>
</li> </li>
<!-- Profile --> <!-- Profile -->
@ -76,7 +76,7 @@
:to="{ name: 'account' }" :to="{ name: 'account' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="circle-user" class="fa-fw"></fa> <fa icon="circle-user" class="fa-fw" />
</router-link> </router-link>
</li> </li>
</ul> </ul>

33
src/components/TopMessage.vue

@ -1,23 +1,26 @@
<template> <template>
<div class="text-center text-red-500">{{ message }}</div> <div class="absolute right-5 top-3">
<span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2">
<router-link
:to="{ name: 'help' }"
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>
</span>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from "vue-facing-decorator";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { AppString } from "@/constants/app";
interface Notification { import { AppString, NotificationIface } from "@/constants/app";
group: string; import { retrieveSettingsForActiveAccount } from "@/db/index";
type: string;
title: string;
text: string;
}
@Component @Component
export default class TopMessage extends Vue { export default class TopMessage extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop selected = ""; @Prop selected = "";
@ -25,17 +28,15 @@ export default class TopMessage extends Vue {
async mounted() { async mounted() {
try { try {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
if ( if (
settings?.warnIfTestServer && settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix; this.message = "You're linked to a non-prod server, user " + didPrefix;
} else if ( } else if (
settings?.warnIfProdServer && settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15);

95
src/components/UserNameDialog.vue

@ -0,0 +1,95 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
This is not sent to servers. It is only shared with people when you send
it to them.
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="givenName"
/>
<div class="mt-8">
<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
</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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component
export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (name?: string) => void = () => {};
givenName = "";
visible = false;
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
this.visible = true;
}
async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName,
});
this.visible = false;
this.callback(this.givenName);
}
onClickCancel() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

25
src/components/World/components/objects/landmarks.js

@ -1,12 +1,10 @@
import axios from "axios"; import axios from "axios";
import * as R from "ramda";
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js"; import * as TWEEN from "@tweenjs/tween.js";
import { accountsDB, db } from "@/db"; import { retrieveSettingsForActiveAccount } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { getHeaders } from "@/libs/endorserServer";
import { accessToken } from "@/libs/crypto";
const ANIMATION_DURATION_SECS = 10; const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/"; const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
@ -15,21 +13,10 @@ export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS); vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try { try {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const activeDid = settings.activeDid || "";
const activeDid = settings?.activeDid || ""; const apiServer = settings.apiServer;
const apiServer = settings?.apiServer; const headers = await getHeaders(activeDid);
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const headers = {
"Content-Type": "application/json",
};
const identity = JSON.parse(account?.identity || "null");
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction"; const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
const resp = await axios.get(url, { headers: headers }); const resp = await axios.get(url, { headers: headers });

42
src/constants/app.ts

@ -4,20 +4,52 @@
* See also ../libs/veramo/setup.ts * See also ../libs/veramo/setup.ts
*/ */
export enum AppString { export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000", LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
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",
PROD_PARTNER_API_SERVER = "https://endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner.endorser.ch",
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = "https://timesafari.app", PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
NO_CONTACT_NAME = "(no name)",
} }
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER; export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
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_PARTNER_API_SERVER =
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
AppString.TEST_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/** /**
* The possible values for "group" and "type" are in App.vue. * The possible values for "group" and "type" are in App.vue.
* From the notiwind package * From the notiwind package
@ -26,5 +58,11 @@ export interface NotificationIface {
group: string; // "alert" | "modal" group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string; title: string;
text: string; text?: string;
noText?: string;
onCancel?: (stopAsking?: boolean) => Promise<void>;
onNo?: (stopAsking?: boolean) => Promise<void>;
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
} }

108
src/db/index.ts

@ -1,5 +1,7 @@
import BaseDexie, { Table } from "dexie"; import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda";
import { Account, AccountsSchema } from "./tables/accounts"; import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts"; import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs"; import { Log, LogSchema } from "./tables/logs";
@ -8,6 +10,7 @@ import {
Settings, Settings,
SettingsSchema, SettingsSchema,
} from "./tables/settings"; } from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data // Define types for tables that hold sensitive and non-sensitive data
@ -16,6 +19,7 @@ type NonsensitiveTables = {
contacts: Table<Contact>; contacts: Table<Contact>;
logs: Table<Log>; logs: Table<Log>;
settings: Table<Settings>; settings: Table<Settings>;
temp: Table<Temp>;
}; };
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
@ -25,14 +29,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
// Initialize Dexie databases for sensitive and non-sensitive data // Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = {
...ContactSchema,
...LogSchema,
...SettingsSchema,
};
// Manage the encryption key. If not present in localStorage, create and store it. // Manage the encryption key. If not present in localStorage, create and store it.
const secret = const secret =
@ -42,16 +39,95 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key // Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret }); encrypted(accountsDB, { secretKey: secret });
// Define the schema for our databases // Define the schemas for our databases
accountsDB.version(1).stores(SensitiveSchemas); // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
// v1 was contacts & settings accountsDB.version(1).stores(AccountsSchema);
// v2 added logs // v1 also had contacts & settings
db.version(2).stores(NonsensitiveSchemas); // v2 added Log
db.version(2).stores({
...ContactSchema,
...LogSchema,
...{ settings: "id" }, // old Settings schema
});
// v3 added Temp
db.version(3).stores(TempSchema);
db.version(4)
.stores(SettingsSchema)
.upgrade((tx) => {
return tx
.table("settings")
.toCollection()
.modify((settings) => {
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
});
});
// Event handler to initialize the non-sensitive database with default settings const DEFAULT_SETTINGS = {
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY, id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER, apiServer: DEFAULT_ENDORSER_API_SERVER,
};
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
}); });
});
// retrieves default settings
// calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
await db.open();
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
}
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount();
if (!defaultSettings.activeDid) {
return defaultSettings;
} else {
const overrideSettings =
(await db.settings
.where("accountDid")
.equals(defaultSettings.activeDid)
.first()) || {};
return R.mergeDeepRight(defaultSettings, overrideSettings);
}
}
// Update settings for the given account, or in MASTER_SETTINGS_KEY if no accountDid is provided.
// Don't expose this because we should be explicit on whether we're updating the default settings or account settings.
async function updateSettings(settingsChanges: Settings): Promise<void> {
await db.open();
if (!settingsChanges.accountDid) {
// ensure there is no "id" that would override the key
delete settingsChanges.id;
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
} else {
const result = await db.settings
.where("accountDid")
.equals(settingsChanges.accountDid)
.modify(settingsChanges);
if (result === 0) {
if (!settingsChanges.id) {
// It is unfortunate that we have to set this explicitly.
// We didn't make id a "++id" at the beginning and Dexie won't let us change it,
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
settingsChanges.id = (await db.settings.count()) + 1;
}
await db.settings.add(settingsChanges);
}
}
}
export async function updateDefaultSettings(settings: Settings): Promise<void> {
delete settings.accountDid; // just in case
await updateSettings(settings);
}
export async function updateAccountSettings(
accountDid: string,
settings: Settings,
): Promise<void> {
settings.accountDid = accountDid;
await updateSettings(settings);
}

1
src/db/tables/README.md

@ -0,0 +1 @@
Check the contact & settings export to see whether you want your new table to be included in it.

29
src/db/tables/accounts.ts

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

5
src/db/tables/contacts.ts

@ -2,9 +2,10 @@ export interface Contact {
did: string; did: string;
name?: string; name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
profileImageUrl?: string;
publicKeyBase64?: string; publicKeyBase64?: string;
seesMe?: boolean; seesMe?: boolean; // cached value of the server setting
registered?: boolean; registered?: boolean; // cached value of the server setting
} }
export const ContactSchema = { export const ContactSchema = {

40
src/db/tables/settings.ts

@ -12,15 +12,35 @@ export type BoundingBox = {
* Settings type encompasses user-specific configuration details. * Settings type encompasses user-specific configuration details.
*/ */
export type Settings = { export type Settings = {
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY // default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: number; // this is only blank on input, when the database assigns it
// if supplied, this settings record overrides the master record when the user switches to this account
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL apiServer?: string; // API server URL
firstName?: string; // User's first name
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean; isRegistered?: boolean;
imageServer?: string;
lastName?: string; // deprecated - put all names in firstName lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string; // Last notified claim ID
lastViewedClaimId?: string; // Last viewed claim ID lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string; // may be null if unwanted for a particular account
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders reminderOn?: boolean; // Toggle to enable or disable reminders
@ -31,20 +51,28 @@ export type Settings = {
}>; }>;
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL webPushServer?: string; // Web Push server URL
}; };
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}
/** /**
* Schema for the Settings table in the database. * Schema for the Settings table in the database.
*/ */
export const SettingsSchema = { export const SettingsSchema = {
settings: "id", settings: "id, &accountDid",
}; };
/** /**
* Constants. * Constants.
*/ */
export const MASTER_SETTINGS_KEY = 1; export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

14
src/db/tables/temp.ts

@ -0,0 +1,14 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string;
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};

93
src/libs/crypto/index.ts

@ -3,14 +3,18 @@ import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39"; import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode"; import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer"; import {
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
export const LOCAL_KMS_NAME = "local";
/** /**
* *
* *
@ -31,7 +35,7 @@ export const newIdentifier = (
keys: [ keys: [
{ {
kid: publicHex, kid: publicHex,
kms: "local", kms: LOCAL_KMS_NAME,
meta: { derivationPath: derivationPath }, meta: { derivationPath: derivationPath },
privateKeyHex: privateHex, privateKeyHex: privateHex,
publicKeyHex: publicHex, publicKeyHex: publicHex,
@ -64,6 +68,10 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath]; return [address, privateHex, publicHex, derivationPath];
}; };
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/** /**
* *
* *
@ -77,81 +85,20 @@ export const generateSeed = (): string => {
}; };
/** /**
* Retreive an access token * Retrieve an access token, or "" if no DID is provided.
* *
* @param {IIdentifier} identifier
* @return {*} * @return {*}
*/ */
export const accessToken = async (identifier: IIdentifier) => { export const accessToken = async (did?: string) => {
const did: string = identifier.did; if (did) {
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
const signer = SimpleSigner(privateKeyHex);
const nowEpoch = Math.floor(Date.now() / 1000); const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R return createEndorserJwtForDid(did, tokenPayload);
const jwt: string = await didJwt.createJWT(tokenPayload, { } else {
alg, return "";
issuer: did,
signer,
});
return jwt;
};
export const sign = async (privateKeyHex: string) => {
const signer = SimpleSigner(privateKeyHex);
return signer;
};
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
export function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
} }
};
/** /**
@return results of uportJwtPayload: @return results of uportJwtPayload:
@ -169,7 +116,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
} }
// JWT format: { header, payload, signature, data } // JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText); const jwt = decodeEndorserJwt(jwtText);
return jwt.payload; return jwt.payload;
}; };

46
src/libs/crypto/vc/did-eth-local-resolver.ts

@ -0,0 +1,46 @@
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* This effectively hard codes the did:ethr DID resolver to use the address as the public key.
* @param did : string
* @returns {Promise<DIDResolutionResult>}
*
* Similar code resides in image-api
*/
export const didEthLocalResolver = async (did: string) => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1]; // Extract eth address: 0x...
const publicKeyHex = address; // Use the address directly as a public key placeholder
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSec256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

96
src/libs/crypto/vc/didPeer.ts

@ -0,0 +1,96 @@
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, multibaseToBytes } from "did-jwt";
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers";
export const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
/**
*
*
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto
*
* @returns {Promise<boolean>}
*/
export async function verifyPeerSignature(
payloadBytes: Buffer,
issuerDid: string,
signatureBytes: Uint8Array,
): Promise<boolean> {
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const WebCrypto = await getWebCrypto();
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"],
);
const verified = await WebCrypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
signatureBytes,
payloadBytes,
);
return verified;
}
export function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes);
if (
jwkObj[1] != 2 || // kty "EC"
jwkObj[3] != -7 || // alg "ES256"
jwkObj[-1] != 1 || // crv "P-256"
jwkObj[-2].length != 32 || // x
jwkObj[-3].length != 32 // y
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]),
]);
return { publicKeyJwk, publicKeyBuffer };
}
export function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export function createPeerDid(publicKeyBytes: Uint8Array) {
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
const methodSpecificId = bytesToMultibase(
publicKeyBytes,
"base58btc",
"p256-pub",
);
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
}

200
src/libs/crypto/vc/index.ts

@ -0,0 +1,200 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import { Buffer } from "buffer/";
import * as didJwt from "did-jwt";
import { JWTVerified } from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT";
import { Resolver } from "did-resolver";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
const resolver = new Resolver({ ethr: didEthLocalResolver });
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
expiresIn?: number,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
const options = {
issuer: account.did,
signer: signer,
expiresIn: undefined as number | undefined,
};
if (expiresIn) {
options.expiresIn = expiresIn;
}
return didJwt.createJWT(payload, options);
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(privateKeyHexString)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
// We should be calling 'verify' in more places, showing warnings if it fails.
export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt);
}
// return Promise of at least { issuer, payload, verified boolean }
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
export async function decodeAndVerifyJwt(
jwt: string,
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> {
const pieces = jwt.split(".");
console.log("WTF decodeAndVerifyJwt", typeof jwt, jwt, pieces);
const header = JSON.parse(base64urlDecodeString(pieces[0]));
const payload = JSON.parse(base64urlDecodeString(pieces[1]));
console.log("WTF decodeAndVerifyJwt after", header, payload);
const issuerDid = payload.iss;
if (!issuerDid) {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await didJwt.verifyJWT(jwt, { resolver });
return verified;
} catch (e: unknown) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const verified = await verifyPeerSignature(
Buffer.from(payload),
issuerDid,
urlBase64ToUint8Array(pieces[2]),
);
if (!verified) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
} else {
return { issuer: issuerDid, payload: payload, verified: true };
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}

549
src/libs/crypto/vc/passkeyDidPeer.ts

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

105
src/libs/crypto/vc/passkeyHelpers.ts

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

11
src/libs/crypto/vc/util.ts

@ -0,0 +1,11 @@
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

1274
src/libs/endorserServer.ts

File diff suppressed because it is too large

390
src/libs/util.ts

@ -1,29 +1,374 @@
// many of these are also found in endorser-mobile utility.ts // many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { db } from "@/db/index"; import {
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; accountsDB,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
} from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import * as serverUtil from "@/libs/endorserServer";
import {
containsHiddenDid,
GenericCredWrapper,
GenericVerifiableCredential,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import { KeyMeta } from "@/libs/crypto/vc";
import { createPeerDid } from "@/libs/crypto/vc/didPeer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
// eslint-disable-next-line @typescript-eslint/no-var-requires export interface GiverReceiverInputInfo {
const Buffer = require("buffer/").Buffer; did?: string;
name?: string;
}
export enum OnboardPage {
Home = "HOME",
Discover = "DISCOVER",
Create = "CREATE",
Contact = "CONTACT",
Account = "ACCOUNT",
}
export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
"BTC": "BTC",
"BX": "BX",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
};
/* eslint-enable prettier/prettier */
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BTC": "Bitcoin",
"BX": "Buxbe",
"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) => { export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
}; };
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, too), export const isGiveAction = (
// and make sure they can take all actions while the notification shows. veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
export const ONBOARD_MESSAGE = ) => {
"1) Check that they have entered their name on the profile page in their device. 2) Add them to your Contacts by scanning with the QR icon that is by the input box. 3) Click the person icon to register them. 4) Have them go to their Contact page and scan your QR to add you to their list."; return veriClaim.claimType === "GiveAction";
};
export const nameForDid = (
activeDid: string,
contacts: Array<Contact>,
did: string,
): string => {
if (did === activeDid) {
return "you";
}
const contact = R.find((con) => con.did == did, contacts);
return nameForContact(contact);
};
export const nameForContact = (
contact?: Contact,
capitalize?: boolean,
): string => {
return (
(contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user"
);
};
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 = (
isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string,
confirmerIdList: string[] = [],
) => {
return (
isRegistered &&
isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
);
};
export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
// Extract the content type and the Base64 data
const [metadata, base64] = base64DataUrl.split(",");
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
const byteCharacters = atob(base64);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: contentType });
}
/**
* @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<OfferVerifiableCredential>,
) => 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<GenericVerifiableCredential>,
) => {
return !!(
veriClaim.claimType === "Offer" &&
offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
);
};
// 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 interface AccountKeyInfo extends Account, KeyMeta {}
export const getAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
return account;
};
/**
* 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 updateDefaultSettings({ activeDid: newId.did });
//console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;
};
export const registerAndSavePasskey = async (
keyName: string,
): Promise<Account> => {
const cred = await registerCredential(keyName);
const publicKeyBytes = cred.publicKeyBytes;
const did = createPeerDid(publicKeyBytes as Uint8Array);
const passkeyCredIdHex = cred.credIdHex as string;
const account = {
dateCreated: new Date().toISOString(),
did,
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
await accountsDB.open();
await accountsDB.accounts.add(account);
return account;
};
export const registerSaveAndActivatePasskey = async (
keyName: string,
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
return account;
};
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount();
return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60
);
};
export const sendTestThroughPushServer = async ( export const sendTestThroughPushServer = async (
subscription: PushSubscription, subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean, skipFilter: boolean,
): Promise<AxiosResponse> => { ): Promise<AxiosResponse> => {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl: string = DEFAULT_PUSH_SERVER as string; let pushUrl: string = DEFAULT_PUSH_SERVER as string;
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;
@ -34,28 +379,11 @@ export const sendTestThroughPushServer = async (
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213 // Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION"; const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const auth = Buffer.from(subscription.getKey("auth"));
const authB64 = auth
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const p256dh = Buffer.from(subscription.getKey("p256dh"));
const p256dhB64 = p256dh
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const newPayload = { const newPayload = {
endpoint: subscription.endpoint, // eslint-disable-next-line prettier/prettier
keys: { message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
auth: authB64,
p256dh: p256dhB64,
},
message: `Test, where you will see this message ${
skipFilter ? "un" : ""
}filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push", title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
...subscriptionJSON,
}; };
console.log("Sending a test web push message:", newPayload); console.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload); const payloadStr = JSON.stringify(newPayload);

77
src/main.ts

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

5
src/registerServiceWorker.ts

@ -2,8 +2,9 @@
import { register } from "register-service-worker"; import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") { // NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
register("/additional-scripts.js", { if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", {
ready() { ready() {
console.log( console.log(
"App is being served from cache by a service worker.\n" + "App is being served from cache by a service worker.\n" +

210
src/router/index.ts

@ -28,200 +28,220 @@ const enterOrStart = async (
}; };
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: () =>
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
},
{ {
path: "/account", path: "/account",
name: "account", name: "account",
component: () => component: () => import("../views/AccountViewView.vue"),
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
}, },
{ {
path: "/claim/:id?", path: "/claim/:id?",
name: "claim", name: "claim",
component: () => component: () => import("../views/ClaimView.vue"),
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"), },
{
path: "/claim-add-raw/:id?",
name: "claim-add-raw",
component: () => import("../views/ClaimAddRawView.vue"),
}, },
{ {
path: "/confirm-contact", path: "/confirm-contact",
name: "confirm-contact", name: "confirm-contact",
component: () => component: () => import("../views/ConfirmContactView.vue"),
import( },
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue" {
), path: "/confirm-gift/:id?",
name: "confirm-gift",
component: () => import("@/views/ConfirmGiftView.vue"),
}, },
{ {
path: "/contact-amounts", path: "/contact-amounts",
name: "contact-amounts", name: "contact-amounts",
component: () => component: () => import("../views/ContactAmountsView.vue"),
import(
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
),
}, },
{ {
path: "/contact-gives", path: "/contact-gift",
name: "contact-gives", name: "contact-gift",
component: () => component: () => import("../views/ContactGiftingView.vue"),
import( },
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue" {
), path: "/contact-import",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
}, },
{ {
path: "/contact-qr", path: "/contact-qr",
name: "contact-qr", name: "contact-qr",
component: () => component: () => import("../views/ContactQRScanShowView.vue"),
import(
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
),
}, },
{ {
path: "/contacts", path: "/contacts",
name: "contacts", name: "contacts",
component: () => component: () => import("../views/ContactsView.vue"),
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"), },
{
path: "/did/:did?",
name: "did",
component: () => import("../views/DIDView.vue"),
}, },
{ {
path: "/discover", path: "/discover",
name: "discover", name: "discover",
component: () => component: () => import("../views/DiscoverView.vue"),
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"), },
{
path: "/gifted-details",
name: "gifted-details",
component: () => import("@/views/GiftedDetailsView.vue"),
}, },
{ {
path: "/help", path: "/help",
name: "help", name: "help",
component: () => component: () => import("../views/HelpView.vue"),
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
}, },
{ {
path: "/help-notifications", path: "/help-notifications",
name: "help-notifications", name: "help-notifications",
component: () => component: () => import("../views/HelpNotificationsView.vue"),
import( },
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue" {
), path: "/help-onboarding",
name: "help-onboarding",
component: () => import("../views/HelpOnboardingView.vue"),
},
{
path: "/",
name: "home",
component: () => import("../views/HomeView.vue"),
}, },
{ {
path: "/identity-switcher", path: "/identity-switcher",
name: "identity-switcher", name: "identity-switcher",
component: () => component: () => import("../views/IdentitySwitcherView.vue"),
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
}, },
{ {
path: "/import-account", path: "/import-account",
name: "import-account", name: "import-account",
component: () => component: () => import("../views/ImportAccountView.vue"),
import(
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
),
}, },
{ {
path: "/import-derive", path: "/import-derive",
name: "import-derive", name: "import-derive",
component: () => component: () => import("../views/ImportDerivedAccountView.vue"),
import(
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
),
}, },
{ {
path: "/new-edit-account", path: "/invite-one",
name: "new-edit-account", name: "invite-one",
component: () => component: () => import("../views/InviteOneView.vue"),
import(
/* webpackChunkName: "new-edit-account" */ "../views/NewEditAccountView.vue"
),
}, },
{ {
path: "/new-edit-commitment", path: "/new-activity",
name: "new-edit-commitment", name: "new-activity",
component: () => component: () => import("../views/NewActivityView.vue"),
import( },
/* webpackChunkName: "new-edit-commitment" */ "../views/NewEditCommitmentView.vue" {
), path: "/new-edit-account",
name: "new-edit-account",
component: () => import("../views/NewEditAccountView.vue"),
}, },
{ {
path: "/new-edit-project", path: "/new-edit-project",
name: "new-edit-project", name: "new-edit-project",
component: () => component: () => import("../views/NewEditProjectView.vue"),
import(
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
),
}, },
{ {
path: "/new-identifier", path: "/new-identifier",
name: "new-identifier", name: "new-identifier",
component: () => component: () => import("../views/NewIdentifierView.vue"),
import( },
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue" {
), path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
}, },
{ {
path: "/project/:id?", path: "/project/:id?",
name: "project", name: "project",
component: () => component: () => import("../views/ProjectViewView.vue"),
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
}, },
{ {
path: "/projects", path: "/projects",
name: "projects", name: "projects",
component: () => component: () => import("../views/ProjectsView.vue"),
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
beforeEnter: enterOrStart, beforeEnter: enterOrStart,
}, },
{
path: "/quick-action-bvc",
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: "/recent-offers-to-user",
name: "recent-offers-to-user",
component: () => import("../views/RecentOffersToUserView.vue"),
},
{
path: "/recent-offers-to-user-projects",
name: "recent-offers-to-user-projects",
component: () => import("../views/RecentOffersToUserProjectsView.vue"),
},
{ {
path: "/scan-contact", path: "/scan-contact",
name: "scan-contact", name: "scan-contact",
component: () => component: () => import("../views/ContactScanView.vue"),
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
}, },
{ {
path: "/search-area", path: "/search-area",
name: "search-area", name: "search-area",
component: () => component: () => import("../views/SearchAreaView.vue"),
import(
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
),
}, },
{ {
path: "/seed-backup", path: "/seed-backup",
name: "seed-backup", name: "seed-backup",
component: () => component: () => import("../views/SeedBackupView.vue"),
import(
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
),
}, },
{
path: "/share-my-contact-info",
name: "share-my-contact-info",
component: () => import("@/views/ShareMyContactInfoView.vue"),
},
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
// /share-target is also an endpoint in the service worker
{ {
path: "/start", path: "/start",
name: "start", name: "start",
component: () => component: () => import("../views/StartView.vue"),
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
}, },
{ {
path: "/statistics", path: "/statistics",
name: "statistics", name: "statistics",
component: () => component: () => import("../views/StatisticsView.vue"),
import(
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
),
}, },
{ {
path: "/test", path: "/test",
name: "test", name: "test",
component: () => component: () => import("../views/TestView.vue"),
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
}, },
]; ];
/** @type {*} */ /** @type {*} */
const router = createRouter({ const router = createRouter({
history: createWebHistory(process.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes, routes,
}); });

20
src/store/app.ts

@ -1,20 +0,0 @@
// @ts-check
import { defineStore } from "pinia";
export const useAppStore = defineStore({
id: "app",
state: () => ({
_projectId:
typeof localStorage.getItem("projectId") === "undefined"
? ""
: localStorage.getItem("projectId"),
}),
getters: {
projectId: (state): string => state._projectId as string,
},
actions: {
async setProjectId(newProjectId: string) {
localStorage.setItem("projectId", newProjectId);
},
},
});

17
src/test/index.ts

@ -1,11 +1,13 @@
import axios from "axios"; import axios from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app"; import { AppString } from "@/constants/app";
import { db } from "../db"; import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "../libs/endorserServer"; import { SERVICE_ID } from "@/libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto"; import { deriveAddress, newIdentifier } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
/**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/
export async function testServerRegisterUser() { export async function testServerRegisterUser() {
const testUser0Mnem = const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control"; "seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";
@ -14,8 +16,7 @@ export async function testServerRegisterUser() {
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath); const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
// Make a claim // Make a claim
const vcClaim = { const vcClaim = {
@ -23,7 +24,7 @@ export async function testServerRegisterUser() {
"@type": "RegisterAction", "@type": "RegisterAction",
agent: { did: identity0.did }, agent: { did: identity0.did },
object: SERVICE_ID, object: SERVICE_ID,
participant: { did: settings?.activeDid }, participant: { did: settings.activeDid },
}; };
// Make a payload for the claim // Make a payload for the claim
const vcPayload = { const vcPayload = {
@ -50,7 +51,7 @@ export async function testServerRegisterUser() {
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer = const endorserApiServer =
settings?.apiServer || AppString.TEST_ENDORSER_API_SERVER; settings.apiServer || AppString.TEST_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/claim"; const url = endorserApiServer + "/api/claim";
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",

1
src/util.d.ts

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

1189
src/views/AccountViewView.vue

File diff suppressed because it is too large

97
src/views/ClaimAddRawView.vue

@ -0,0 +1,97 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
Raw Claim
</h1>
</div>
<div class="flex">
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
</div>
<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="submitClaim()"
>
Sign &amp; Send
</button>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import * as serverUtil from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
@Component({
components: { QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
apiServer = "";
claimStr = "";
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.claimStr = (this.$route as Router).query["claim"];
try {
this.veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(this.veriClaim, null, 2);
} catch (e) {
// ignore a parse
}
}
async submitClaim() {
const fullClaim = JSON.parse(this.claimStr);
const result = await serverUtil.createAndSubmitClaim(
fullClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Claim submitted.",
},
5000,
);
} else {
console.error("Got error submitting the claim:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the claim.",
},
-1,
);
}
}
}
</script>

726
src/views/ClaimView.vue

@ -10,7 +10,7 @@
@click="$router.go(-1)" @click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw" />
</button> </button>
Verifiable Claim Details Verifiable Claim Details
</h1> </h1>
@ -18,34 +18,181 @@
<!-- Details --> <!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<div class="block flex gap-4 overflow-hidden"> <div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden"> <div class="overflow-hidden">
<h2 class="text-md font-bold">{{ veriClaim.id }}</h2> <h2 class="text-md font-bold">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button
v-if="
['GiveAction', 'Offer'].includes(
veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
"
@click="onClickEditClaim"
title="Edit"
data-testId="editClaimButton"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2>
<div class="text-sm"> <div class="text-sm">
<div> <div data-testId="description">
{{ veriClaim.claimType }} <fa icon="message" class="fa-fw text-slate-400" />
{{
veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.description
}}
</div> </div>
<div> <div>
<fa icon="message" class="fa-fw text-slate-400"></fa> <fa icon="user" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }} {{ didInfo(veriClaim.issuer) }}
</div> </div>
<div> <div>
<fa icon="user" class="fa-fw text-slate-400"></fa> <fa icon="calendar" class="fa-fw text-slate-400" />
{{ veriClaim.issuer }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }} {{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div> </div>
<div v-if="veriClaim.claim.image" class="flex justify-center">
<a :href="veriClaim.claim.image" target="_blank">
<img :src="veriClaim.claim.image" class="h-24 rounded-xl" />
</a>
</div> </div>
<div v-if="veriClaim.claimType === 'PlanAction'" class="mt-4">
<router-link
:to="'/project/' + encodeURIComponent(veriClaim.handleId)"
class="text-blue-500 mt-2"
>
Go to Project page
</router-link>
</div> </div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId" class="mt-4">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
Fulfills a bigger plan...
</router-link>
</div> </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 cursor-pointer"
>
Fulfills
{{
capitalizeAndInsertSpacesBeforeCaps(
detailsForGive.fulfillsType,
)
}}...
</a>
</div> </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> <!-- Providers -->
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> <div v-if="providersForGive?.length > 0" class="mt-4">
<span>Other assistance provided by:</span>
<ul class="ml-4">
<li
v-for="provider of providersForGive"
:key="provider.identifier"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
@click="
provider.identifier.startsWith('did:')
? this.$router.push(
'/did/' +
encodeURIComponent(provider.identifier),
)
: showDifferentClaimPage(provider.identifier)
"
class="text-blue-500 mt-4 cursor-pointer"
>
an activity...
</a>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8">
<button
v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()"
class="col-span-1 block w-fit text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Affirm Delivery
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
</button>
</div>
<GiftedDialog ref="customGiveDialog" />
<div v-if="libsUtil.isGiveAction(veriClaim)">
<div class="flex columns-3">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<h2 v-else class="font-bold uppercase text-xl mt-2">Confirmations</h2>
<span class="mt-0.5 px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
data-testId="confirmGiftLink"
>
Details...
</router-link>
</span>
</div>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> <span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1"> <span v-else-if="totalConfirmers() === 1">
@ -71,16 +218,28 @@
</div> </div>
<div v-if="confirmerIdList.length > 0"> <div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim. The following people have issued or confirmed this claim.
<ul> <ul class="ml-4">
<li <li
v-for="confirmerId in confirmerIdList" v-for="confirmerId in confirmerIdList"
:key="confirmerId" :key="confirmerId"
class="list-disc" class="list-disc ml-4"
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<div class="text-sm"> <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> </div>
</div> </div>
@ -89,25 +248,39 @@
</div> </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 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. 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"> <div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who have issued or The following people can connect you with people who have issued or
confirmed this claim. confirmed this claim.
<ul> <ul class="ml-4">
<li <li
v-for="confsVisibleTo in confsVisibleToIdList" v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo" :key="confsVisibleTo"
class="list-disc" class="list-disc ml-4"
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<div class="text-sm"> <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> </div>
</div> </div>
@ -116,29 +289,134 @@
</div> </div>
</div> </div>
<div class="mt-4"> <!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="confirmerIdList.includes(activeDid)"> <div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim. You have confirmed this claim.
</div> </div>
<div v-else-if="containsHiddenDid(veriClaim.claim)"> <div v-else-if="veriClaim.issuer == activeDid">
You cannot confirm this claim because it contains data that is hidden You cannot confirm this because you issued this claim, so you already
from you. count as confirming it.
</div> </div>
<div v-else> <div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<!-- Note that a similar section is found in ConfirmGiftView.vue -->
<div>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
</h2>
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN". They
are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction. They are surely
connected to someone; if you don't know who to ask, you might try the
person who registered you.
</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('A link to this page', 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('A link to this page', windowLocation)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button <button
class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4" @click="copyToClipboard('The DID of ' + visDid, visDid)"
@click="confirmClaim(veriClaim.id)"
> >
Confirm Claim <fa icon="copy" class="text-slate-400 fa-fw" />
</button> </button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div> </div>
</li>
</ul>
</div> </div>
</div> </div>
</div>
<div> <span v-if="isEditedGlobalId" class="mt-2">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Claim</h2> This record is an edited version. The latest version is here.
</span>
<br />
<button @click="showVeriClaimDump = !showVeriClaimDump" class="ml-2">
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre <pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre >{{ veriClaimDump }}</pre
> >
</div> </div>
@ -154,20 +432,23 @@
</p> </p>
<button <button
v-else v-else
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2" class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
@click="showFullClaim(veriClaim.id)" @click="showFullClaim(veriClaim.id as string)"
> >
Load Full Claim Details Load Full Claim Details
</button> </button>
</div> </div>
<div v-else> <div v-else>
<pre>{{ fullClaimDump }}</pre> <pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div> </div>
<a <a
:href="apiServer + '/api/claim/' + veriClaim.id" :href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank" target="_blank"
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2" class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
> >
View on the Public Server View on the Public Server
</a> </a>
@ -175,74 +456,101 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError } from "axios";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import * as util from "util";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; 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 serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import {
interface Notification { GenericCredWrapper,
group: string; OfferVerifiableCredential,
type: string; } from "@/libs/endorserServer";
title: string;
text: string; interface ProviderInfo {
identifier: string; // could be a DID or a handleId
linkConfirmed: boolean;
} }
@Component({ @Component({
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav }, components: { GiftedDialog, QuickNav },
}) })
export default class ClaimView extends Vue { export default class ClaimView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
apiServer = ""; 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 = ""; 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; fullClaim = null;
fullClaimDump = ""; fullClaimDump = "";
fullClaimMessage = ""; fullClaimMessage = "";
isEditedGlobalId = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
providersForGive: ProviderInfo[] = [];
showIdCopy = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location.href;
util = util; R = R;
yaml = yaml; 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.isEditedGlobalId = false;
this.numConfsNotVisible = 0;
this.providersForGive = [];
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
this.veriClaimDidsVisible = {};
}
async created() { async created() {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.activeDid = settings.activeDid || "";
this.activeDid = settings?.activeDid || ""; this.apiServer = settings.apiServer || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = settings.isRegistered || false;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray(); const accountsArr: Array<Account> = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did); this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const pathParam = window.location.pathname.substring("/claim/".length); const pathParam = window.location.pathname.substring("/claim/".length);
let claimId; let claimId;
if (pathParam) { if (pathParam) {
claimId = decodeURIComponent(pathParam); claimId = decodeURIComponent(pathParam);
this.loadClaim(claimId, identity); await this.loadClaim(claimId, this.activeDid);
} else { } else {
this.$notify( this.$notify(
{ {
@ -254,6 +562,18 @@ export default class ClaimView extends Vue {
-1, -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() { totalConfirmers() {
@ -264,91 +584,130 @@ export default class ClaimView extends Vue {
); );
} }
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identity available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
// Isn't there a better way to make this available to the template? // Isn't there a better way to make this available to the template?
didInfo( didInfo(did: string) {
did: string, return serverUtil.didInfo(
activeDid: string, did,
dids: Array<string>, this.activeDid,
contacts: Array<Contact>, this.allMyDids,
) { this.allContacts,
return serverUtil.didInfo(did, activeDid, dids, contacts); );
} }
async loadClaim(claimId: string, identity: IIdentifier) { async loadClaim(claimId: string, userDid: string) {
const url = const urlPath = libsUtil.isGlobalUri(claimId)
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(claimId); ? "/api/claim/byHandle/"
const headers = await this.getHeaders(identity); : "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
const headers = await serverUtil.getHeaders(userDid);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
this.veriClaim = resp.data; this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim); this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
} else { } else {
// actually, axios typically throws an error so we never get here // 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( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was a problem getting that claim. See logs for more info.", text: "There was a problem retrieving that claim.",
}, },
-1, -1,
); );
return;
} }
} catch (error: unknown) {
const serverError = error as AxiosError; this.isEditedGlobalId = !this.veriClaim.handleId.endsWith(claimId);
console.error("Error retrieving claim:", serverError);
// 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 serverUtil.getHeaders(userDid);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
if (giveResp.status === 200 && giveResp.data.data?.length > 0) {
this.detailsForGive = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
}
// look for providers
const providerUrl =
this.apiServer +
"/api/v2/report/providersToGive?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const providerHeaders = await serverUtil.getHeaders(userDid);
const providerResp = await this.axios.get(providerUrl, {
headers: providerHeaders,
});
// should be at least an empty array
if (
providerResp.status === 200 &&
Array.isArray(providerResp.data.data)
) {
this.providersForGive = providerResp.data.data;
} else {
console.error("Error getting give providers:", giveResp);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "warning",
title: "Error", title: "Error",
text: "Something went wrong retrieving that claim. See logs for more info.", text: "Got error retrieving linked provider data.",
}, },
-1, -1,
); );
} }
} else if (this.veriClaim.claimType === "Offer") {
const offerUrl =
this.apiServer +
"/api/v2/report/offers?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const offerHeaders = await serverUtil.getHeaders(userDid);
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);
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "Got error retrieving linked offer data.",
},
-1,
);
}
}
// retrieve the list of confirmers
const confirmUrl = const confirmUrl =
this.apiServer + this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + "/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await this.getHeaders(identity); const confirmHeaders = await serverUtil.getHeaders(userDid);
try {
const response = await this.axios.get(confirmUrl, { const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders, headers: confirmHeaders,
}); });
if (response.status === 200) { if (response.status === 200) {
const resultList1 = response.data.result || []; const resultList1 = response.data.result || [];
//const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls;
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject( const resultList3 = R.reject(
(did: string) => did === this.veriClaim.issuer, (did: string) => did === this.veriClaim.issuer,
@ -369,22 +728,23 @@ export default class ClaimView extends Vue {
} }
} catch (error: unknown) { } catch (error: unknown) {
const serverError = error as AxiosError; const serverError = error as AxiosError;
console.error("Error retrieving confirmations:", serverError); console.error("Error retrieving claim:", serverError);
this.confsVisibleErrorMessage = this.$notify(
"Had problems retrieving confirmations. See logs for more info."; {
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
-1,
);
} }
} }
async showFullClaim(claimId: string) { async showFullClaim(claimId: string) {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const url = const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId); this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
const headers = await this.getHeaders(identity); const headers = await serverUtil.getHeaders(this.activeDid);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
@ -393,13 +753,13 @@ export default class ClaimView extends Vue {
this.fullClaimDump = yaml.dump(this.fullClaim); this.fullClaimDump = yaml.dump(this.fullClaim);
} else { } else {
// actually, axios typically throws an error so we never get here // 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( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was a problem getting that claim. See logs for more info.", text: "There was a problem getting that claim.",
}, },
-1, -1,
); );
@ -421,7 +781,7 @@ export default class ClaimView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Something went wrong retrieving that claim. See logs for more info.", text: "Something went wrong retrieving that claim.",
}, },
-1, -1,
); );
@ -429,8 +789,23 @@ export default class ClaimView extends Vue {
} }
} }
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() { async confirmClaim() {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile // similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext( const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids( serverUtil.removeVisibleToDids(
@ -441,17 +816,14 @@ export default class ClaimView extends Vue {
), ),
), ),
); );
const confirmationClaim: serverUtil.GenericVerifiableCredential & { const confirmationClaim: serverUtil.GenericVerifiableCredential = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any;
} = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "AgreeAction", "@type": "AgreeAction",
object: goodClaim, object: goodClaim,
}; };
const result = await serverUtil.createAndSubmitClaim( const result = await serverUtil.createAndSubmitClaim(
confirmationClaim, confirmationClaim,
await this.getIdentity(this.activeDid), this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
@ -466,18 +838,104 @@ export default class ClaimView extends Vue {
5000, 5000,
); );
} else { } else {
console.log("Got error submitting the confirmation:", result); console.error("Got error submitting the confirmation:", result);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.", text: "There was a problem submitting the confirmation.",
}, },
-1, -1,
); );
} }
} }
showDifferentClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
});
}
openFulfillGiftDialog() {
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>,
),
};
console.log("giver & dialog", giver, this.$refs.customGiveDialog);
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.veriClaim.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation,
});
}
onClickEditClaim() {
if (this.veriClaim.claimType === "GiveAction") {
const route = {
name: "gifted-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else if (this.veriClaim.claimType === "Offer") {
const route = {
name: "offer-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else {
console.error(
"Unrecognized claim type for edit:",
this.veriClaim.claimType,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "This is an unrecognized claim type.",
},
3000,
);
}
} }
} }
</script> </script>

6
src/views/ConfirmContactView.vue

@ -30,18 +30,20 @@
</div> </div>
<div class="mt-8"> <div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input <input
type="submit" 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" 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" value="Add Contact"
/> />
<button <button
type="button" type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
</section> </section>
</template> </template>

871
src/views/ConfirmGiftView.vue

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

131
src/views/ContactAmountsView.vue

@ -16,7 +16,7 @@
</h1> </h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Given with {{ contact?.name }} Transferred with {{ contact?.name }}
</h1> </h1>
</div> </div>
@ -55,9 +55,9 @@
{{ new Date(record.issuedAt).toLocaleString() }} {{ new Date(record.issuedAt).toLocaleString() }}
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid == contact.did"> <span v-if="record.agentDid == contact?.did">
<div class="font-bold"> <div class="font-bold">
{{ record.amount }} {{ record.unit }} {{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" /> <fa icon="circle-check" class="text-green-600 fa-fw" />
</span> </span>
@ -71,7 +71,7 @@
</span> </span>
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid == contact.did"> <span v-if="record.agentDid == contact?.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" /> <fa icon="arrow-left" class="text-slate-400 fa-fw" />
</span> </span>
<span v-else> <span v-else>
@ -79,9 +79,9 @@
</span> </span>
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid != contact.did"> <span v-if="record.agentDid != contact?.did">
<div class="font-bold"> <div class="font-bold">
{{ record.amount }} {{ record.unit }} {{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" /> <fa icon="circle-check" class="text-green-600 fa-fw" />
</span> </span>
@ -105,78 +105,48 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, AxiosRequestHeaders } from "axios";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db/index"; import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { import {
AgreeVerifiableCredential, AgreeVerifiableCredential,
GiveServerRecord, createEndorserJwtVcFromClaim,
displayAmount,
getHeaders,
GiveSummaryRecord,
GiveVerifiableCredential, GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT, SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer"; } 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 } }) @Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue { export default class ContactAmountssView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
contact: Contact | null = null; contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = []; giveRecords: Array<GiveSummaryRecord> = [];
numAccounts = 0; numAccounts = 0;
displayAmount = displayAmount;
async beforeCreate() { async beforeCreate() {
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
} }
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 Give records with no identity available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() { async created() {
try { try {
await db.open(); const contactDid = (this.$route as Router).query["contactDid"] as string;
const contactDid = this.$route.query.contactDid as string;
this.contact = (await db.contacts.get(contactDid)) || null; this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
@ -185,7 +155,7 @@ export default class ContactAmountssView extends Vue {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
console.log("Error retrieving settings or gives.", err); console.error("Error retrieving settings or gives.", err);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -193,7 +163,7 @@ export default class ContactAmountssView extends Vue {
title: "Error", title: "Error",
text: text:
err.userMessage || 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, -1,
); );
@ -202,15 +172,14 @@ export default class ContactAmountssView extends Vue {
async loadGives(activeDid: string, contact: Contact) { async loadGives(activeDid: string, contact: Contact) {
try { try {
const identity = await this.getIdentity(this.activeDid); let result: Array<GiveSummaryRecord> = [];
let result: Array<GiveServerRecord> = [];
const url = const url =
this.apiServer + this.apiServer +
"/api/v2/report/gives?agentDid=" + "/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did) + encodeURIComponent(this.activeDid) +
"&recipientDid=" + "&recipientDid=" +
encodeURIComponent(contact.did); encodeURIComponent(contact.did);
const headers = await this.getHeaders(identity); const headers = await getHeaders(activeDid);
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
result = resp.data.data; result = resp.data.data;
@ -236,8 +205,8 @@ export default class ContactAmountssView extends Vue {
"/api/v2/report/gives?agentDid=" + "/api/v2/report/gives?agentDid=" +
encodeURIComponent(contact.did) + encodeURIComponent(contact.did) +
"&recipientDid=" + "&recipientDid=" +
encodeURIComponent(identity.did); encodeURIComponent(this.activeDid);
const headers2 = await this.getHeaders(identity); const headers2 = await getHeaders(activeDid);
const resp2 = await this.axios.get(url2, { headers: headers2 }); const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) { if (resp2.status === 200) {
result = R.concat(result, resp2.data.data); result = R.concat(result, resp2.data.data);
@ -258,7 +227,7 @@ export default class ContactAmountssView extends Vue {
); );
} }
const sortedResult: Array<GiveServerRecord> = R.sort( const sortedResult: Array<GiveSummaryRecord> = R.sort(
(a, b) => (a, b) =>
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(), new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
result, result,
@ -277,7 +246,7 @@ export default class ContactAmountssView extends Vue {
} }
} }
async confirm(record: GiveServerRecord) { async confirm(record: GiveSummaryRecord) {
// Make claim // Make claim
// I use clone here because otherwise it gets a Proxy object. // I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -292,45 +261,24 @@ export default class ContactAmountssView extends Vue {
object: origClaim, object: origClaim,
}; };
// Make a payload for the claim const vcJwt: string = await createEndorserJwtVcFromClaim(
const vcPayload = { this.activeDid,
vc: { vcClaim,
"@context": ["https://www.w3.org/2018/credentials/v1"], );
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
const identity = await this.getIdentity(this.activeDid);
if (identity.keys[0].privateKeyHex !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim"; const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity); const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success) { if (resp.data?.success) {
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1; record.amountConfirmed =
(origClaim.object?.amountOfThisGood as number) || 1;
} }
} catch (error) { } catch (error) {
let userMessage = "There was an error. See logs for more info."; let userMessage = "There was an error.";
const serverError = error as AxiosError; const serverError = error as AxiosError;
if (serverError) { if (serverError) {
if (serverError.message) { if (serverError.message) {
@ -353,7 +301,6 @@ export default class ContactAmountssView extends Vue {
); );
} }
} }
}
cannotConfirmMessage() { cannotConfirmMessage() {
this.$notify( this.$notify(

125
src/views/ContactGiftingView.vue

@ -11,8 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa ><fa icon="chevron-left" class="fa-fw"></fa
></router-link> ></router-link>
Given by...
Give to Contacts
</h1> </h1>
</div> </div>
@ -20,19 +19,19 @@
<ul class="border-t border-slate-300"> <ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3"> <li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow italic text-slate-500" <span class="grow">
><EntityIcon <img
:entityId="null" src="../assets/blank-square.svg"
:iconSize="32" width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-md mr-1"
></EntityIcon> />
Anonymous Unnamed/Unknown
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
type="button" type="button"
@click="openDialog()" @click="openDialog()"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md" class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
> >
<fa icon="gift" class="fa-fw"></fa> <fa icon="gift" class="fa-fw"></fa>
</button> </button>
@ -45,19 +44,19 @@
class="border-b border-slate-300 py-3" class="border-b border-slate-300 py-3"
> >
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold" <span class="grow font-semibold">
><EntityIcon <EntityIcon
:entityId="contact.did" :contact="contact"
:iconSize="32" :iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-md mr-1"
></EntityIcon> />
{{ contact.name || "(no name)" }} {{ contact.name || "(no name)" }}
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
type="button" type="button"
@click="openDialog(contact)" @click="openDialog(contact)"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md" class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
> >
<fa icon="gift" class="fa-fw"></fa> <fa icon="gift" class="fa-fw"></fa>
</button> </button>
@ -66,86 +65,55 @@
</li> </li>
</ul> </ul>
<GiftedDialog <GiftedDialog ref="customDialog" :projectId="projectId" />
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverInputInfo } from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import { IIdentifier } from "@veramo/core"; import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
interface Notification { import { Contact } from "@/db/tables/contacts";
group: string; import { GiverReceiverInputInfo } from "@/libs/util";
type: string;
title: string;
text: string;
}
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { GiftedDialog, QuickNav, EntityIcon },
}) })
export default class ContactGiftingView extends Vue { export default class ContactGiftingView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
apiServer = ""; apiServer = "";
accounts: typeof AccountsSchema; description = "";
numAccounts = 0; projectId = "";
prompt = "";
async beforeCreate() { async created() {
accountsDB.open(); try {
this.numAccounts = await accountsDB.accounts.count(); const settings = await retrieveSettingsForActiveAccount();
} this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
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) { // .orderBy("name") wouldn't retrieve any entries with a blank name
throw new Error( // .toCollection.sortBy("name") didn't sort in an order I understood
"Attempted to load Give records with no identity available.", const baseContacts = await db.contacts.toArray();
this.allContacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
); );
}
return identity;
}
public async getHeaders(identity: IIdentifier) { this.projectId = (this.$route as Router).query["projectId"] || "";
const token = await accessToken(identity);
const headers = { this.prompt = (this.$route as Router).query["prompt"] ?? this.prompt;
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
console.log("Error retrieving settings & contacts:", err); console.error("Error retrieving settings & contacts:", err);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -153,15 +121,24 @@ export default class ContactGiftingView extends Vue {
title: "Error", title: "Error",
text: text:
err.message || err.message ||
"There was an error retrieving your settings and/or contacts.", "There was an error retrieving your settings or contacts.",
}, },
-1, 5000,
); );
} }
} }
openDialog(giver: GiverInputInfo) { openDialog(giver?: GiverReceiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver); const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
this.prompt,
);
} }
} }
</script> </script>

233
src/views/ContactImportView.vue

@ -0,0 +1,233 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<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">
Contact Import
</h1>
<span class="flex justify-center">
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div>
<!-- Results List -->
<ul v-if="contactsImporting.length > 0" class="border-t border-slate-300">
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[contact.did]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else>There are no contacts to import.</p>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { setVisibilityUtil } from "@/libs/endorserServer";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
})
export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
libsUtil = libsUtil;
R = R;
activeDid = "";
apiServer = "";
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<string, { new: string; old: string }>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false;
makeVisible = true;
sameCount = 0;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
// Retrieve the imported contacts from the query parameter
const importedContacts =
((this.$route as Router).query["contacts"] as string) || "[]";
this.contactsImporting = JSON.parse(importedContacts);
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open();
const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find(
(contact) => contact.did === contactIn.did,
);
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<string, { new: string; old: string }> = {};
Object.keys(contactIn).forEach((key) => {
if (contactIn[key] !== existingContact[key]) {
differences[key] = {
old: existingContact[key],
new: contactIn[key],
};
}
});
this.contactDifferences[contactIn.did] = differences;
if (R.isEmpty(differences)) {
this.sameCount++;
}
// don't automatically import previous data
this.contactsSelected[i] = false;
}
}
}
async importContacts() {
this.importing = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
const existingContact = this.contactsExisting[contact.did];
if (existingContact) {
await db.contacts.update(contact.did, contact);
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
await db.contacts.add(R.clone(contact));
importedCount++;
}
}
}
if (this.makeVisible) {
const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) {
const contact = this.contactsImporting[i];
if (contact) {
const visResult = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
true,
);
if (!visResult.success) {
failedVisibileToContacts.push(contact);
}
}
}
if (failedVisibileToContacts.length) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Visibility Error",
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
failedVisibileToContacts.length == 1 ? "" : "s"
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
},
-1,
);
}
}
this.importing = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Imported",
text:
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
(updatedCount ? ` ${updatedCount} updated.` : ""),
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
}
</script>

398
src/views/ContactQRScanShowView.vue

@ -1,5 +1,5 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav selected="Profile" />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()" @click="$router.back()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw" />
</h1> </h1>
</div> </div>
@ -18,19 +18,30 @@
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info Your Contact Info
</h1> </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> <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 <br />
:to="{ name: 'new-edit-account' }" <span
class="bg-blue-500 text-white px-1.5 py-1 rounded-md" @click="
() => $refs.userNameDialog.open((name) => (this.givenName = name))
"
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> </span>
</p> </p>
</div> </div>
<UserNameDialog ref="userNameDialog" />
<div @click="onCopyToClipboard()" v-if="activeDid"> <div
@click="onCopyUrlToClipboard()"
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
>
<!-- <!--
Play with display options: https://qr-code-styling.com/ Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3 See docs: https://www.npmjs.com/package/qr-code-generator-vue3
@ -41,6 +52,18 @@
:dotsOptions="{ type: 'square' }" :dotsOptions="{ type: 'square' }"
class="flex justify-center" class="flex justify-center"
/> />
<span>
Click the QR code to copy your contact info to your clipboard.
</span>
</div>
<div v-else-if="activeDid" class="text-center">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span @click="onCopyDidToClipboard()" class="text-blue-500">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
</div> </div>
<div class="text-center" v-else> <div class="text-center" v-else>
You have no identitifiers yet, so You have no identitifiers yet, so
@ -55,7 +78,7 @@
</div> </div>
<div class="text-center"> <div class="text-center">
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1> <h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" /> <qrcode-stream @detect="onScanDetect" @error="onScanError" />
<span> <span>
If you do not see a scanning camera window here, check your camera If you do not see a scanning camera window here, check your camera
@ -66,153 +89,321 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import * as didJwt from "did-jwt"; import { AxiosError } from "axios";
import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader"; import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts"; import UserNameDialog from "@/components/UserNameDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
import { import {
CONTACT_URL_PREFIX, generateEndorserJwtForAccount,
ENDORSER_JWT_URL_LOCATION, isDid,
register,
setVisibilityUtil,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ @Component({
components: { components: {
QrcodeStream, QrcodeStream,
QRCodeVue3, QRCodeVue3,
QuickNav, QuickNav,
UserNameDialog,
}, },
}) })
export default class ContactQRScanShow extends Vue { export default class ContactQRScanShow extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
givenName = ""; givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = ""; qrValue = "";
public async getIdentity(activeDid: string) { ETHR_DID_PREFIX = ETHR_DID_PREFIX;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identity available.",
);
}
return identity;
}
async created() { async created() {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); this.activeDid = settings.activeDid || "";
this.activeDid = settings?.activeDid || ""; this.apiServer = settings.apiServer || "";
this.apiServer = settings?.apiServer || ""; this.givenName = settings.firstName || "";
this.givenName = settings?.firstName || ""; this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts); const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (account) { if (account) {
const identity = await this.getIdentity(this.activeDid); const name =
const publicKeyHex = identity.keys[0].publicKeyHex; (settings.firstName || "") +
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); (settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
own: {
name:
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
},
};
const alg = undefined; this.qrValue = await generateEndorserJwtForAccount(
const privateKeyHex: string = identity.keys[0].privateKeyHex; account,
const signer = await SimpleSigner(privateKeyHex); !!settings.isRegistered,
// create a JWT for the request name,
const vcJwt: string = await didJwt.createJWT(contactInfo, { settings.profileImageUrl,
alg: alg, false,
issuer: identity.did, );
signer: signer,
});
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt;
} }
} }
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
timeout,
);
}
/** /**
* *
* @param content is the result of a QR scan, an array with one item with a rawValue property * @param content is the result of a QR scan, an array with one item with a rawValue property
*/ */
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet. // Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) { async onScanDetect(content: any) {
if (content[0]?.rawValue) { const url = content[0]?.rawValue;
//console.log("onDetect", content[0].rawValue); if (url) {
localStorage.setItem("contactEndorserUrl", content[0].rawValue); let newContact: Contact;
this.$router.push({ name: "contacts" }); try {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
newContact = {
did: payload.iss as string,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
} catch (e) {
console.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
return;
}
try {
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
console.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
}
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "danger",
title: "Invalid Contact QR Code", title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.", text: "No QR code detected with contact information.",
}, },
-1, 5000,
);
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
console.error("Got strange result from setting visibility:", result);
}
}
async register(contact: Contact) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
); );
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) { onScanError(error: any) {
console.log("Scan was invalid:", error); console.error("Scan was invalid:", error);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "danger",
title: "Invalid Scan", title: "Invalid Scan",
text: "The scan was invalid.", text: "The scan was invalid.",
}, },
-1, 5000,
); );
} }
onCopyToClipboard() { onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard() useClipboard()
.copy(this.qrValue) .copy(this.qrValue)
.then(() => { .then(() => {
@ -228,5 +419,22 @@ export default class ContactQRScanShow extends Vue {
); );
}); });
} }
onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.activeDid)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
},
5000,
);
});
}
} }
</script> </script>

6
src/views/ContactScanView.vue

@ -65,18 +65,20 @@
/> />
<div class="mt-8"> <div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input <input
type="submit" 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" 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" value="Look Up Contact"
/> />
<button <button
type="button" type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
</section> </section>
</template> </template>

1474
src/views/ContactsView.vue

File diff suppressed because it is too large

753
src/views/DIDView.vue

@ -0,0 +1,753 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Identifier Details
</h1>
</div>
<!-- Identity Details -->
<div
v-if="!!contactFromDid"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div>
<h2 class="text-xl font-semibold">
{{ contactFromDid?.name || "(no name)" }}
<button
@click="
contactEdit = true;
contactNewName = (contactFromDid?.name as string) || '';
"
title="Edit"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2>
<button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
>
Details
<fa v-if="showDidDetails" icon="chevron-down" class="text-blue-400" />
<fa v-else icon="chevron-right" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showDidDetails"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ contactYaml }}</pre
>
</div>
<div class="flex justify-center mt-4">
<span
v-if="contactFromDid?.profileImageUrl"
class="flex justify-between"
>
<EntityIcon
:icon-size="96"
:profileImageUrl="contactFromDid?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contactFromDid?.profileImageUrl"
/>
</span>
</div>
<div class="flex justify-between mt-4">
<div class="flex items-center">
<div v-if="activeDid" class="flex justify-between">
<div>
<button
v-if="
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
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="confirmSetVisibility(contactFromDid, false)"
title="They can see you"
>
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
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="confirmSetVisibility(contactFromDid, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<button
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(contactFromDid)"
title="Check Visibility"
v-if="contactFromDid?.did !== activeDid"
>
<fa icon="rotate" class="fa-fw" />
</button>
</div>
<button
@click="confirmRegister(contactFromDid)"
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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
v-if="contactFromDid?.did !== activeDid"
title="Registration"
>
<fa
v-if="contactFromDid?.registered"
icon="person-circle-check"
class="fa-fw"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
</div>
<button
@click="confirmDeleteContact(contactFromDid)"
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-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<div v-if="!contactFromDid?.profileImageUrl">
<div>Auto-Generated Icon</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
</div>
</div>
</div>
<div
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
class="fixed z-[100] top-0 inset-x-0 w-full"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
showLargeIdenticonUrl = undefined;
"
/>
</div>
</div>
</div>
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<!-- !contactFromDid -->
<div>
<h2 class="text-xl font-semibold">
{{ isMyDid ? "You" : "(no name)" }}
</h2>
</div>
</div>
<!-- Edit Name Dialog, maybe should be replaced by ContactNameDialog -->
<div v-if="contactEdit" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<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(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>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">
Claims That Involve {{ isMyDid ? "You" : "Them" }}
</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="claim in claims"
:key="claim.handleId"
>
<div class="grid grid-cols-12 gap-4">
<span class="col-span-2">
{{ claim.issuedAt.substring(0, 10) }}
</span>
<span class="col-span-2">
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
</span>
<span class="col-span-2">
{{ claimAmount(claim) }}
</span>
<span class="col-span-5">
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a @click="onClickLoadClaim(claim.id)" class="cursor-pointer">
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</span>
</div>
</li>
</ul>
</InfiniteScroll>
<div
v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4"
>
<span v-if="isMyDid">You have no claims yet.</span>
<span v-else>They are in no claims visible to you.</span>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox } from "@/db/tables/settings";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
TopMessage,
},
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
yaml = yaml;
activeDid = "";
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contactFromDid?: Contact;
contactEdit = false;
contactNewName: string = "";
contactYaml = "";
hitEnd = false;
isLoading = false;
isMyDid = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showDidDetails = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contactFromDid = await db.contacts.get(this.viewingDid);
if (this.contactFromDid) {
this.contactYaml = yaml.dump(this.contactFromDid);
}
await this.loadClaimsAbout();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
for (const account of allAccounts) {
if (account.did === this.viewingDid) {
this.isMyDid = true;
break;
}
}
}
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
// prompt with confirmation if they want to delete a contact
confirmDeleteContact(contact: Contact) {
let message =
"Are you sure you want to remove " +
libsUtil.nameForContact(contact, false) +
" from your contact list?";
if (contact.seesMe) {
message +=
" Note that they can see your activity, so if you want to hide your activity from them then you should do that first.";
}
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete",
text: message,
onYes: async () => {
await this.deleteContact(contact);
},
},
-1,
);
}
async deleteContact(contact: Contact) {
await db.open();
await db.contacts.delete(contact.did);
this.$notify(
{
group: "alert",
type: "success",
title: "Deleted",
text: "Contact has been removed.",
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
// confirm to register a new contact
async confirmRegister(contact: Contact) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text:
"Are you sure you want to register " +
libsUtil.nameForContact(this.contactFromDid, false) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
onYes: async () => {
await this.register(contact);
},
},
-1,
);
}
// note that this is also in ContactView.vue
async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
await db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
);
}
}
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
return;
}
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
let postfix = "";
if (this.claims.length > 0) {
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
},
5000,
);
return;
}
const results = await response.json();
this.claims = this.claims.concat(results.data);
this.hitEnd = !results.hitLimit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
(this.$router as Router).push(route);
}
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
return displayAmount(
giveClaim.object.unitCode,
giveClaim.object.amountOfThisGood,
);
} else {
return "";
}
} else if (claim.claimType === "Offer") {
const offerClaim = claim.claim as OfferVerifiableCredential;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood
) {
return displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
} else {
return "";
}
}
return "";
}
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
private async onClickCancelName() {
this.contactEdit = false;
}
private async onClickSaveName(newName: string) {
if (!this.contactFromDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not A Contact",
text: "First add this on the contact page, then you can edit here.",
},
5000,
);
return;
}
this.contactFromDid.name = newName;
return db.contacts
.update(this.contactFromDid.did, { name: newName })
.then(() => (this.contactEdit = false));
}
// note that this is also in ContactView.vue
async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = 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?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Visibility",
text: visibilityPrompt,
onYes: async () => {
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
},
},
-1,
);
}
// note that this is also in ContactView.vue
async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) {
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
(contact.name || "That user") +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
}
return true;
} else {
console.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Visibility",
text: message,
},
5000,
);
return false;
}
}
// note that this is also in ContactView.vue
async checkVisibility(contact: Contact) {
const url =
this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did);
const headers = await getHeaders(this.activeDid);
if (!headers["Authorization"]) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Identity",
text: "There is no identity to use to check visibility.",
},
3000,
);
return;
}
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const visibility = resp.data;
contact.seesMe = visibility;
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
await db.contacts.update(contact.did, { seesMe: visibility });
this.$notify(
{
group: "alert",
type: "info",
title: "Visibility Refreshed",
text:
libsUtil.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
} else {
console.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: message,
},
5000,
);
}
} catch (err) {
console.error("Caught error from request to check visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: "Check connectivity and try again.",
},
3000,
);
}
}
}
</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>

173
src/views/DiscoverView.vue

@ -1,16 +1,22 @@
<template> <template>
<QuickNav selected="Discover"></QuickNav> <QuickNav selected="Discover" />
<TopMessage /> <TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light">
Discover Discover Projects
</h1> </h1>
<OnboardingDialog ref="onboardingDialog" />
<!-- Quick Search --> <!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchSelected()"> <div
id="QuickSearch"
class="mt-8 mb-4 flex"
v-on:keyup.enter="searchSelected()"
>
<input <input
type="text" type="text"
v-model="searchTerms" v-model="searchTerms"
@ -37,7 +43,7 @@
isRemoteActive = false; isRemoteActive = false;
searchLocal(); searchLocal();
" "
v-bind:class="computedLocalTabClassNames()" v-bind:class="computedLocalTabStyleClassNames()"
> >
Nearby Nearby
<span <span
@ -51,13 +57,13 @@
<li> <li>
<a <a
href="#" href="#"
v-bind:class="computedRemoteTabClassNames()"
@click=" @click="
projects = []; projects = [];
isRemoteActive = true; isRemoteActive = true;
isLocalActive = false; isLocalActive = false;
searchAll(); searchAll();
" "
v-bind:class="computedRemoteTabStyleClassNames()"
> >
Anywhere Anywhere
<span <span
@ -72,11 +78,12 @@
</div> </div>
<div v-if="isLocalActive"> <div v-if="isLocalActive">
<div> <div class="text-center">
<button <button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500" class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })" @click="$router.push({ name: 'search-area' })"
> >
<fa icon="location-dot" class="fa-fw" />
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
</button> </button>
</div> </div>
@ -89,10 +96,19 @@
> >
<fa icon="spinner" class="fa-spin-pulse"></fa> <fa icon="spinner" class="fa-spin-pulse"></fa>
</div> </div>
<div v-else-if="projects.length === 0" class="text-center mt-8">
<p class="text-lg text-slate-500">
<span v-if="isLocalActive">
<span v-if="searchBox"> None found in the selected area. </span>
<!-- Otherwise there's no search area selected so we'll just leave the search box for them to click. -->
</span>
<span v-else>No projects were found with that search.</span>
</p>
</div>
<!-- Results List --> <!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData"> <InfiniteScroll @reached-bottom="loadMoreData">
<ul> <ul id="listDiscoverResults">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
v-for="project in projects" v-for="project in projects"
@ -100,14 +116,15 @@
> >
<a <a
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4" class="block py-4 flex gap-4 cursor-pointer"
> >
<div class="w-12"> <div>
<EntityIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
class="block border border-slate-300 rounded-md" :imageUrl="project.image"
></EntityIcon> class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div> </div>
<div class="grow"> <div class="grow">
@ -128,41 +145,38 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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 QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue";
import OnboardingDialog from "@/components/OnboardingDialog.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
interface Notification { import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
group: string; import { Contact } from "@/db/tables/contacts";
type: string; import { BoundingBox } from "@/db/tables/settings";
title: string; import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
text: string; import { OnboardPage } from "@/libs/util";
}
@Component({ @Component({
components: { components: {
QuickNav,
InfiniteScroll, InfiniteScroll,
EntityIcon, OnboardingDialog,
ProjectIcon,
QuickNav,
TopMessage, TopMessage,
}, },
}) })
export default class DiscoverView extends Vue { export default class DiscoverView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
searchTerms = ""; searchTerms = "";
projects: ProjectData[] = []; projects: PlanData[] = [];
isLoading = false; isLoading = false;
isLocalActive = true; isLocalActive = true;
isRemoteActive = false; isRemoteActive = false;
@ -174,11 +188,10 @@ export default class DiscoverView extends Vue {
didInfo = didInfo; didInfo = didInfo;
async mounted() { async mounted() {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); this.activeDid = (settings.activeDid as string) || "";
this.activeDid = settings?.activeDid || ""; this.apiServer = (settings.apiServer as string) || "";
this.apiServer = settings?.apiServer || ""; this.searchBox = settings.searchBoxes?.[0] || null;
this.searchBox = settings?.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
@ -186,6 +199,14 @@ export default class DiscoverView extends Vue {
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
this.searchTerms = (this.$route as Router).query["searchText"] || "";
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);
}
if (this.searchBox) { if (this.searchBox) {
await this.searchLocal(); await this.searchLocal();
} else { } else {
@ -208,30 +229,6 @@ export default class DiscoverView extends Vue {
} }
} }
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
public async searchAll(beforeId?: string) { public async searchAll(beforeId?: string) {
this.resetCounts(); this.resetCounts();
@ -252,13 +249,13 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plans?" + queryParams, this.apiServer + "/api/v2/report/plans?" + queryParams,
{ {
method: "GET", method: "GET",
headers: await this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
if (response.status !== 200) { if (response.status !== 200) {
const details = await response.text(); const details = await response.text();
console.log("Problem with full search:", details); console.error("Problem with full search:", details);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -274,11 +271,18 @@ export default class DiscoverView extends Vue {
const results = await response.json(); const results = await response.json();
const plans: ProjectData[] = results.data; const plans: PlanData[] = results.data;
if (plans) { if (plans) {
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid }); this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
} }
this.remoteCount = this.projects.length; this.remoteCount = this.projects.length;
} else { } else {
@ -286,7 +290,9 @@ export default class DiscoverView extends Vue {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.log("Error with feed load:", e); console.error("Error with feed load:", e);
// this sometimes gives different information
console.error("Error with feed load (error added): " + e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -335,13 +341,13 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams, this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{ {
method: "GET", method: "GET",
headers: await this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
if (response.status !== 200) { if (response.status !== 200) {
const details = await response.text(); const details = await response.text();
console.log("Problem with nearby search:", details); console.error("Problem with nearby search:", details);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -358,7 +364,7 @@ export default class DiscoverView extends Vue {
if (results.data) { if (results.data) {
if (beforeId) { if (beforeId) {
const plans: ProjectData[] = results.data; const plans: PlanData[] = results.data;
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan; const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ this.projects.push({
@ -378,7 +384,7 @@ export default class DiscoverView extends Vue {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.log("Error with feed load:", e); console.error("Error with feed load:", e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -413,42 +419,45 @@ export default class DiscoverView extends Vue {
* @param id of the project * @param id of the project
**/ **/
onClickLoadProject(id: string) { onClickLoadProject(id: string) {
localStorage.setItem("projectId", id);
const route = { const route = {
path: "/project/" + encodeURIComponent(id), path: "/project/" + encodeURIComponent(id),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
public computedLocalTabClassNames() { public computedLocalTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.isLocalActive, active: this.isLocalActive,
"text-blue-600": this.isLocalActive, "text-black": this.isLocalActive,
"border-blue-600": this.isLocalActive, "border-black": this.isLocalActive,
"font-semibold": this.isLocalActive, "font-semibold": this.isLocalActive,
"text-blue-600": !this.isLocalActive,
"border-transparent": !this.isLocalActive, "border-transparent": !this.isLocalActive,
"hover:text-slate-600": !this.isLocalActive, "hover:border-slate-400": !this.isLocalActive,
"hover:border-slate-300": !this.isLocalActive,
}; };
} }
public computedRemoteTabClassNames() { public computedRemoteTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.isRemoteActive, active: this.isRemoteActive,
"text-blue-600": this.isRemoteActive, "text-black": this.isRemoteActive,
"border-blue-600": this.isRemoteActive, "border-black": this.isRemoteActive,
"font-semibold": this.isRemoteActive, "font-semibold": this.isRemoteActive,
"text-blue-600": !this.isRemoteActive,
"border-transparent": !this.isRemoteActive, "border-transparent": !this.isRemoteActive,
"hover:text-slate-600": !this.isRemoteActive, "hover:border-slate-400": !this.isRemoteActive,
"hover:border-slate-300": !this.isRemoteActive,
}; };
} }
} }

844
src/views/GiftedDetailsView.vue

@ -0,0 +1,844 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</span>
<br />
<span>
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone unidentified"
}}</span
>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex justify-center mt-4" data-testId="imagery">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<div class="h-7 mt-4 flex">
<input
v-if="providerProjectId && !providedByGiver"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProvidingProject()"
/>
<label class="text-sm mt-1">
{{
providerProjectId
? "This was provided by " + providerProjectName
: "This was not provided by a project"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="fulfillsProjectId && !givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserFulfillsProject()"
/>
<label class="text-sm mt-1">
{{
fulfillsProjectId
? "This was given to " + fulfillsProjectName
: "No recipient project was chosen"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="recipientDid && !givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div>
<div class="mt-4 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<div v-if="showGeneralAdvanced" class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructGiveParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</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 { Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import {
createAndSubmitGive,
didInfo,
editAndSubmitGive,
GenericCredWrapper,
getHeaders,
getPlanFromCache,
GiveVerifiableCredential,
hydrateGive,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
@Component({
components: {
ImageMethodDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
destinationPathAfter = "";
fulfillsProjectId = "";
fulfillsProjectName = "a project";
givenToProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
givenToRecipient = false; // basically static, based on input; if we allow changing then let's fix things (see below)
giverDid: string | undefined;
giverName = "";
hideBackButton = false;
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
providerProjectId = "";
providerProjectName = "a project";
providedByProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
providedByGiver = false; // basically static, based on input; if we allow changing then let's fix things (see below)
recipientDid = "";
recipientName = "";
showGeneralAdvanced = false;
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<GiveVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.description =
(this.$route as Router).query["description"] ||
this.prevCredToEdit?.claim?.description ||
this.description;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.giverDid = ((this.$route as Router).query["giverDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.giverDid) as string;
this.giverName =
((this.$route as Router).query["giverName"] as string) || "";
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills;
const fulfillsArray = Array.isArray(fulfills)
? fulfills
: fulfills
? [fulfills]
: [];
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
this.offerId = ((this.$route as Router).query["offerId"] ||
offer?.identifier ||
this.offerId) as string;
// find any fulfills project ID
const fulfillsProject = fulfillsArray.find(
(rec) => rec["@type"] === "PlanAction",
);
// eslint-disable-next-line prettier/prettier
this.fulfillsProjectId =
((this.$route as Router).query["fulfillsProjectId"] ||
fulfillsProject?.identifier ||
this.fulfillsProjectId) as string;
// find any provider project ID
const provider = this.prevCredToEdit?.claim?.provider;
const providerArray = Array.isArray(provider)
? provider
: provider
? [provider]
: [];
const providerProject = providerArray.find(
(rec) => rec["@type"] === "PlanAction",
);
this.providerProjectId = ((this.$route as Router).query[
"providerProjectId"
] ||
providerProject?.identifier ||
this.providerProjectId) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.object?.unitCode ||
this.unitCode) as string;
this.imageUrl =
((this.$route as Router).query["imageUrl"] as string) ||
this.prevCredToEdit?.claim?.image ||
localStorage.getItem("imageUrl") ||
this.imageUrl;
// this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if ((this.$route as Router).query["shareTitle"]) {
this.description =
((this.$route as Router).query["shareTitle"] as string) +
(this.description ? "\n" + this.description : "");
}
if ((this.$route as Router).query["shareText"]) {
this.description =
(this.description ? this.description + "\n" : "") +
((this.$route as Router).query["shareText"] as string);
}
if ((this.$route as Router).query["shareUrl"]) {
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
}
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (
(this.giverDid && !this.giverName) ||
(this.recipientDid && !this.recipientName)
) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
if (this.giverDid && !this.giverName) {
this.giverName = didInfo(
this.giverDid,
this.activeDid,
allMyDids,
allContacts,
);
}
if (this.recipientDid && !this.recipientName) {
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.fulfillsProjectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
// these should be functions but something's wrong with the syntax in the <> conditional
this.providedByProject = !!this.providerProjectId;
this.providedByGiver = !this.providedByProject && !!this.giverDid;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
if (this.fulfillsProjectId) {
// console.log("Getting project name from cache", this.fulfillsProjectId);
const fulfillsProject = await getPlanFromCache(
this.fulfillsProjectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.fulfillsProjectName = fulfillsProject?.name
? `the project "${fulfillsProject.name}"`
: "a project";
}
if (this.providerProjectId) {
// console.log("Getting project name from cache", this.providerProjectId);
const providerProject = await getPlanFromCache(
this.providerProjectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.providerProjectName = providerProject?.name
? `the project "${providerProject.name}"`
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
(this.$router as Router).back();
}
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "GiveAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("Weird: 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();
}
notifyUserOfProvidingProject() {
// we're here because they clicked and either there is no provider project or there is a giver chosen
if (!this.providerProjectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To select a project as a provider, you must open this page through a project.",
},
3000,
);
} else {
// no providing project was chosen
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot select both a giving project and person.",
},
3000,
);
}
}
notifyUserFulfillsProject() {
// we're here because they clicked and either there is no fulfills project or there is a recipient chosen
if (!this.fulfillsProjectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this page through a project.",
},
3000,
);
} else {
// no fulfills project was chosen
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because givenToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive() {
try {
const recipientDid = this.givenToRecipient
? this.recipientDid
: undefined;
const fulfillsProjectId = this.givenToProject
? this.fulfillsProjectId
: undefined;
let result;
if (this.prevCredToEdit) {
// don't create from a blank one in case some properties were set from a different interface
result = await editAndSubmitGive(
this.axios,
this.apiServer,
this.prevCredToEdit,
this.activeDid,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
} else {
result = await createAndSubmitGive(
this.axios,
this.apiServer,
this.activeDid,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
}
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const fulfillsProjectId = this.givenToProject
? this.fulfillsProjectId
: undefined;
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// 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>

33
src/views/HelpNotificationsView.vue

@ -31,7 +31,7 @@
If this works then you're all set. If this works then you're all set.
<button <button
@click="sendTestWebPushMessage(true)" @click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
> >
Send Yourself a Test Web Push Message (Through Push Server but Send Yourself a Test Web Push Message (Through Push Server but
Skipping Client Filter) Skipping Client Filter)
@ -233,7 +233,7 @@
<h2 class="text-xl font-semibold mt-4">Tests</h2> <h2 class="text-xl font-semibold mt-4">Tests</h2>
<button <button
@click="showTestNotification()" @click="showTestNotification()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
> >
Send Test Notification Directly to Device (Not Through Push Server) Send Test Notification Directly to Device (Not Through Push Server)
</button> </button>
@ -246,7 +246,7 @@
<button <button
@click="alertWebPushSubscription()" @click="alertWebPushSubscription()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
> >
Show Web Push Subscription Info Show Web Push Subscription Info
</button> </button>
@ -259,7 +259,7 @@
<button <button
@click="sendTestWebPushMessage(true)" @click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
> >
Send Yourself a Test Web Push Message (Through Push Server but Skipping Send Yourself a Test Web Push Message (Through Push Server but Skipping
Client Filter) Client Filter)
@ -272,7 +272,7 @@
<button <button
@click="sendTestWebPushMessage()" @click="sendTestWebPushMessage()"
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4 mb-2"
> >
Send Yourself a Test Web Push Message (Through Push Server and Client Send Yourself a Test Web Push Message (Through Push Server and Client
Filter) Filter)
@ -294,25 +294,20 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { sendTestThroughPushServer } from "@/libs/util"; import { sendTestThroughPushServer } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class HelpNotificationsView extends Vue { 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() { async mounted() {
try { try {
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
this.subscription = await registration.pushManager.getSubscription(); const fullSub = await registration.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) { } catch (error) {
console.error("Mount error:", error); console.error("Mount error:", error);
} }
@ -321,13 +316,13 @@ export default class HelpNotificationsView extends Vue {
alertWebPushSubscription() { alertWebPushSubscription() {
console.log( console.log(
"Web push subscription:", "Web push subscription:",
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
); );
alert(JSON.stringify(this.subscription)); alert(JSON.stringify(this.subscriptionJSON));
} }
async sendTestWebPushMessage(skipFilter: boolean = false) { async sendTestWebPushMessage(skipFilter: boolean = false) {
if (!this.subscription) { if (!this.subscriptionJSON) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -342,7 +337,7 @@ export default class HelpNotificationsView extends Vue {
} }
try { try {
await sendTestThroughPushServer(this.subscription, skipFilter); await sendTestThroughPushServer(this.subscriptionJSON, skipFilter);
this.$notify( this.$notify(
{ {

115
src/views/HelpOnboardingView.vue

@ -0,0 +1,115 @@
<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>
<p>
To invite someone the easiest way, send them a link that you generate from
this page:
<router-link
:to="{ name: 'invite-one' }"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-xl"
/></router-link>
</p>
<p>Then watch that page to see when they accept their invite.</p>
<p>
(That page is also reachable from the Contacts <fa icon="users" /> page
though the invitation <fa icon="envelope-open-text" /> icon.)
</p>
<h1 class="mt-4 font-bold text-xl">Next Steps</h1>
Although not totally necessary, backups are important to understand.
<div class="ml-4">
<h1 class="font-bold text-xl">Without a backup, you can lose data.</h1>
<div>
<p>
Exporting backups (from the Account <fa icon="circle-user" /> screen)
is important for the case where they lose their device. This is
especially true for the Identifier Seed: that is theirs and and theirs
alone, and currently nobody else can recover it if they lose it. The
good thing is that anyone can create a new account and simply inform
their network of their new ID.
</p>
</div>
</div>
<h1 class="mt-4 font-bold text-xl">Advanced</h1>
The following are optional steps for even more functionality.
<!-- eslint-disable prettier/prettier -->
<div class="ml-4">
<h1 class="font-bold text-xl">Add Contact & Register</h1>
<p>
You share even more information such as your picture and name when
you share with your QR code at these links: <fa icon="qrcode" />
</p>
<p>
Scanning
those with your cameras will automatically register people and add them
to each other's contact lists.
</p>
<p>
The following are more detailed manual steps:
</p>
<div>
<p>
1) Have them follow their yellow prompts.
</p>
<p>
2) Scan their QR, or have them tap on it to copy their info and send it to you.
Then you can add them to your Contacts <fa icon="users" />
</p>
<p>
3) You can register them at their info page <fa icon="circle-info" />
and click on the register button <fa icon="person-circle-question" />
</p>
<p>
4) Add yourself to their Contacts <fa icon="users" />
</p>
</div>
<h1 class="font-bold text-xl">Install</h1>
<div>
<p>
Have them visit TimeSafari.app in a browser, preferably Chrome or Safari,
and then look for the "Install" selection which adds this app to their desktop.
This enables other things, like the ability to "share" a photo from their
device directly to Time Safari, and it makes notifications more reliable.
</p>
</div>
<h1 class="font-bold text-xl">Enable Notifications</h1>
<div>
<p>
Enable notifications from the Account page <fa icon="circle-user" />.
Those notifications might show up on the device depending on your settings.
For the most reliable habits, people should own alarm or some other ritual to look every day.
</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>

444
src/views/HelpView.vue

@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()" @click="$router.back()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw" />
</h1> </h1>
</div> </div>
@ -21,74 +21,221 @@
</h1> </h1>
</div> </div>
<!-- eslint-disable prettier/prettier --> <!-- eslint-disable prettier/prettier max-len -->
<div> <div>
<p> <p>
This app is a window into data that you and your friends own, focused on This app focuses on gifts & gratitude, using them to build cool things together with your network.
gifts and collaboration. </p>
<p class="ml-4">
If you'd like to see the page-by-page help,
<span
@click="unsetFinishedOnboarding()"
class="text-blue-500 cursor-pointer"
>click here</span>.
</p> </p>
<h2 class="text-xl font-semibold">What is the idea here?</h2> <h2 class="text-xl font-semibold">What is the idea here?</h2>
<p> <p>
We are building networks of people who want to grow a giving society. We are building networks of people who want to grow good society from the ground up, using modern
First of all, you can see what people have given, and also recognize technology that connects people peer-to-peer.
gifts you've seen, in a way that leaves a permanent record -- one that First of all, let's showcase gratitude: see what people have given, and recognize
came from you, and the recipient can prove it was for them. This is gifts you've seen. This is done in a way that leaves a permanent record -- one that
came from you, and one that the recipient can prove it was for them. This can be
personally gratifying, but it extends to broader work: volunteers get personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and selectively show off their contributions confirmation of activity, and they can selectively show off their contributions
and network. and network.
</p> </p>
<p class="mt-2">
With this, you highlight giving and you also offer help --
which could be conditional on others' contributions, too.
You can record your own ideas and invite others to collaborate.
It's a way to organize & build with the resource that everyone has in equal amounts: time.
</p>
<p class="mt-2">
Note that your personal data is safe: your ID is only shared with those you allow. Neither
your name nor your contacts' names are shared with anyone -- even our servers --
though you can explicitly share it with other individuals if you choose.
</p>
<h2 class="text-xl font-semibold">I want to know more because...</h2>
<ul class="list-disc list-outside ml-4">
<li class="p-2">
<div @click="showAlpha = !showAlpha" class="text-blue-500">... I'm a member of Alpha chat.</div>
<div v-if="showAlpha">
<p>
This is a project for public benefit. You are invited to add your gratitude
and propose projects on a distributable ledger.
</p>
<p> <p>
You can show giving and also offer help to ideas, based on others' The underlying data is on a merkle tree with each verifiable claim, signature and all.
willingness to help out, too. You can record your own ideas and invite The chain includes individual IDs for discovery & visibility, so not all data is distributed -- yet.
others to collaborate. The goal is to eventually distribute the data on people's devices with their chosen network,
where anyone could host their own chain of provenance if they choose.
The formats follow standard schemas (eg. schema.org) to encourage interoperability.
We're currently at the beginning phase where we're trusting the server to keep IDs private.
It's all open-source, and we expect to have a professional audit someday.
</p> </p>
<p> <p>
This app uses the power of cryptography to build a reputation, recording A person's network of contacts is similar: the server currently knows some of the links between people
activity that you can share at your discretion. You put some activity to allow discovery and visibility. However, even that will be manageable on personal devices someday.
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.
</p> </p>
<p>
There are no tokens to maintain the chain: the purpose is to create software that communities
and activists can easily join and use. We're betting that this is a case where network
participants have the motivation to run the software. The protocol is meant to be lightweight enough that
non-technical people can run it on inexpensive devices they already own. There may be cases for
MPC or ZKP in the future when they are more widespread and standard,
but our preference is to engineer as simply as possible with "white-magic" cryptography
over those "black-magic" functions.
</p>
<p>
Let's make real distributed computing and shared data happen, starting with our own small networks.
</p>
<p>
... and exemplify the fun along the way.
</p>
</div>
</li>
<li class="p-2">
<div @click="showGroup = !showGroup" class="text-blue-500">... I want to find a group I'll enjoy working with.</div>
<div v-if="showGroup">
<p>
This app encourages people to offer small bits of time to one another. It's a way to
run experiments with other people... tests of working together, which can start small
and easy but build into cooperation with people who are like-minded and who work well together.
</p>
<p>
Search the projects and place an offer on an interesting one
-- or create your own project and see who offers to help.
After your first experiment, you can give and get confirmation about the work, which you might choose
to show to future contacts.
</p>
</div>
</li>
<li class="p-2">
<div @click="showCommunity = !showCommunity" class="text-blue-500">... I want to participate in community projects.</div>
<div v-if="showCommunity">
<p>
These are mostly at the beginning stages, so any of them will appreciate your offers that show interest.
In fact, your offers can include your preferences, which give the project owners indications of how to proceed.
</p>
<p>
Search through the projects for issues of interest, locally as well as globally.
If you don't see any projects that interest you, create your own and see what kind of offers you get.
</p>
</div>
</li>
<li class="p-2">
<div @click="showVerifiable = !showVerifiable" class="text-blue-500">... I want to build with verifiable, private data.</div>
<div v-if="showVerifiable">
<p>
Make your claims and get others to confirm them. Then you can use the API to pull your copy of all that
data, both claims from you and claims from others about you. These are hard-and-fast credentials that can
be shown to others, along with their verifiable time and signature.
</p>
<p>
Furthermore, you can use your network to verify claims by other people, even if they haven't given you
visibility. First, on the claim screen you can see if the server detects anyone who is a direct link
between you, so you can reach out to those in-between people for more info. If there isn't anyone
who is directly in between then you can reach out with a message to your network.
</p>
<p>
This app generated an identifier, based on public & private keys located on your device.
That ID is only shared with our server and with people you explicitly allow.
The other information -- like gratitude and contributions and projects --
are published to a server that protects your ID. (Someday, your devices
will share directly P2P and not need a server... you can choose your levels
of discovery and privacy.) What this means is that you are in charge of your
network, and we provide tools and reporting to help you connect with your network for
references and reputation.
</p>
</div>
</li>
<li class="p-2">
<div @click="showGovernance = !showGovernance" class="text-blue-500">... I want to build governance organically.</div>
<div v-if="showGovernance">
<p>
This requires motivated, dedicated citizens. The good thing is that dedication the primary ingredient;
add coordination and we can find ways to replace monopolistic systems.
</p>
<p>
Add projects for your main areas of interest, and offer commitments to projects to kick-start some initiatives.
</p>
<p>
One other feature worth emphasizing: you build a history of credentials, ones that are verifiably
yours. But one other good thing is that you get support from those who confirm your activity.
You can share this support in a way that others can validate the data for themselves from people
in their own network. This kind of reputable project and history of performance is good evidence
for your ability to take responsibility for important initiatives.
</p>
</div>
</li>
<li class="p-2">
<div @click="showBasics = !showBasics" class="text-blue-500">... I want to supply life's basics freely.</div>
<div v-if="showBasics">
<p>
This platform is not optimal for balancing needs and resources at this point,
but we continuously seek out and list
those kinds of projects. Watch our blog, and watch the project list for words like
<router-link class="text-blue-500" to="/discover?searchText=sharing">"sharing"</router-link>
or
<router-link class="text-blue-500" to="/discover?searchText=basic">"basic"</router-link>
or
<router-link class="text-blue-500" to="/discover?searchText=free">"free"</router-link>.
</p>
</div>
</li>
</ul>
<h2 class="text-xl font-semibold">How do I get started?</h2> <h2 class="text-xl font-semibold">How do I get started?</h2>
<p> <p>
You need someone to register you -- usually the person who told you Someone -- like the person who told you about this app -- needs to register you
about this app, on the Contacts on the Contacts <fa icon="users" class="fa-fw" /> page.
<fa icon="users" class="fa-fw" /> page. After they register you, you can If you heard about this from our outreach, feel free to contact us (below) for a chat.
select any contact on the home page (or "anonymous") and record your After someone registers you, you can register others.
appreciation for... whatever. The main goal is to record what people
have given you, to grow giving economies. Each claim is recorded on a
custom ledger. The day after being registered, you'll be able to able to
register others; later, you can create projects, too.
</p> </p>
<p> <p>
Note that there are limits to how many others each person can register, Then you can record your appreciation for... whatever: select any contact on the home page
so you may have to wait. (or "Unnamed") and send it. The main goal is to record what people
have given you, to grow giving economies. You can also record your own
ideas for projects. Each claim is recorded on a
custom ledger.
</p>
<p>
The day after being registered, you'll be able to able to register others, too.
Note that there are limits to how many others you can register.
Take your time to bring people on... make it an opportunity to get to
know their projects, and to show off your own.
</p> </p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2> <h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p> <p>
<button class="text-blue-500" @click="showOnboardInfo"> <a href="/help-onboarding" target="_blank" class="text-blue-500">
Click here to show an alert with the steps. Use these instructions.
</button> </a>
To start scanning, go To start scanning, go to the
<router-link class="text-blue-500" to="/contact-qr">here.</router-link> <router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link>
</p> </p>
<p> <p>
If they are not nearby to scan QR codes, tell them to copy their ID from If they are not nearby to scan QR codes, you each can tap on the QR code
their Identity <fa icon="circle-user" class="fa-fw" /> page, which and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
typically starts with "did:ethr:...", and send it to you. Go to the </p>
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
top form. To add a name, put a comma and then their name; to add their <h2 class="text-xl font-semibold">
public key, put another comma followed by the key. 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>.
</p> </p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2> <h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p> <p>
There are two sets of data to backup: the identifier secrets and the There are four sets of data to backup: the identifier secrets;
other data that isn't quite a secret such as settings, contacts, etc. the private text data that isn't as sensitive such as settings and contacts;
the private image for yourself; and the data that you have sent to the public.
</p> </p>
<div class="px-4"> <div class="px-4">
@ -109,7 +256,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data? How do I backup my other private text data like settings & contacts?
</h2> </h2>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
@ -121,11 +268,32 @@
won't lose it. won't lose it.
</li> </li>
</ul> </ul>
<h2 class="text-xl font-semibold">
How do I backup my profile image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup other data I've posted?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
This requires use of the API, so investigate the endpoints
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
(particularly the "claim" endpoints).
</li>
</ul>
</div> </div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2> <h2 class="text-xl font-semibold">How do I restore my data?</h2>
<p> <p>
There are two parts to restore your data: the identity secrets and the There are two steps to restore your data: the identity secrets, then the
other data such as settings, contacts, etc. other data such as settings, contacts, etc.
</p> </p>
@ -148,30 +316,32 @@
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page, Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for Data Import. click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li> </li>
</ul> </ul>
</div> </div>
<h2 class="text-xl font-semibold">How do I create another identity?</h2> <h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p> <p>
Before doing this, note that it is an advanced feature that affects Before doing this, beware that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features) functionality (eg. the words "Alt ID" next to results, backup features). You can
so beware. You can
<router-link to="start" class="text-blue-500"> <router-link to="start" class="text-blue-500">
create another identity here. create another identity here.
</router-link> </router-link>
</p> </p>
<h2 class="text-xl font-semibold">How do I erase my data?</h2> <h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<p> <p>
Before doing this, note the two kinds of data to backup: identity data, Before doing this, you may want to back up your data with the instructions above.
and other data for contacts and settings (see instructions above).
</p> </p>
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Mobile Mobile
<ul> <ul>
<li class="list-disc list-outside ml-4">
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data Chrome: Settings -> Privacy and Security -> Clear Browsing Data
</li> </li>
@ -185,13 +355,11 @@
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Chrome: Chrome:
<a href="chrome://settings/content/all" class="text-blue-500" Clear at "chrome://settings/content/all" and
>clear here</a
>
also clear under dev tools Application also clear under dev tools Application
</li> </li>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Firefox: <a href="about:preferences">go here</a>, Manage Data, Firefox: Navigate to "about:preferences", Manage Data,
find timesafari.app and select, hit Remove Selected, then Save find timesafari.app and select, hit Remove Selected, then Save
Changes Changes
</li> </li>
@ -204,14 +372,36 @@
</ul> </ul>
<p>To erase your data from our servers, contact us (below).</p> <p>To erase your data from our servers, contact us (below).</p>
<h2 class="text-xl font-semibold">
How do I get higher limits?
</h2>
<p>
Let's talk. Contact us (below).
</p>
<h2 class="text-xl font-semibold">
How do I access even more functionality?
</h2>
<p>
There is an "Advanced" section at the bottom of the Account
<fa icon="circle-user" /> page.
</p>
<p>
There is even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com
</a>
</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info? I know there is a record from someone, so why can't I see that info?
</h2> </h2>
<p> <p>
If you don't see anything associated with a person, this is typically If you don't see anything associated with a person, this is typically
because they have not given you permission to see their information. Ask because they have not given you permission to see their information. Ask
them to add you to their contact list and make sure the eye next to your them to add you to their contact list, and ask specifically to make sure
name is open like this the eye next to your name is open like this
<fa icon="eye" class="fa-fw" /> and not closed like this <fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />. <fa icon="eye-slash" class="fa-fw" />.
</p> </p>
@ -232,23 +422,65 @@
</p> </p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I access even more functionality? My app is misbehaving, like showing me a blank screen or failing to show a feed.
What can I do?
</h2> </h2>
<p> <p>
There is an "Advanced" section at the bottom of the Account First, note that clearing the cache will clear all your identity and contact info,
<fa icon="circle-user" /> page. so we recommend doing other things first (unless you know you have your backups ready).
</p> </p>
<ul class="list-disc list-outside ml-4">
<li>
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the current version.
You can see the version information at the bottom of this page; the best
way to determine the current version is to open this page in an incognito
browser window and look at the version there.
</li>
<li>
Close all tabs that have Time Safari open; it can be difficult to find them all,
and you may have to close all your tabs. In addition, it may be running as an
installed app, so look for any Time Safari app that may be running outside a browser.
</li>
<li>
There may be a problem with your identity. Go to the Identity
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
and you may see helpful info there. If it shows a problem, try adding your identifier again.
</li>
<li>
It can help to reregister the service worker:
<ul>
<li>
In Chrome, open a tab to
"chrome://serviceworker-internals",
find "timesafari.app", and click "Unregister".</li>
<li>
In Firefox,
open a tab to "about:serviceworkers",
find "timesafari.app", and click "Unregister".
</li>
<li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p> <p>
There is a even more functionality in a mobile app (and more If you still have problems, you can clear the cache (see "erase my data" above)
documentation) at and even uninstall and reinstall the app
<a href="https://endorser.ch" class="text-blue-500"> -- just be sure to have your backups ready or be
EndorserSearch.com prepared to restart with a new identity and recreate your network.
</a> Nobody else has access to your identity or contact information because
this app is designed to give you full control over your data.
</p> </p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2> <h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center"> <p style="display:inline; align-items: center">
This work is marked with This work is public domain. If you like rules, reference
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer"> <a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span> <span class="text-blue-500 mr-1">CC0 1.0</span>
<img <img
@ -269,15 +501,37 @@
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page. by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br /> <br />
For all other claim data, For all other claim data,
<a href="https://endorser.ch/privacy-policy" class="text-blue-500"> <a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy. the Endorser Service has this Privacy Policy.
</a> </a>
</p> </p>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
<p>
If you have skills, contact us below.
If you have Bitcoin, donate to
<button
@click="
doCopyTwoSecRedo(
'bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma',
() => (showDidCopy = !showDidCopy)
)
"
class="text-blue-500 ml-2"
>
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
<fa v-show="!showDidCopy" icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showDidCopy" class="ml-2 text-sm text-green-500">Copied</span>
You can donate online via
<a href="https://www.patreon.com/TimeSafari" target="_blank" class="text-blue-500">Patreon here</a>.
For other donations, contact us.
</p>
<h2 class="text-xl font-semibold">Where can I read more?</h2> <h2 class="text-xl font-semibold">Where can I read more?</h2>
<p> <p>
This is part of the This is part of the
<a href="https://livesofgiving.org" class="text-blue-500"> <a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving Lives of Giving
</a> </a>
initiative. initiative.
@ -287,7 +541,7 @@
<p>{{ package.version }} ({{ commitHash }})</p> <p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
For any other questions, including removing your data: I have other questions, like getting a new account or removing all my data from the public ledger.
</h2> </h2>
<p> <p>
Contact us at Contact us at
@ -302,35 +556,47 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import * as Package from "../../package.json"; import * as Package from "../../package.json";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { ONBOARD_MESSAGE } from "@/libs/util"; import { NotificationIface } from "@/constants/app";
import {
interface Notification { retrieveSettingsForActiveAccount,
group: string; updateAccountSettings,
type: string; } from "@/db/index";
title: string;
text: string;
}
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class Help extends Vue { export default class Help extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
package = Package; package = Package;
commitHash = process.env.VUE_APP_GIT_HASH; commitHash = import.meta.env.VITE_GIT_HASH;
showAlpha = false;
showOnboardInfo() { showBasics = false;
this.$notify( showCommunity = false;
{ showGovernance = false;
group: "alert", showGroup = false;
type: "info", showDidCopy = false;
title: "Onboard Someone", showVerifiable = false;
text: ONBOARD_MESSAGE,
}, // call fn, copy text to the clipboard, then redo fn after 2 seconds
-1, doCopyTwoSecRedo(text: string, fn: () => void) {
); fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
async unsetFinishedOnboarding() {
const settings = await retrieveSettingsForActiveAccount();
if (settings.activeDid) {
await updateAccountSettings(settings.activeDid || "", {
finishedOnboarding: false,
});
}
(this.$router as Router).push({ name: "home" });
} }
} }
</script> </script>

867
src/views/HomeView.vue

File diff suppressed because it is too large

159
src/views/IdentitySwitcherView.vue

@ -18,30 +18,64 @@
<!-- Identity List --> <!-- Identity List -->
<!-- Current Identity - Display First! --> <!-- Current Identity - Display First! -->
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4"> <div
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa> v-if="activeDid && !activeDidInIdentities"
<span class="overflow-hidden"> class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4"
<div class="text-sm text-slate-500 truncate"> >
<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> <b>ID:</b> <code>{{ activeDid }}</code>
</div> </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> </div>
<!-- Other Identity/ies --> <!-- Other Identity/ies -->
<ul class="mb-4"> <ul class="mb-4">
<li <li v-for="ident in otherIdentities" :key="ident.did">
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2" <div class="flex items-center justify-between mb-2">
v-for="ident in otherIdentities" <div
:key="ident.did" class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
@click="switchAccount(ident.did)" @click="switchAccount(ident.did)"
> >
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa> <fa
<span class="overflow-hidden"> v-if="ident.did === activeDid"
<h2 class="text-xl font-semibold mb-0"></h2> 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="flex-grow overflow-hidden">
<div class="text-sm text-slate-500 truncate"> <div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code> <b>ID:</b> <code>{{ ident.did }}</code>
</div> </div>
</span> </span>
</div>
<div>
<fa
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteAccount(ident.id)"
/>
</div>
</div>
</li> </li>
</ul> </ul>
@ -50,13 +84,13 @@
<router-link <router-link
id="start-link" id="start-link"
:to="{ name: 'start' }" :to="{ name: 'start' }"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2" class="block text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
> >
Add Another Identity&hellip; Add Another Identity&hellip;
</router-link> </router-link>
<a <a
href="#" href="#"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-8"
@click="switchAccount('0')" @click="switchAccount('0')"
> >
No Identity No Identity
@ -65,63 +99,37 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { AppString } from "@/constants/app"; import { Router } from "vue-router";
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 { import { NotificationIface } from "@/constants/app";
group: string; import { db, accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
type: string; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
title: string; import QuickNav from "@/components/QuickNav.vue";
text: string;
}
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue { 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 activeDid = "";
public activeDidInIdentities = false;
public apiServer = ""; public apiServer = "";
public apiServerInput = ""; public apiServerInput = "";
public otherIdentities: Array<{ did: string }> = []; public otherIdentities: Array<{ id: string; 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() { async created() {
try { try {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.activeDid = settings.activeDid || "";
this.activeDid = settings?.activeDid || ""; this.apiServer = settings.apiServer || "";
this.apiServer = settings?.apiServer || ""; this.apiServerInput = settings.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.showContactGives = !!settings?.showContactGivesInline;
const identity = await this.getIdentity(this.activeDid);
if (identity) {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
}
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) { for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"]; const acct = accounts[n];
if (did && this.activeDid !== did) { this.otherIdentities.push({ id: acct.id as string, did: acct.did });
this.otherIdentities.push({ did: did }); if (acct.did && this.activeDid === acct.did) {
this.activeDidInIdentities = true;
} }
} }
} catch (err) { } catch (err) {
@ -147,17 +155,38 @@ export default class IdentitySwitcherView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did, activeDid: did,
}); });
this.activeDid = did || ""; (this.$router as Router).push({ name: "account" });
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 });
} }
async deleteAccount(id: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
await accountsDB.open();
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
},
-1,
);
} }
this.$router.push({ name: "account" });
notifyCannotDelete() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity.",
},
3000,
);
} }
} }
</script> </script>

86
src/views/ImportAccountView.vue

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

24
src/views/ImportDerivedAccountView.vue

@ -17,7 +17,7 @@
<div> <div>
<p class="text-center text-xl mb-4 font-light"> <p class="text-center text-xl mb-4 font-light">
Will increment the maximum derivation path from the existing seed. Will increment the maximum known derivation path from the existing seed.
</p> </p>
<p v-if="didArrays.length > 1"> <p v-if="didArrays.length > 1">
@ -49,31 +49,35 @@
</ul> </ul>
</div> </div>
<div class="mt-8"> <div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button <button
@click="incrementDerivation()" @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" 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 Increment and Import
</button> </button>
<button <button
@click="onCancelClick()" @click="onCancelClick()"
type="button" type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { import {
DEFAULT_ROOT_DERIVATION_PATH, DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress, deriveAddress,
newIdentifier, newIdentifier,
nextDerivationPath, nextDerivationPath,
} from "../libs/crypto"; } from "@/libs/crypto";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@ -98,7 +102,7 @@ export default class ImportAccountView extends Vue {
} }
public onCancelClick() { public onCancelClick() {
this.$router.back(); (this.$router as Router).back();
} }
public switchAccount(did: string) { public switchAccount(did: string) {
@ -122,9 +126,11 @@ export default class ImportAccountView extends Vue {
} }
}); });
// increment the last number in that max derivation path // increment the last number in that max derivation path
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath); const newDerivPath = nextDerivationPath(
accountWithMaxDeriv.derivationPath as string,
);
const mne: string = accountWithMaxDeriv.mnemonic; const mne: string = accountWithMaxDeriv.mnemonic as string;
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath); const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
@ -142,10 +148,10 @@ export default class ImportAccountView extends Vue {
// record that as the active DID // record that as the active DID
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did, activeDid: newId.did,
}); });
this.$router.push({ name: "account" }); (this.$router as Router).push({ name: "account" });
} catch (err) { } catch (err) {
console.error("Error saving mnemonic & updating settings:", err); console.error("Error saving mnemonic & updating settings:", err);
} }

392
src/views/InviteOneView.vue

@ -0,0 +1,392 @@
<template>
<QuickNav selected="Invite" />
<TopMessage />
<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 class="text-4xl text-center font-light">Invitations</h1>
<ul class="ml-8 mt-4 list-outside list-disc w-5/6">
<li>
Note when sending
<span
v-if="!showAppleWarning"
class="text-blue-500 cursor-pointer"
@click="showAppleWarning = !showAppleWarning"
>
to Apple users...
</span>
<span v-else>
to Apple users: their links often fail because their device cuts off
part of the link. You might need to send it to them some other way,
like in an email.
</span>
</li>
</ul>
<!-- New Project -->
<button
v-if="isRegistered"
class="fixed right-6 top-12 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="createInvite()"
>
<fa icon="plus" class="fa-fw"></fa>
</button>
<InviteDialog ref="inviteDialog" />
<!-- Invites Table -->
<div v-if="invites.length" class="mt-6">
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">
ID
<br />
(click for link)
</th>
<th class="py-2">Notes</th>
<th class="py-2">Expires At</th>
<th class="py-2">Redeemed</th>
</tr>
</thead>
<tbody>
<tr
v-for="invite in invites"
:key="invite.inviteIdentifier"
class="border-t py-2"
>
<td>
<span
v-if="
!invite.redeemedAt &&
invite.expiresAt > new Date().toISOString()
"
@click="
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)
"
class="text-center text-blue-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
<span
v-else
@click="
showInvite(
invite.inviteIdentifier,
!!invite.redeemedAt,
invite.expiresAt < new Date().toISOString(),
)
"
class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
</td>
<td class="text-left" :data-testId="inviteLink(invite.jwt)">
{{ invite.notes }}
</td>
<td class="text-center">
{{ invite.expiresAt.substring(0, 10) }}
</td>
<td class="text-center">
{{ invite.redeemedAt?.substring(0, 10) }}
<br />
{{ getTruncatedRedeemedBy(invite.redeemedBy) }}
<br />
<fa
v-if="invite.redeemedBy && !contactsRedeemed[invite.redeemedBy]"
icon="plus"
class="bg-green-600 text-white px-1 py-1 rounded-full cursor-pointer"
@click="addNewContact(invite.redeemedBy)"
/>
</td>
<td>
<fa
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteInvite(invite.inviteIdentifier, invite.notes)"
/>
</td>
</tr>
</tbody>
</table>
<ContactNameDialog ref="contactNameDialog" />
</div>
<p v-else class="mt-6 text-center">No invites found.</p>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import ContactNameDialog from "@/components/ContactNameDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import InviteDialog from "@/components/InviteDialog.vue";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db";
import { createInviteJwt, getHeaders } from "@/libs/endorserServer";
interface Invite {
inviteIdentifier: string;
expiresAt: string;
jwt: string;
notes: string;
redeemedAt: string | null;
redeemedBy: string | null;
}
@Component({
components: { ContactNameDialog, QuickNav, TopMessage, InviteDialog },
})
export default class InviteOneView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
invites: Invite[] = [];
activeDid: string = "";
apiServer: string = "";
contactsRedeemed = {};
isRegistered: boolean = false;
showAppleWarning = false;
async mounted() {
try {
await db.open();
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
const headers = await getHeaders(this.activeDid);
const response = await axios.get(
this.apiServer + "/api/userUtil/invite",
{ headers },
);
this.invites = response.data.data;
const baseContacts = await db.contacts.toArray();
for (const invite of this.invites) {
const contact = baseContacts.find(
(contact) => contact.did === invite.redeemedBy,
);
if (contact) {
this.contactsRedeemed[invite.redeemedBy] = contact;
}
}
} catch (error) {
console.error("Error fetching invites:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Load Error",
text: "Got an error loading your invites.",
},
5000,
);
}
}
getTruncatedInviteId(inviteId: string): string {
if (inviteId.length <= 9) return inviteId;
return `${inviteId.slice(0, 6)}...`;
}
getTruncatedRedeemedBy(redeemedBy: string | null): string {
if (!redeemedBy) return "";
if (this.contactsRedeemed[redeemedBy]) {
return this.contactsRedeemed[redeemedBy].name;
}
if (redeemedBy.length <= 19) return redeemedBy;
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
}
inviteLink(jwt: string): string {
return APP_SERVER + "/contacts?inviteJwt=" + jwt;
}
copyInviteAndNotify(inviteId: string, jwt: string) {
useClipboard().copy(this.inviteLink(jwt));
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: "Your clipboard now contains the link for invite " + inviteId,
},
5000,
);
}
showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
let message = `Your clipboard now contains the invite ID ${inviteId}`;
if (redeemed) {
message += " (This invite has been used.)";
} else if (expired) {
message += " (This invite has expired.)";
}
useClipboard().copy(inviteId);
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: message,
},
5000,
);
}
lookForErrorAndNotify(error, title: string, defaultMessage: string) {
console.error(title, "-", error);
let message = defaultMessage;
if (error.response && error.response.data && error.response.data.error) {
if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = error.response.data.error;
}
}
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
5000,
);
}
async createInvite() {
const inviteIdentifier =
Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2);
(this.$refs.inviteDialog as InviteDialog).open(
inviteIdentifier,
async (notes, expiresAt) => {
try {
const headers = await getHeaders(this.activeDid);
if (!expiresAt) {
throw {
response: {
data: { error: "You must select an expiration date." },
},
};
}
const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000;
const inviteJwt = await createInviteJwt(
this.activeDid,
undefined,
inviteIdentifier,
expiresIn,
);
await axios.post(
this.apiServer + "/api/userUtil/invite",
{ inviteJwt: inviteJwt, notes: notes },
{ headers },
);
this.invites.push({
inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt,
jwt: inviteJwt,
notes: notes,
redeemedAt: null,
redeemedBy: null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
this.lookForErrorAndNotify(
error,
"Error Creating Invite",
"Got an error creating your invite.",
);
}
},
);
}
addNewContact(did) {
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Sent You The Invite?",
"Their name will be added to your contact list.",
(name) => {
// the person obviously registered themselves and this user already granted visibility, so we just add them
const contact = {
did: did,
name: name,
registered: true,
};
db.contacts.add(contact);
this.contactsRedeemed[did] = contact;
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: `${name} has been added to your contacts.`,
},
3000,
);
},
);
}
deleteInvite(inviteId: string, notes: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Invite?",
text: `Are you sure you want to erase the invite for "${notes}"? (There is no undo.)`,
onYes: async () => {
const headers = await getHeaders(this.activeDid);
try {
const result = await axios.delete(
this.apiServer + "/api/userUtil/invite/" + inviteId,
{ headers },
);
if (result.status !== 204) {
throw result.data;
}
this.invites = this.invites.filter(
(invite) => invite.inviteIdentifier !== inviteId,
);
this.$notify(
{
group: "alert",
type: "success",
title: "Deleted",
text: "Invite deleted.",
},
3000,
);
} catch (e) {
this.lookForErrorAndNotify(
e,
"Error Deleting Invite",
"Got an error deleting your invite.",
);
}
},
},
-1,
);
}
}
</script>

335
src/views/NewActivityView.vue

@ -0,0 +1,335 @@
<template>
<QuickNav selected="Home"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<fa
icon="chevron-left"
@click="$router.back()"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
/>
New Activity For You
</h1>
</div>
<!-- Display a single row with the name of "New Offers To You" with a count. -->
<div class="flex justify-between" data-testId="showOffersToUser">
<div>
<span class="text-lg font-medium"
>{{ newOffersToUser.length
}}{{ newOffersToUserHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>New Offer{{ newOffersToUser.length === 1 ? "" : "s" }} To You</span
>
<fa
v-if="newOffersToUser.length > 0"
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserAndMarkRead()"
/>
</div>
<router-link to="/recent-offers-to-user" class="text-blue-500">
See&nbsp;all
</router-link>
</div>
<div v-if="showOffersDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="offer in newOffersToUser"
:key="offer.jwtId"
class="mt-4 relative group"
>
<span>{{
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
<span v-if="offer.amount">{{
displayAmount(offer.unit, offer.amount)
}}</span>
<router-link
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
</router-link>
<!-- New line that appears on hover -->
<div
@click="markOffersAsReadStartingWith(offer.jwtId)"
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
</div>
</li>
</ul>
</div>
<!-- Display a single row with the name of "New Offers To Your Projects" with a count. -->
<div
class="mt-4 flex justify-between"
data-testId="showOffersToUserProjects"
>
<div>
<span class="text-lg font-medium"
>{{ newOffersToUserProjects.length
}}{{ newOffersToUserProjectsHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>New Offer{{ newOffersToUserProjects.length === 1 ? "" : "s" }} To
Your Projects</span
>
<fa
v-if="newOffersToUserProjects.length > 0"
:icon="
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserProjectsAndMarkRead()"
/>
</div>
<router-link to="/recent-offers-to-user-projects" class="text-blue-500">
See&nbsp;all
</router-link>
</div>
<div v-if="showOffersToUserProjectsDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="offer in newOffersToUserProjects"
:key="offer.jwtId"
class="mt-4 relative group"
>
<span>{{
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
<span v-if="offer.amount">{{
displayAmount(offer.unit, offer.amount)
}}</span>
to
<span>{{ offer.planName }}</span>
<router-link
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
</router-link>
<!-- New line that appears on hover -->
<div
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)"
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
</div>
</li>
</ul>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { NotificationIface } from "@/constants/app";
import {
accountsDB,
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
didInfo,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
OfferSummaryRecord,
OfferToPlanSummaryRecord,
} from "@/libs/endorserServer";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class NewActivityView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserHitLimit = false;
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsHitLimit = false;
showOffersDetails = false;
showOffersToUserProjectsDetails = false;
didInfo = didInfo;
displayAmount = displayAmount;
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
if (allAccounts.length > 0) {
this.allMyDids = allAccounts.map((acc) => acc.did);
}
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.newOffersToUser = offersToUserData.data;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
const offersToUserProjectsData = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
// 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 activity.",
},
5000,
);
}
}
async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails) {
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserJwtId in case they
// later choose the last one to keep the offers as new
this.$notify(
{
group: "alert",
type: "info",
title: "Marked as Read",
text: "The offers are marked as viewed. Click in the list to keep them as new.",
},
5000,
);
}
}
async markOffersAsReadStartingWith(jwtId: string) {
const index = this.newOffersToUser.findIndex(
(offer) => offer.jwtId === jwtId,
);
if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
});
}
this.$notify(
{
group: "alert",
type: "info",
title: "Marked as Unread",
text: "All offers above that one are marked as unread.",
},
3000,
);
}
async expandOffersToUserProjectsAndMarkRead() {
this.showOffersToUserProjectsDetails =
!this.showOffersToUserProjectsDetails;
if (this.showOffersToUserProjectsDetails) {
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserProjectsJwtId in case
// they later choose the last one to keep the offers as new
this.$notify(
{
group: "alert",
type: "info",
title: "Marked as Read",
text: "The offers are marked as viewed. Click in the list to keep them as new.",
},
5000,
);
}
}
async markOffersToUserProjectsAsReadStartingWith(jwtId: string) {
const index = this.newOffersToUserProjects.findIndex(
(offer) => offer.jwtId === jwtId,
);
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId,
});
}
this.$notify(
{
group: "alert",
type: "info",
title: "Marked as Unread",
text: "All offers above that one are marked as unread.",
},
3000,
);
}
}
</script>

29
src/views/NewEditAccountView.vue

@ -22,9 +22,10 @@
/> />
<div class="mt-8"> <div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button <button
type="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" 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()" @click="onClickSaveChanges()"
> >
Save Changes Save Changes
@ -32,19 +33,22 @@
<!-- SHOW ME instead while processing saving changes --> <!-- SHOW ME instead while processing saving changes -->
<button <button
type="button" type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onClickCancel()" @click="onClickCancel()"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { db } from "@/db/index"; import { Router } from "vue-router";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({ @Component({
components: {}, components: {},
@ -54,25 +58,22 @@ export default class NewEditAccountView extends Vue {
// 'created' hook runs when the Vue instance is first created // 'created' hook runs when the Vue instance is first created
async created() { async created() {
await db.open(); const settings = await retrieveSettingsForActiveAccount();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.givenName = this.givenName =
(settings?.firstName || "") + (settings.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3 (settings.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
} }
onClickSaveChanges() { async onClickSaveChanges() {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName, firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3 lastName: "", // deprecated, pre v 0.1.3
}); });
localStorage.setItem("firstName", this.givenName as string); (this.$router as Router).back();
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.back();
} }
onClickCancel() { onClickCancel() {
this.$router.back(); (this.$router as Router).back();
} }
} }
</script> </script>

67
src/views/NewEditCommitmentView.vue

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

552
src/views/NewEditProjectView.vue

@ -29,6 +29,44 @@
v-model="fullClaim.name" v-model="fullClaim.name"
/> />
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<input
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid"
/>
<div class="mb-4">
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
<span class="text-red-500">Beware!</span>
If you save this, the original project owner will no longer be able to
edit it.
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
Click here to make the original owner an authorized representative.
</button>
</p>
</div>
<textarea <textarea
placeholder="Description" placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
@ -36,6 +74,9 @@
v-model="fullClaim.description" v-model="fullClaim.description"
maxlength="5000" maxlength="5000"
></textarea> ></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
If you want to be contacted, be sure to include your contact information.
</div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4"> <div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description?.length }}/5000 max. characters {{ fullClaim.description?.length }}/5000 max. characters
</div> </div>
@ -43,28 +84,45 @@
<input <input
v-model="fullClaim.url" v-model="fullClaim.url"
placeholder="Website" placeholder="Website"
autocapitalize="none"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/> />
<div class="flex items-center mb-4"> <div class="flex mb-4 columns-3 w-full">
<input <input
type="checkbox" v-model="startDateInput"
class="mr-2" placeholder="Start Date"
v-model="includeLocation" type="date"
@click="includeLocation = !includeLocation" class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
/> />
<label for="includeLocation">Include Location</label> <input
:disabled="!startDateInput"
placeholder="Start Time"
v-model="startTimeInput"
type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/>
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
</div> </div>
<div v-if="includeLocation" style="height: 600px; width: 800px">
<div class="px-2 py-2"> <div
For your security, we recommend you choose a location nearby but not class="flex items-center mb-4"
exactly at the place. @click="includeLocation = !includeLocation"
>
<input type="checkbox" class="mr-2" v-model="includeLocation" />
<label for="includeLocation">Include Location</label>
</div> </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 <l-map
ref="map" ref="map"
v-model:zoom="zoom" v-model:zoom="zoom"
:center="[0, 0]" :center="[0, 0]"
class="!z-40 rounded-md"
@click=" @click="
(event) => { (event) => {
latitude = event.latlng.lat; latitude = event.latlng.lat;
@ -80,15 +138,30 @@
<l-marker <l-marker
v-if="latitude && longitude" v-if="latitude && longitude"
:lat-lng="[latitude, longitude]" :lat-lng="[latitude, longitude]"
@click="maybeEraseLatLong()" @click="confirmEraseLatLong()"
/> />
</l-map> </l-map>
</div> </div>
<div
v-if="showGeneralAdvanced && includeLocation"
class="items-center mb-4"
>
<div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<label>Send to Trustroots</label>
</div>
<div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label>
</div>
</div>
<div class="mt-8"> <div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button <button
:disabled="isHiddenSave" :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" 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()" @click="onSaveProjectClick()"
> >
<!-- SHOW if in idle state --> <!-- SHOW if in idle state -->
@ -98,49 +171,62 @@
<span :class="{ hidden: isHiddenSpinner }"> <span :class="{ hidden: isHiddenSpinner }">
<!-- icon no worky? --> <!-- icon no worky? -->
<i class="fa-solid fa-spinner fa-spin-pulse"></i> <i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving&hellip;</span Saving...</span
> >
</button> </button>
<button <button
type="button" type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onCancelClick()" @click="onCancelClick()"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios"; import { AxiosError, AxiosRequestHeaders } from "axios";
import * as didJwt from "did-jwt"; import { DateTime } from "luxon";
import { hexToBytes } from "@noble/hashes/utils";
import type { EventTemplate, VerifiedEvent } from "nostr-tools/lib/types/core";
import { accountFromSeedWords } from "nostr-tools/nip06";
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { accountsDB, db } from "@/db/index"; import {
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; DEFAULT_IMAGE_API_SERVER,
import { accessToken, SimpleSigner } from "@/libs/crypto"; DEFAULT_PARTNER_API_SERVER,
import { useAppStore } from "@/store/app"; NotificationIface,
import { IIdentifier } from "@veramo/core"; } from "@/constants/app";
import { PlanVerifiableCredential } from "@/libs/endorserServer"; import { accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
import {
interface Notification { createEndorserJwtVcFromClaim,
group: string; getHeaders,
type: string; PlanVerifiableCredential,
title: string; } from "@/libs/endorserServer";
text: string; import { getAccount } from "@/libs/util";
}
@Component({ @Component({
components: { LMap, LMarker, LTileLayer, QuickNav }, components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
}) })
export default class NewEditProjectView extends Vue { export default class NewEditProjectView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message: string) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = ""; activeDid = "";
agentDid = "";
apiServer = ""; apiServer = "";
errorMessage = ""; errorMessage = "";
fullClaim: PlanVerifiableCredential = { fullClaim: PlanVerifiableCredential = {
@ -149,98 +235,170 @@ export default class NewEditProjectView extends Vue {
name: "", name: "",
description: "", description: "",
}; // this default is only to avoid errors before plan is loaded }; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false; includeLocation = false;
isHiddenSave = false;
isHiddenSpinner = true;
lastClaimJwtId = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
numAccounts = 0; numAccounts = 0;
projectId = "";
projectIssuerDid = "";
sendToTrustroots = false;
sendToTripHopping = false;
showGeneralAdvanced = false;
startDateInput?: string;
startTimeInput?: string;
zoneName = DateTime.local().zoneName;
zoom = 2; zoom = 2;
async beforeCreate() { async mounted() {
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) { const settings = await retrieveSettingsForActiveAccount();
await accountsDB.open(); this.activeDid = settings.activeDid || "";
const account = await accountsDB.accounts this.apiServer = settings.apiServer || "";
.where("did") this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
.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;
}
public async getHeaders(identity: IIdentifier) { this.projectId =
const token = await accessToken(identity); (this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
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 || "";
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("Error: no account was found."); this.errNote("There was a problem loading your account info.");
} else { } else {
const identity = await this.getIdentity(this.activeDid); this.loadProject(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.",
);
}
this.LoadProject(identity);
} }
} }
} }
async LoadProject(identity: IIdentifier) { async loadProject(userDid: string) {
const url = const url =
this.apiServer + this.apiServer +
"/api/claim/byHandle/" + "/api/claim/byHandle/" +
encodeURIComponent(this.projectId); encodeURIComponent(this.projectId);
const token = await accessToken(identity); const headers = await getHeaders(userDid);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim; this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) { if (this.fullClaim?.location) {
this.includeLocation = true; this.includeLocation = true;
this.latitude = this.fullClaim.location.geo.latitude; this.latitude = this.fullClaim.location.geo.latitude;
this.longitude = this.fullClaim.location.geo.longitude; this.longitude = this.fullClaim.location.geo.longitude;
} }
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
).toLocal();
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
} }
} catch (error) { } catch (error) {
console.error("Got error retrieving that project", error); console.error("Got error retrieving that project", error);
this.errNote("There was an error retrieving that project.");
}
} }
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
} }
private async SaveProject(identity: IIdentifier) { 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 headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
return;
}
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
private async saveProject() {
// Make a claim // Make a claim
const vcClaim: PlanVerifiableCredential = this.fullClaim; const vcClaim: PlanVerifiableCredential = this.fullClaim;
if (this.projectId) { if (this.projectId) {
vcClaim.identifier = this.projectId; vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
vcClaim.agent = {
identifier: this.agentDid,
};
} else {
delete vcClaim.agent;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
} }
if (this.includeLocation) { if (this.includeLocation) {
vcClaim.location = { vcClaim.location = {
@ -250,58 +408,71 @@ export default class NewEditProjectView extends Vue {
longitude: this.longitude, longitude: this.longitude,
}, },
}; };
} else {
delete vcClaim.location;
} }
// Make a payload for the claim if (this.startDateInput) {
const vcPayload = { try {
vc: { const startTimeFull = this.startTimeInput || "00:00:00";
"@context": ["https://www.w3.org/2018/credentials/v1"], const fullTimeString = this.startDateInput + " " + startTimeFull;
type: ["VerifiableCredential"], // throw an error on an invalid date or time string
credentialSubject: vcClaim, vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.startTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "The date was invalid so it was not set.",
}, },
}; 5000,
// create a signature using private key of identity );
if (identity.keys[0].privateKeyHex != null) { }
const privateKeyHex: string = identity.keys[0].privateKeyHex; } else {
const signer = await SimpleSigner(privateKeyHex); delete vcClaim.startTime;
const alg = undefined; }
// create a JWT for the request const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim);
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim"; const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity); const headers = await getHeaders(this.activeDid);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
// handleId is new in server v release-1.6.0; remove fullIri when that if (resp.data?.success?.handleId) {
// version shows up here: https://api.endorser.ch/api-docs/
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
this.errorMessage = ""; this.errorMessage = "";
// handleId is new in server v release-1.6.0; remove fullIri when that const projectPath = encodeURIComponent(resp.data.success.handleId);
// version shows up here: https://api.endorser.ch/api-docs/
useAppStore().setProjectId( let signedPayload: VerifiedEvent | undefined; // sign something to prove ownership of pubkey
resp.data.success.handleId || resp.data.success.fullIri, if (this.sendToTrustroots) {
signedPayload = await this.signPayload();
this.sendToNostrPartner(
"NOSTR-EVENT-TRUSTROOTS",
"Trustroots",
resp.data.success.claimId,
signedPayload,
); );
setTimeout( }
function (that: NewEditProjectView) { if (this.sendToTripHopping) {
that.$router.push({ name: "project" }); if (!signedPayload) {
}, signedPayload = await this.signPayload();
2000, }
this, this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
signedPayload,
); );
}
(this.$router as Router).push({ path: "/project/" + projectPath });
} else { } else {
console.log( console.error(
"Got unexpected 'data' inside response from server", "Got unexpected 'data' inside response from server",
resp, resp,
); );
@ -321,9 +492,11 @@ export default class NewEditProjectView extends Vue {
error?: { message?: string }; error?: { message?: string };
}>; }>;
if (serverError) { if (serverError) {
console.log("Got error from server", serverError); console.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) { 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( this.$notify(
{ {
group: "alert", group: "alert",
@ -345,10 +518,7 @@ export default class NewEditProjectView extends Vue {
); );
} }
} else { } else {
console.error( console.error("Here's the full error trying to save the claim:", error);
"Here's the full error trying to save the claim:",
error,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -363,6 +533,114 @@ export default class NewEditProjectView extends Vue {
this.errorMessage = userMessage; this.errorMessage = userMessage;
} }
} }
private async signPayload(): Promise<VerifiedEvent> {
const account = await getAccount(this.activeDid);
// get the last number of the derivationPath
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
// remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0);
const pubPri = accountFromSeedWords(
account?.mnemonic as string,
"",
accountNum,
);
const privateBytes = hexToBytes(pubPri?.privateKey);
// No real content is necessary, we just want something signed,
// so we might as well use nostr libs for nostr functions.
// Besides: someday we may create real content that we can relay.
const event: EventTemplate = {
kind: 30402,
tags: [[]],
content: "",
created_at: 0,
};
// Why does IntelliJ not see matching types?
const signedEvent = finalizeEvent(event, privateBytes);
return signedEvent;
}
private async sendToNostrPartner(
linkCode: string,
serviceName: string,
jwtId: string,
signedPayload: VerifiedEvent,
) {
// first, get the public key for nostr
const account = await getAccount(this.activeDid);
// get the last number of the derivationPath
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
// remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0);
const pubPri = accountFromSeedWords(
account?.mnemonic as string,
"",
accountNum,
);
const nostrPubKey = pubPri?.publicKey;
const trustrootsUrl = DEFAULT_PARTNER_API_SERVER + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
// Why does IntelliJ not see matching types?
const payload = serializeEvent(signedPayload);
const trustrootsParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: nostrPubKey,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const fullTrustrootsUrl = trustrootsUrl;
const headers = await getHeaders(this.activeDid);
try {
const linkResp = await this.axios.post(
fullTrustrootsUrl,
trustrootsParams,
{ headers },
);
if (linkResp.status === 201) {
this.$notify(
{
group: "alert",
type: "success",
title: `Sent to ${serviceName}`,
text: `The project info was sent to ${serviceName}.`,
},
5000,
);
} else {
// axios never gets here because it throws an error, but just in case
this.$notify(
{
group: "alert",
type: "danger",
title: `Failed Sending to ${serviceName}`,
text: JSON.stringify(linkResp.data),
},
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error(`Error sending to ${serviceName}`, error);
let errorMessage = `There was an error sending to ${serviceName}.`;
if (error.response?.data?.error?.message) {
errorMessage = error.response.data.error.message;
}
this.$notify(
{
group: "alert",
type: "danger",
title: `Error Sending to ${serviceName}`,
text: errorMessage,
},
5000,
);
}
} }
public async onSaveProjectClick() { public async onSaveProjectClick() {
@ -372,21 +650,33 @@ export default class NewEditProjectView extends Vue {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("Error: there is no account."); console.error("Error: there is no account.");
} else { } else {
const identity = await this.getIdentity(this.activeDid); this.saveProject();
this.SaveProject(identity);
} }
} }
public maybeEraseLatLong() { confirmEraseLatLong() {
if (window.confirm("Are you sure you don't want to mark a location?")) { this.$notify(
{
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
public eraseLatLong() {
this.latitude = 0; this.latitude = 0;
this.longitude = 0; this.longitude = 0;
this.includeLocation = false; this.includeLocation = false;
} }
}
public onCancelClick() { public onCancelClick() {
this.$router.back(); (this.$router as Router).back();
} }
} }
</script> </script>

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

Loading…
Cancel
Save