Compare commits

...

184 Commits

Author SHA1 Message Date
ec96bd8235 bump version to 0.3.54 2025-02-06 20:21:02 -07:00
62ae603778 fix linting 2025-02-06 20:16:04 -07:00
b8ca2a03fe add 'isRegistered' flag to encrypted contents in an onboarding meeting 2025-02-06 19:58:09 -07:00
287a440b3e show a better message when admission to an onboarding meeting succeeds but registration fails 2025-02-06 19:35:33 -07:00
9411096ab7 prompt organizer about adding a contact if not in list, and other sanity checks 2025-02-05 21:03:57 -07:00
fe71c3f754 make member view available to onboard meeting organizer and reorganize buttons 2025-02-05 20:07:25 -07:00
93831c372a fix problem with you-are-missing message and refactor other messages in onboard meeting 2025-02-05 19:03:54 -07:00
34248a2ee5 fix test 2025-02-04 20:12:08 -07:00
0b05ca3de8 fix linting 2025-02-03 20:37:46 -07:00
dffecae565 now add registration when the organizer admits them 2025-02-03 20:31:22 -07:00
4cd130244c add an icon for each attendee to add them to their contact list 2025-02-03 19:55:41 -07:00
d5f4337558 organizer can toggle admission to the meeting 2025-02-03 19:00:11 -07:00
114f0e4405 fix message for when some passwords are wrong (and now things decode correctly) 2025-02-03 17:22:38 -07:00
64830eeb05 fix linting (and change a little wording in onboarding page) 2025-02-03 16:36:13 -07:00
dd281e78fd show when an onboarding member is already in a meeting, and allow them to leave 2025-02-03 15:31:00 -07:00
31d573684a split out group-meeting member list into a separate component, and fix some edit/create mode titles 2025-02-03 14:30:49 -07:00
40765feea1 move edit & delete around & eliminate redundant boolean 2025-02-03 12:39:16 -07:00
5ff91186e2 add onboarding pages for the list and members, and refine the setup 2025-02-03 12:18:13 -07:00
2a23587c3b make screen where user can create a group onboarding meeting 2025-02-02 17:06:51 -07:00
51c8d8ac8b change to three prompts for an onboarding-method choice (first one doesn't work yet) 2025-02-01 20:33:48 -07:00
65cc13977d bump version and add "-beta" 2025-01-30 08:53:28 -07:00
30552916a2 bump version to 0.3.53 2025-01-30 08:39:14 -07:00
920d3f4d25 fix linting, add to the 10-project timeout 2025-01-29 21:36:27 -07:00
d57aee203f add instructions for contacting potential links to hidden people 2025-01-29 21:01:35 -07:00
7ababb4e1b fix problem after minimizing use of account private data 2025-01-28 09:06:29 -07:00
41041d72c0 fix problem with production map endpoint server, and bump version to 0.3.52 2025-01-22 21:20:12 -07:00
93bf3d2c08 bump to version 0.3.51, tweak README for deployments 2025-01-22 20:15:04 -07:00
0576fc4187 fix a jump on user profile map move & recenter 2025-01-21 19:51:58 -07:00
b9fedcd3fd bump to version 0.3.50 2025-01-21 18:05:22 -07:00
802130d3b6 fix the marker storage & clearing logic, and add the second profile map when used 2025-01-21 18:03:32 -07:00
aec530f5a8 fix build and auto-test issues 2025-01-20 13:06:05 -07:00
5763fe4e49 add page for user profile view and update endpoints; rename any "rowid" to "rowId" 2025-01-20 12:43:05 -07:00
f3f8aeefc3 add discovery of people's profiles, and update profile endpoints for latest server version 2025-01-18 20:02:20 -07:00
8eb8b746d7 add map and location for user profile 2025-01-13 19:27:17 -07:00
0e52f4806f fix linting 2025-01-13 19:02:50 -07:00
7c90cf908a save a profile blurb 2025-01-12 20:33:47 -07:00
881c5d287d fix linting 2025-01-12 17:01:03 -07:00
be010f7777 show warning about using nostr, and show errors if location was enabled but data is missing 2025-01-12 16:57:20 -07:00
5c58f75d82 fix test number check that increments 2025-01-11 16:41:26 -07:00
67c440fde5 bump version and add "-beta" 2025-01-10 07:32:48 -07:00
36301ed238 bump to version 0.3.49 2025-01-09 20:48:13 -07:00
6514f52b92 change all copied contact URLs to contact-import, and handle multiples & singles separately 2025-01-09 20:10:00 -07:00
5f39beef55 run the tests on a different port (so there's no conflict when running the development server) 2025-01-09 08:51:09 -07:00
7f6688ee53 bump version and add "-beta" 2025-01-08 08:37:07 -07:00
398f3e64a3 bump version to 0.3.48 2025-01-08 08:35:50 -07:00
07c4e58e87 add sanity checks for importing bulk contacts, eg. when there is a truncated link 2025-01-07 20:56:39 -07:00
c7b570d01f bump version and add "-beta" 2025-01-06 12:19:35 -07:00
57a09cf9fb update to 0.3.47 - fix linting 2025-01-06 09:01:06 -07:00
c6d77e20f2 bump version to 0.3.47 2025-01-06 08:58:11 -07:00
702e44872f switch so personal contact JWT is link to this server (not endorser.ch), make empty-did URL show user's info 2025-01-06 08:52:10 -07:00
0a7645b8e7 fix tests that were broken by contact-edit page changes 2025-01-05 19:37:49 -07:00
3c1731acdf add contact-methods to a contact 2025-01-04 20:34:05 -07:00
6cf28776b7 update instructions for test-all testing 2025-01-04 20:32:56 -07:00
defbef736f fix any error messages with words that are too long and push the "X" off the page 2025-01-04 18:19:25 -07:00
f405e7d02f make notification errors go away automatically 2025-01-04 18:02:10 -07:00
086ccce0bb add a contact-edit page and allow saving of notes 2025-01-04 16:35:05 -07:00
7b73e9f51d move extended details under details section in ClaimView, and make that section more similar to ConfirmGiftView 2025-01-04 14:55:20 -07:00
cb34c52c40 bump version and add "-beta" 2025-01-04 13:15:39 -07:00
3b6d981046 bump version to 0.3.46 and fix vulnerabilities 2025-01-03 13:01:51 -07:00
346bd1dbb4 change gifted prompts to point to achievements that were made possible 2025-01-03 12:32:05 -07:00
15e00f9be0 fix verbiage when looking at new offers 2025-01-03 09:48:33 -07:00
7db5b9875b fix error where visibility was set on all imported contacts even if not selected (and specify more types) 2025-01-03 09:47:42 -07:00
55abb5d925 add test that copies contact-import JWT to clipboard and imports from it 2025-01-01 20:08:21 -07:00
09c3e3220c bump version and add "-beta" 2025-01-01 14:12:49 -07:00
a5c90db615 bump version to 0.3.45 2025-01-01 14:07:18 -07:00
1e66bc1126 fix problem switching projects where old link data remained 2025-01-01 13:55:42 -07:00
a8d90ae0fd add ability to edit & resubmit any raw claim 2025-01-01 12:51:58 -07:00
d9aa512350 bump version and add "-beta" 2024-12-31 10:49:04 -07:00
6fc23e4765 fix centering of numbers on map markers, fix recenter after first drag 2024-12-31 10:26:48 -07:00
f524714fbf fix linting 2024-12-30 14:53:09 -07:00
3b59dbc558 bump version to 0.3.43 2024-12-30 13:56:55 -07:00
56bbc3f4cc show results for project map grouping when clicked 2024-12-30 13:51:42 -07:00
d8325240f0 load the projects on the map on initial load 2024-12-30 13:05:04 -07:00
a8b404133e add requests for map tiles with counts of plans (commented out) 2024-12-29 19:14:23 -07:00
c98859fc7e add more debug information on errors caught from server 2024-12-28 16:29:57 -07:00
f6509b4013 make invite notes the default user name when adding a contact 2024-12-28 16:29:13 -07:00
e279582443 bump version and add "-beta" 2024-12-28 16:28:53 -07:00
ecd4367196 shrink the input to fit on DuckDuckGo on Pixel 6a 2024-12-27 09:13:51 -07:00
67b4d0e953 add more visibility for invite JWT plus a check for JWT, bump version to 0.3.42 2024-12-27 09:06:25 -07:00
f4dd7bafca add more details for terms of data & service use 2024-12-24 16:20:28 -07:00
e083585379 reword some onboarding phrases 2024-12-24 14:01:12 -07:00
8ca3df31fb remove actions from contact-import if all are the same as existing ones 2024-12-24 13:48:55 -07:00
a99a0fb5cc change the contact-sharing data into a JWT for the contact-import page 2024-12-23 20:07:14 -07:00
7228f5a01b make feed pictures larger 2024-12-23 20:07:00 -07:00
762dfa0f2a fix the verificationMethod type in the local ETHR DID resolver 2024-12-23 20:05:33 -07:00
25829f4ae0 bump to version 0.3.41 2024-12-21 16:24:38 -07:00
f51bbd61b0 make claim certificate image clickable 2024-12-21 15:20:56 -07:00
7989ef5071 bump version and add "-beta" 2024-12-20 20:25:18 -07:00
acc9dc17ae fix linting 2024-12-20 20:21:42 -07:00
ec0e4693cb don't show issuer for self-issued claims 2024-12-20 20:19:55 -07:00
6f81fc88f3 bump version and add "-beta" 2024-12-20 20:00:35 -07:00
778d26e7bf bump to version 0.3.39 2024-12-20 19:57:32 -07:00
40382157f9 fix linting 2024-12-20 19:12:21 -07:00
f21555184c for certificate: fix the canvas to fit in the middle vertically, add amount, and position things better 2024-12-20 19:09:20 -07:00
e67ae23879 add number of confirmers to certificate & show DID info when appropriate 2024-12-20 15:49:42 -07:00
2cb70f8497 add copy-link on the claim view page & enable certificate 2024-12-18 16:31:27 -07:00
959f5f6f63 add another sample boundary frame for the certificate view of a claim 2024-12-18 16:08:48 -07:00
6d1681cb07 refine claim certificate view 2024-12-18 16:05:43 -07:00
f228e27eb9 bump version and add -beta 2024-12-14 14:39:01 -07:00
1e70af12fe bump version to 0.3.38 2024-12-14 14:33:11 -07:00
e9aeec48ed fix a missed accountsDB reference 2024-12-14 14:30:51 -07:00
e22378675c bump version to 0.3.37 2024-12-13 14:05:00 -07:00
5a56f9ab30 tweak verbiage 2024-12-13 13:42:21 -07:00
0a314934b8 add invite-one-accept screen dedicated to accepting invitations 2024-12-13 13:27:22 -07:00
49aff7e488 fix the wording on accepted invite 2024-12-11 08:29:40 -07:00
7a80474c5c don't allow clicking on the invite link if they're not registered 2024-12-10 20:20:24 -07:00
6ffbcfa9a1 catch more errors if something catastrophic happens to encrypted data 2024-12-10 20:02:49 -07:00
8763ade341 add license file 2024-12-08 21:22:03 -07:00
6274f083a1 add DB file for the secret 2024-12-08 21:21:16 -07:00
bb3807a805 switch the encryption secret from localStorage to IndexedDB (because localStorage gets lost so often) 2024-12-08 19:34:31 -07:00
fb0d855fac rename variables for clarity 2024-12-04 20:31:59 -07:00
e6f5511dbb add page for a printable certificate (which works but isn't too impressive yet) 2024-12-01 20:20:03 -07:00
76280b7ee5 add periods "." at the end of the giver & receiver sentences 2024-11-30 17:28:46 -07:00
9861a1388e allow to deselect the giver & refactor dialog to group giver vs recipient 2024-11-30 17:27:16 -07:00
5effb76cf5 ensure overlays show on top of relative+absolute positioning like green pluses 2024-11-30 15:30:17 -07:00
658214abb6 fix linting 2024-11-30 14:36:45 -07:00
f1163d8302 add words under feed, add big "plus" on first page, and reword some things 2024-11-30 14:33:31 -07:00
7acf921e82 refactor some verbiage & look-and-feel 2024-11-30 13:16:58 -07:00
5fc021b197 fix problem on claim view showing issuer name 2024-11-29 19:57:38 -07:00
92fbde4f51 fix error on project screen hitting "back" with the chevron 2024-11-29 19:57:05 -07:00
f7fd568c60 add tests for gives to & from projects 2024-11-29 10:42:58 -07:00
10bb79f695 refactor project screen: add action to record a give from it, and add checks to give confirmation buttons 2024-11-28 11:26:51 -07:00
1cef64c1ec bump version and add "-beta" 2024-11-26 08:31:26 -07:00
60f066bda0 change default reminder message; show people & unnamed icons blue for clickable 2024-11-24 20:14:30 -07:00
4db6bbd8d5 bump version and add "-beta" 2024-11-24 17:53:23 -07:00
fa46663dda fix problem when notification subscription isn't found 2024-11-24 17:40:29 -07:00
7777fa202b finish separation of daily reminder message, bump version to 0.3.34 2024-11-24 13:09:40 -07:00
8735fe44db change the notification detection to our own variables, and save the selected time 2024-11-20 19:55:51 -07:00
2a652d2079 make the import selection more obvious, plus other verbiage 2024-11-18 18:22:11 -07:00
75fb4da42d move push notification setup out of an App.vue Notification and into a component 2024-11-18 17:00:06 -07:00
6dc44b2494 move more logging into the database 2024-11-17 18:16:22 -07:00
2c0c7ac256 add minute to notification scheduling & fix a bug, plus other tweaks 2024-11-15 20:39:08 -07:00
f06eb27ba0 Revert "after npx cap add ... for ios & android"
This reverts commit 17f304ddb8.
2024-11-15 20:38:40 -07:00
a1c1c9f805 add way to quickly import test data when on a test instance 2024-11-11 16:37:28 -07:00
17f304ddb8 after npx cap add ... for ios & android 2024-11-10 20:17:45 -07:00
6605fbd708 bump version and add "-beta"; add capacitor libraries 2024-11-10 20:16:19 -07:00
9b079ee5f2 update changelog 2024-11-10 19:07:39 -07:00
a3b10d9a78 adjust test lines to await/expect appropriately 2024-11-07 19:07:45 -07:00
a73f0239c9 fix problem not showing user's projects on project page 2024-11-07 18:48:52 -07:00
8466bb0b1f fix linting problem (NOW we'll deploy 0.3.33) 2024-11-07 18:24:01 -07:00
71675edc3f bump all files to 0.3.33 2024-11-07 18:21:09 -07:00
7ef8263d49 bump version to 0.3.33 2024-11-07 18:19:52 -07:00
bacf9d7de6 fix problem with "Affirm Delivery" on offer claim page, plus other look-and-feel tweaks 2024-11-07 18:17:33 -07:00
79a530aff5 bump version to 0.3.32 2024-11-05 19:58:00 -07:00
c004706425 add pages to see all the offers to user and offers to user's projects 2024-11-05 19:03:12 -07:00
0d880d1edc add "+" to numbers if hit limit (>50), fix linting 2024-11-05 09:06:04 -07:00
f96c5892e7 add test for user-project offers on front page 2024-11-05 05:50:15 -07:00
195ba6c759 add new projects to front page 2024-11-04 19:57:39 -07:00
5f452dcf73 add tests for new activity of offers-directly-to-user 2024-11-03 20:09:54 -07:00
fcec9e53f5 add better verbiage when an offer has both description and amount 2024-11-03 17:30:40 -07:00
dbf010c1fe mark new-activity offers as seen, and mark them unseen again 2024-11-03 17:20:54 -07:00
67b2b7199a fix tests (from project-page switch 4 commits ago) and fix linting 2024-11-03 15:23:03 -07:00
4168c37074 add large notice when user has a new offer to them 2024-11-03 10:39:28 -07:00
8a61d9df45 various look-and-feel improvements 2024-11-01 20:32:39 -06:00
eb90c9ebae still 0.3.31, fix linting 2024-10-25 15:14:37 -06:00
e1d0a2b02c bump to version 0.3.31, tweak messaging to include offers 2024-10-25 15:12:06 -06:00
42dcb3b43c tweak onboarding messages 2024-10-24 20:50:27 -06:00
00b191c4fd suggest new user going to the front page 2024-10-24 20:04:08 -06:00
45214eabc5 adjust tests for new onboarding messages 2024-10-23 09:07:34 -06:00
53abf964b2 add basic page-by-page onboarding help 2024-10-23 08:27:16 -06:00
6f880d0df1 fix bad link to project page, fix improper action on invite-add-contact cancel 2024-10-12 20:55:55 -06:00
9c527b27f8 enhance help & help onboarding 2024-10-10 08:53:12 -06:00
14cc309d25 bump to version 0.3.29 2024-10-09 21:06:46 -06:00
fe482d06f6 show more redeemed info & action on the invites, refactor onboarding instructions 2024-10-09 20:45:06 -06:00
7fabb78ae3 improve messages on invite page 2024-10-08 20:25:55 -06:00
6e248f0385 add an invite-delete function 2024-10-08 20:13:07 -06:00
98afa8a259 refactor invite link & add test 2024-10-08 08:36:32 -06:00
2e100aedf5 fix linting 2024-10-06 20:07:05 -06:00
149481d468 finish the loading of an invite RegisterAction when clicking on a link 2024-10-06 20:01:07 -06:00
1bfdcab90b add page for one-on-one invites (incomplete) 2024-10-05 18:35:59 -06:00
9f4a19993e update nostr message to include signature for public key 2024-10-04 13:24:07 -06:00
5efd3e0e89 bump to version 3.0.28 2024-09-30 20:29:19 -06:00
4edcefd0f0 fix verbiage for recipient on home page 2024-09-30 19:54:14 -06:00
1fccf0fa92 change give provider to a single value 2024-09-30 18:33:15 -06:00
9925800fbd allow details on a give for a providing project (so we can attach a picture) 2024-09-30 18:11:07 -06:00
7c70e699d8 switch BVC-meeting-end gift to be from the plan, and add display of providers on claim-view page 2024-09-28 17:31:58 -06:00
a271d9c206 add link directly into contact page to add a new contact via "contactJwt" query parameter 2024-09-27 18:41:11 -06:00
2942a02a4e fix another vulnerability 2024-09-26 20:12:18 -06:00
eecca9b345 fix some vulnerabilities 2024-09-26 20:05:56 -06:00
8868d17c85 bump version and add "-beta" 2024-09-26 09:16:58 -06:00
3831cda76d Merge branch 'nostr' 2024-09-26 09:14:56 -06:00
e6b9ef237b bump to version 0.3.27 2024-09-22 13:30:42 -06:00
791c0a0a5e update caniuse-lite 2024-09-22 13:28:18 -06:00
cd9f6b448b add more specific check to avoid complaint about multiple matches 2024-09-22 13:27:21 -06:00
105 changed files with 15223 additions and 4634 deletions

View File

@@ -1,3 +1,5 @@
# 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.
VITE_APP_SERVER=http://localhost:8080

View File

@@ -1,5 +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
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch

View File

@@ -6,7 +6,164 @@ 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).
## [0.3.?]
## [0.3.54] - 2025.02.06
### Added
- Group onboarding meetings
## [0.3.53] - 2025.01.30
### Added
- Hints for contacting the creator of a project
## [0.3.52] - 2025.01.22
### Fixed
- User profile endpoint server for map was broken.
## [0.3.51] - 2025.01.22
### Fixed
- User profile map jumped on first zoom.
## [0.3.50] - 2025.01.20 - b9fedcd3fd3e34c3fb0fc79150d1a81a76eaeb40
### Added
- User public profiles
## [0.3.49] - 2025.01.09 - 36301ed238ff84df25bb11a8d44a295ee7eaf0f8
### Changed
- Make all external contact links direct to the contact-import page.
- Handle all new-single-contact JWTs in the contacts page, and multiple-contact JWTs in the contacts-import page.
## [0.3.48] - 2025.01.08 - 398f3e64a376789f7eb1c400cd886f5a2cacd588 (but app shows 07c4e58)
### Added
- More sanity-checks on contact-import JWT
## [0.3.47] - 2025.01.06 - 5bf6dd1ee32ca7cc46d39bd7afca58365b422f93
### Added
- Notes on contacts page with new contact-edit page
- Contact methods (only on contact-edit page and under DID details)
- DID view with no DID shows user's info.
### Changed
- URL for user's contact info is now URL to this app (not endorser.ch).
- Extended details (eg. full claim) is beneath details link on claim page.
## [0.3.46] - 2025.01.03 - 9e7056616b5e5acc51e5a8cf7354d408029fefb3
### Added
- More action-oriented questions for the gift prompts
### Fixed
- Contact-list import set visibility for all, even if not chosen.
## [0.3.45] - 2025.01.01 - 65402dc68ce69ccc6cb9aa8d2e7a9249bf4298e0
### Fixed
- Previous project links stayed when following a link.
## [0.3.44] - 2024.12.31 - 694b22987b05482e4527c2478bbe15e6b6f3b532
### Added
- Project counts on a map
## [0.3.42] - 2024.12.27 - 9751934bc24a1040415a8cfeacbae59ed91f92a5
### Added
- Link from certificate page to the claim
### Changed
- Contact data sharing is now a verified JWT.
- Feed pictures are larger.
## [0.3.41] - 2024.12.21 - ff6d14138f26daea6216b051562f0a04681f69fc
### Added
- Link from certificate page to the claim
## [0.3.40] - 2024.12.20 - 77290d9fed3c364243793dc3e9bfe2e994a016b8
### Added
- Only show issuer on certificate if it's not the agent.
## [0.3.39] - 2024.12.20 - d8819155e2acd2b57fdab523168fa5d1d09e80cc
### Added
- Page for a framed claim certificate
## [0.3.38] - 2024.12.14 - f8cae5ad4fee1f114320dcce052299eab12108b2
### Fixed
- Error on BVC confirmation screen (from IndexedDB refactor)
## [0.3.37] - 2024.12.13 - 4d805b43cd25eed73cdd6651f36ad1ec8c109555
### Added
- Record a give from a project on the project page.
- New button on home page opens the gifted dialog.
- On confirmation buttons on the project page gives, mark when unavailable and explain why.
### Changed
- Moved the secret into IndexedDB (and out of localStorage) for more reliability.
- New "invite" destination page helps troubleshoot when JWT link doesn't come through.
### Fixed
- Problem showing claim issuer name
- Problem going "back" from a project page
## [0.3.36] - 2024.11.24 - c8d23647d165016f8a8f575e13d32583242e53ac
### Changed
- More friendly default reminder message
- Blue borders around people to indicate clickability
## [0.3.35] - 2024.11.24 - bff7d0a6320b70349185e26bfac72e3bb17f76df
### Added
- Daily reliable, hard-coded notification message
- Setting to change the partner API server
## [0.3.33] - 2024.11.07 - adb7b16ecf1343c39cba71a7d6bb0e7a973e1102
### 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
- Highlight in green new offers to user & to user's projects on the front page.
## [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

8
LICENSE Normal file
View File

@@ -0,0 +1,8 @@
The author disclaims copyright to this source code. In place of a legal notice, here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
________________________________________________________________
from https://www.sqlite.org/src/info/689401a6cfb4c234 and memorialized here https://spdx.org/licenses/blessing.html

View File

@@ -10,7 +10,7 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
We like pkgx: `sh <(curl https://pkgx.sh) +vite sh`
```
npm install
@@ -33,6 +33,11 @@ npm run serve
npm run lint
```
### Run all UI tests
Look below for the "test-all" instructions.
### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
@@ -43,30 +48,36 @@ npm run lint
* Commit everything (since the commit hash is used the app).
* Record what version is currently on production.
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Run the correct build:
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.36` && `git push origin 0.3.36`.
* For test, build the app (because test server is not yet set up to build):
* 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_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://partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
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
```
* Production
```
# This picks up values from .env.production
npm run build
```
... and transfer to the test server: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Get on the server and back up the time-safari/dist folder.
(Let's replace that with a .env.development or .env.staging file.)
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
(Note: 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.)
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* For prod, get on the server and run the correct build:
... and log onto the server:
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder, then `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* 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)
@@ -79,11 +90,20 @@ Use the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
npm install
test/test.sh
cp .env.local .env
NODE_ENV=test-local npm run dev
```
* Now run the local tests:
If that fails, go to the README.md in the endorser-ch directory and follow the instructions there.
* Install playwright browsers:
```
npx playwright install
```
* Now you can run the local tests:
```
npm run test-all
```
@@ -108,13 +128,11 @@ NODE_ENV=test-local npm run dev
```
To run a single test with the screenshots, use the following:
To run a single test like above with the screenshots, use the following:
```
npx playwright test test-playwright/40-add-contact.spec.ts --trace on
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts
```
... with the `-c playwright.config-local.ts` to get the same results as above.
### Register new user on test server

5920
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.27-beta",
"version": "0.3.54",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@@ -12,6 +12,10 @@
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
},
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2",
"@capacitor/core": "^6.1.2",
"@capacitor/ios": "^6.1.2",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
@@ -24,6 +28,7 @@
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
@@ -32,6 +37,7 @@
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
@@ -41,11 +47,13 @@
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
@@ -56,6 +64,7 @@
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
@@ -84,14 +93,12 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.23.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
@@ -11,7 +11,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
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. */
@@ -21,44 +21,44 @@ export default defineConfig({
/* 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',
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',
baseURL: "http://localhost:8081",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
name: "chromium",
use: {
...devices['Desktop Chrome'],
...devices["Desktop Chrome"],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
/* Test against branded browsers. */
@@ -67,14 +67,14 @@ export default defineConfig({
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
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: 5000,
timeout: 30000, // various tests fail at various times with 25000
/* Run your local dev server before starting the tests */
/**
@@ -91,8 +91,8 @@ export default defineConfig({
*/
webServer: {
command:
"VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
url: "http://localhost:8080",
"VITE_APP_SERVER=http://localhost:8081 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev -- --port=8081",
url: "http://localhost:8081",
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2480 4005 c-25 -7 -58 -20 -75 -29 -16 -9 -40 -16 -52 -16 -17 0
-24 -7 -28 -27 -3 -16 -14 -45 -24 -65 -21 -41 -13 -55 18 -38 25 13 67 13 92
-1 15 -8 35 -4 87 17 99 39 130 41 197 10 64 -29 77 -31 107 -15 20 11 20 11
-3 35 -12 13 -30 24 -38 24 -24 1 -132 38 -148 51 -8 7 -11 20 -7 32 12 37
-40 47 -126 22z"/>
<path d="M1450 3775 c-7 -8 -18 -15 -24 -15 -7 0 -31 -14 -54 -32 -29 -22 -38
-34 -29 -40 17 -11 77 -10 77 1 0 5 16 16 35 25 60 29 220 19 290 -18 17 -9
33 -16 37 -16 4 0 31 -15 60 -34 108 -70 224 -215 282 -353 30 -71 53 -190 42
-218 -10 -27 -23 -8 -52 75 -30 90 -88 188 -120 202 -13 6 -26 9 -29 6 -3 -2
11 -51 30 -108 28 -83 35 -119 35 -179 0 -120 -22 -127 -54 -17 -11 37 -13 21
-18 -154 -5 -180 -8 -200 -32 -264 -51 -132 -129 -245 -199 -288 -21 -12 -79
-49 -129 -80 -161 -102 -294 -141 -473 -141 -228 0 -384 76 -535 259 -81 99
-118 174 -154 312 -31 121 -35 273 -11 437 19 127 19 125 -4 125 -23 0 -51
-34 -87 -104 -14 -28 -33 -64 -41 -81 -19 -34 -22 -253 -7 -445 9 -106 12
-119 44 -170 19 -30 42 -67 50 -81 64 -113 85 -140 130 -169 28 -18 53 -44 61
-62 8 -20 36 -45 83 -76 62 -39 80 -46 151 -54 44 -5 96 -13 115 -18 78 -20
238 -31 282 -19 24 6 66 8 95 5 76 -9 169 24 319 114 32 19 80 56 106 82 27
26 52 48 58 48 5 0 27 26 50 58 48 66 56 70 132 71 62 1 165 29 238 64 112 55
177 121 239 245 37 76 39 113 10 267 -12 61 -23 131 -26 156 -5 46 -5 47 46
87 92 73 182 70 263 -8 l51 -49 -6 -61 c-4 -34 -13 -85 -21 -113 -28 -103 -30
-161 -4 -228 16 -44 32 -67 55 -83 18 -11 39 -37 47 -58 10 -23 37 -53 73 -81
32 -25 69 -57 82 -71 14 -14 34 -26 47 -26 12 0 37 -7 56 -15 20 -8 66 -17
104 -20 107 -10 110 -11 150 -71 50 -75 157 -177 197 -187 18 -5 53 -24 78
-42 71 -51 176 -82 304 -89 61 -4 127 -12 147 -18 29 -9 45 -8 77 6 23 9 50
16 60 16 31 0 163 46 216 76 28 15 75 46 105 69 30 23 69 49 85 58 17 8 46 31
64 51 19 20 40 36 47 36 18 0 77 70 100 120 32 66 45 108 55 173 5 32 16 71
24 87 43 84 43 376 0 549 -27 105 -43 127 -135 188 -30 21 -65 46 -77 57 -13
11 -23 17 -23 14 0 -3 21 -46 47 -94 79 -151 85 -166 115 -263 25 -83 28 -110
28 -226 0 -144 -17 -221 -75 -335 -39 -77 -208 -244 -304 -299 -451 -263 -975
-67 -1138 426 -23 70 -26 95 -28 254 -1 108 -7 183 -14 196 -6 12 -11 31 -11
43 0 32 31 122 52 149 10 13 18 28 18 34 0 5 25 40 56 78 60 73 172 170 219
190 30 12 30 13 6 17 -15 2 -29 -2 -37 -12 -6 -9 -16 -16 -22 -16 -6 0 -23
-11 -39 -24 -15 -12 -33 -25 -40 -27 -17 -6 -82 -60 -117 -97 -65 -70 -75 -82
-107 -133 -23 -34 -35 -46 -37 -35 -3 16 20 87 44 134 6 12 9 34 6 48 -4 22
-8 25 -31 19 -14 -3 -38 -15 -53 -26 -34 -24 -34 -21 -6 28 65 112 184 206
291 227 15 3 39 9 55 12 l27 6 -24 9 c-90 35 -304 -66 -478 -225 -39 -36 -74
-66 -77 -66 -22 0 18 82 72 148 19 23 32 46 28 49 -4 4 -26 13 -49 19 -73 21
-161 54 -171 64 -6 6 -20 10 -32 10 -21 0 -21 -1 -8 -40 45 -130 8 -247 -93
-299 -25 -13 -31 0 -14 29 15 22 1 33 -22 17 -56 -36 -117 -22 -117 28 0 13
-16 47 -35 76 -22 34 -33 60 -29 73 4 16 -3 26 -26 39 -16 10 -30 21 -30 25 1
18 54 64 87 76 l38 13 -33 5 c-30 4 -115 -18 -154 -42 -13 -7 -20 -5 -27 8 -9
16 -12 16 -53 1 -160 -61 -258 -104 -258 -114 0 -7 10 -20 21 -31 103 -91 217
-297 249 -449 28 -135 41 -237 35 -276 -14 -91 -48 -170 -97 -220 -44 -47 -68
-60 -68 -40 0 6 4 12 8 15 5 3 24 35 42 72 l33 67 -6 141 c-4 103 -11 158 -26
205 -12 35 -21 70 -21 77 0 7 -20 56 -45 108 -82 173 -227 322 -392 401 -67
33 -90 39 -163 42 -108 5 -130 10 -130 28 0 20 -63 20 -80 0z"/>
<path d="M3710 3765 c0 -20 8 -28 39 -41 22 -8 42 -22 45 -30 5 -14 42 -19 70
-8 10 4 -7 21 -58 55 -41 27 -79 49 -85 49 -6 0 -11 -11 -11 -25z"/>
<path d="M3173 3734 c-9 -25 10 -36 35 -18 12 8 22 19 22 25 0 16 -50 10 -57
-7z"/>
<path d="M1982 3728 c6 -16 36 -34 44 -26 3 4 4 14 1 23 -7 17 -51 21 -45 3z"/>
<path d="M1540 3620 c0 -5 7 -10 16 -10 8 0 12 5 9 10 -3 6 -10 10 -16 10 -5
0 -9 -4 -9 -10z"/>
<path d="M4467 3624 c-4 -4 23 -27 60 -50 84 -56 99 -58 67 -9 -28 43 -107 79
-127 59z"/>
<path d="M655 3552 c-11 -2 -26 -9 -33 -14 -7 -6 -27 -18 -45 -27 -36 -18 -58
-64 -39 -83 9 -9 25 1 70 43 53 48 78 78 70 84 -2 1 -12 -1 -23 -3z"/>
<path d="M1015 3460 c-112 -24 -247 -98 -303 -165 -53 -65 -118 -214 -136
-311 -20 -113 -20 -145 -1 -231 20 -88 49 -153 102 -230 79 -113 186 -182 331
-214 108 -24 141 -24 247 1 130 30 202 72 316 181 102 100 153 227 152 384 0
142 -58 293 -150 395 -60 67 -180 145 -261 171 -75 23 -232 34 -297 19z m340
-214 c91 -43 174 -154 175 -234 0 -18 -9 -51 -21 -73 -19 -37 -19 -42 -5 -64
35 -54 12 -121 -48 -142 -22 -7 -47 -19 -55 -27 -9 -8 -41 -27 -71 -42 -50
-26 -64 -29 -155 -29 -111 0 -152 14 -206 68 -49 49 -63 85 -64 162 0 59 4 78
28 118 31 52 96 105 141 114 23 5 33 17 56 68 46 103 121 130 225 81z"/>
<path d="M3985 3464 c-44 -7 -154 -44 -200 -67 -55 -28 -138 -96 -162 -132
-10 -16 -39 -75 -64 -130 l-44 -100 0 -160 0 -160 45 -90 c53 -108 152 -214
245 -264 59 -31 215 -71 281 -71 53 0 206 40 255 67 98 53 203 161 247 253 53
113 74 193 74 280 -1 304 -253 564 -557 575 -49 2 -103 1 -120 -1z m311 -220
c129 -68 202 -209 160 -309 -15 -35 -15 -42 -1 -72 26 -55 -3 -118 -59 -129
-19 -3 -43 -15 -53 -26 -26 -29 -99 -64 -165 -78 -45 -10 -69 -10 -120 -1 -74
15 -113 37 -161 91 -110 120 -50 331 109 385 24 8 44 23 52 39 6 14 18 38 25
53 33 72 127 93 213 47z"/>
<path d="M487 3394 c-21 -12 -27 -21 -25 -40 2 -14 7 -26 12 -27 14 -3 48 48
44 66 -3 14 -6 14 -31 1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -45,7 +45,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@@ -68,7 +68,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@@ -91,7 +91,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@@ -114,7 +114,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@@ -229,7 +229,7 @@
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
stopAsking = false; // reset value for next time they open this modal
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
@@ -238,63 +238,7 @@
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p v-if="serviceWorkerReady" class="text-lg mb-4">
Would you like to be notified of new activity once a day?
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 10
seconds...
<fa icon="spinner" spin />
</p>
<div v-if="serviceWorkerReady">
<span class="flex flex-row justify-center">
<span class="mt-2">Yes, tell me at: </span>
<input
type="number"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
</span>
</span>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
() => {
if (checkHour()) {
close(notification.id);
turnOnNotifications();
}
}
"
>
Turn on Daily Message
</button>
</div>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
>
No, Not Now
</button>
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-mute'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -308,17 +252,17 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 1 Hour
For 1 Day
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 8 Hours
For 2 Days
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 24 Hours
For 1 Week
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@@ -334,6 +278,7 @@
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-off'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -343,17 +288,17 @@
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4">
Would you like to <b>turn off</b> notifications for this app?
Would you like to <b>turn off</b> this notification?
</p>
<button
@click="
close(notification.id);
turnOffNotifications();
turnOffNotifications(notification);
"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
>
Turn Off Notifications
Turn Off Notification
</button>
<button
@click="close(notification.id)"
@@ -373,416 +318,116 @@
<style></style>
<script lang="ts">
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";
interface ServiceWorkerMessage {
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean;
message?: string;
}
// Example interface for error
interface ErrorResponse {
message: string;
// Other properties as needed
}
interface VapidResponse {
data: {
vapidKey: string;
};
}
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
notifyTime: { utcHour: number };
}
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { NotificationIface } from "./constants/app";
@Component
export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = "";
hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith("http://localhost")) {
console.log("Not checking for VAPID in this local environment.");
} else {
await axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data?.vapidKey || "";
console.log("Got vapid key:", this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
});
});
if (!this.b64) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
-1,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
console.log("Ignoring the error getting VAPID for local development.");
} else {
console.error("Got an error initializing notifications:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
-1,
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
truncateLongWords(sentence: string) {
return sentence
.split(" ")
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
.join(" ");
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel();
async turnOffNotifications(notification: NotificationIface) {
let subscription: object | null = null;
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse);
}
};
navigator.serviceWorker.controller.postMessage(message, [
messageChannel.port2,
]);
} else {
reject("Service worker controller not available");
}
});
}
private askPermission(): Promise<NotificationPermission> {
console.log("Requesting permission for notifications:", navigator);
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
return Promise.reject("Service worker not available.");
let allGoingOff = false;
const settings = await retrieveSettingsForActiveAccount();
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) {
// the other notification is already off, so fully unsubscribe now
allGoingOff = true;
}
const secret = localStorage.getItem("secret");
if (!secret) {
return Promise.reject("No secret found.");
}
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
console.log("Response from service worker:", response);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
alert("This browser does not support notifications.");
return Promise.reject("This browser does not support notifications.");
}
if (Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return Notification.requestPermission().then((permission) => {
if (permission !== "granted") {
alert(
"Allow this app permission to make notifications for personal reminders." +
" You can adjust them at any time in your settings.",
);
throw new Error("We weren't granted permission.");
}
return permission;
});
}
// this allows us to show an error without closing the dialog
checkHour() {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Number",
text: "The time must be an hour number.",
},
5000,
);
return false;
}
const hourNum = libsUtil.numberOrZero(this.hourInput);
if (!Number.isInteger(hourNum)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be a whole hour number.",
},
5000,
);
return false;
}
if (hourNum < 1 || 12 < hourNum) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be an hour between 1 and 12.",
},
5000,
);
return false;
}
return true;
}
public async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
// Call the function and handle promises
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
if (subscription) {
await this.$notify(
{
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
const hourNum = adjHourNum % 24;
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour },
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
console.log(
"Subscription data sent to server and all finished successfully.",
);
await libsUtil.sendTestThroughPushServer(subscription, true);
this.$notify(
{
group: "alert",
type: "success",
title: "Notifications Turned On",
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
},
-1,
);
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
);
alert(
"Subscription or server communication failed. Try again in a while.",
);
});
})
.catch((error) => {
console.error(
"An error occurred setting notification permissions:",
error,
);
alert("Some error occurred setting notification permissions.");
});
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
console.log("Push subscription successful:", subscription);
resolve();
})
.catch((error) => {
console.error("Push subscription failed:", error, options);
// Inform the user about the issue
alert(
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
): Promise<void> {
console.log("About to send subscription...", subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to send subscription to server");
}
console.log("Subscription sent to server successfully.");
});
}
async turnOffNotifications() {
let subscription;
const pushProviderSuccess = await navigator.serviceWorker?.ready
await navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then((subscript) => {
subscription = subscript;
if (subscription) {
return subscription.unsubscribe();
.then(async (subscript: PushSubscription | null) => {
if (subscript) {
subscription = subscript.toJSON();
if (allGoingOff) {
await subscript.unsubscribe();
}
} else {
console.log("Subscription object is not available.");
return false;
logConsoleAndDb("Subscription object is not available.");
}
})
.catch((error) => {
console.error("Push provider server communication failed:", error);
return false;
logConsoleAndDb(
"Push provider server communication failed: " + JSON.stringify(error),
true,
);
});
if (!subscription) {
// there is no endpoint or auth for the server to compare, so we're done
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.", // a different message so I know there are none stored
},
5000,
);
return true;
}
// clone in order to get only the properties and allow stringify to work
const serverSubscription = {
...subscription,
};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
}
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
body: JSON.stringify(serverSubscription),
})
.then((response) => {
return response.ok;
})
.catch((error) => {
console.error("Push server communication failed:", error);
logConsoleAndDb(
"Push server communication failed: " + JSON.stringify(error),
true,
);
return false;
});
alert(
"Notifications are off. Push provider unsubscribe " +
(pushProviderSuccess ? "succeeded" : "failed") +
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
" push server unsubscribe " +
(pushServerSuccess ? "succeeded" : "failed") +
".",
let message;
if (pushServerSuccess) {
message = "Notification is off.";
} else {
message = "Notification is still on. Try to turn it off again.";
}
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: message,
},
5000,
);
if (notification.callback) {
// it's OK if the local notifications are still on (especially if the other notification is on)
notification.callback(pushServerSuccess);
}
}
}
</script>

View File

@@ -0,0 +1,152 @@
<template>
<NotificationGroup group="customModal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
leave="transition ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
move="transition duration-500"
move-delay="delay-300"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<div
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">{{ title }}</span>
<p class="text-sm mb-2">{{ text }}</p>
<button
@click="handleOption1(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
>
{{ option1Text }}
</button>
<button
@click="handleOption2(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-700 text-white px-2 py-2 rounded-md mb-2"
>
{{ option2Text }}
</button>
<button
@click="handleOption3(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
{{ option3Text }}
</button>
<button
@click="handleCancel(close)"
class="block w-full text-center text-md font-bold capitalize bg-slate-600 text-white px-2 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</Notification>
</div>
</NotificationGroup>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
title = "";
text = "";
option1Text = "";
option2Text = "";
option3Text = "";
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
open(options: {
title: string;
text: string;
option1Text?: string;
option2Text?: string;
option3Text?: string;
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
}) {
this.title = options.title;
this.text = options.text;
this.option1Text = options.option1Text || "";
this.option2Text = options.option2Text || "";
this.option3Text = options.option3Text || "";
this.onOption1 = options.onOption1;
this.onOption2 = options.onOption2;
this.onOption3 = options.onOption3;
this.onCancel = options.onCancel;
this.$notify(
{
group: "customModal",
type: "confirm",
title: this.title,
text: this.text,
option1Text: this.option1Text,
option2Text: this.option2Text,
option3Text: this.option3Text,
onOption1: this.onOption1,
onOption2: this.onOption2,
onOption3: this.onOption3,
onCancel: this.onCancel,
} as NotificationIface,
-1,
);
}
handleOption1(close: (id: string) => void) {
if (this.onOption1) {
this.onOption1();
}
close("string that does not matter");
}
handleOption2(close: (id: string) => void) {
if (this.onOption2) {
this.onOption2();
}
close("string that does not matter");
}
handleOption3(close: (id: string) => void) {
if (this.onOption3) {
this.onOption3();
}
close("string that does not matter");
}
handleCancel(close: (id: string) => void) {
if (this.onCancel) {
this.onCancel();
}
close("string that does not matter");
}
}
</script>

View File

@@ -0,0 +1,102 @@
<!-- 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,
defaultName?: string,
) {
this.cancelCallback = cancelCallback || this.cancelCallback;
this.saveCallback = saveCallback || this.saveCallback;
this.message = message ?? this.message;
this.newText = defaultName ?? "";
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 {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -191,6 +191,7 @@ export default class FeedFilters extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
@@ -204,7 +205,7 @@ export default class FeedFilters extends Vue {
}
#dialogFeedFilters.dialog-overlay {
z-index: 99999;
z-index: 100;
overflow: scroll;
}

View File

@@ -47,7 +47,8 @@
giverDid: giver?.did,
giverName: giver?.name,
offerId,
projectId,
fulfillsProjectId: toProjectId,
providerProjectId: fromProjectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode,
@@ -89,16 +90,22 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { retrieveAccountDids } from "@/libs/util";
@Component
export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId = "";
@Prop fromProjectId = "";
@Prop toProjectId = "";
activeDid = "";
allContacts: Array<Contact> = [];
@@ -143,9 +150,7 @@ export default class GiftedDialog extends Vue {
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
this.allMyDids = await retrieveAccountDids();
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
@@ -294,9 +299,11 @@ export default class GiftedDialog extends Vue {
description,
amount,
unitCode,
this.projectId,
this.toProjectId,
this.offerId,
this.isTrade,
undefined,
this.fromProjectId,
);
if (
@@ -333,7 +340,7 @@ export default class GiftedDialog extends Vue {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
serverMessageForUser(error) ||
"There was an error recording the give.";
this.$notify(
{
@@ -387,6 +394,7 @@ export default class GiftedDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
<h1 class="text-xl font-bold text-center relative">
Here's one:
<div
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@@ -10,8 +10,9 @@
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<span class="flex justify-between">
<span class="mt-2 flex justify-between">
<span
v-if="currentCategory === CATEGORY_IDEAS"
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="prevIdea()"
>
@@ -20,7 +21,7 @@
<div class="m-2">
<span v-if="currentCategory === CATEGORY_IDEAS">
<p class="text-center text-lg font-bold">
<p class="text-center text-lg">
{{ IDEAS[currentIdeaIndex] }}
</p>
</span>
@@ -28,12 +29,12 @@
<p class="text-center">
<span
v-if="currentContact == null"
class="text-orange-500 text-lg font-bold"
class="text-orange-500 text-lg"
>
That's all your contacts.
</span>
<span v-else>
<span class="text-lg font-bold">
<span class="text-lg">
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }}
<br />
or someone near them do anything &ndash; maybe a while ago?
@@ -85,21 +86,22 @@ export default class GivenPrompts extends Vue {
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?",
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)",
"What did a family member do? (How did you take better action because it made you feel loved?)",
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)",
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)",
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)",
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)",
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)",
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)",
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)",
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)",
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)",
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)",
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)",
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)",
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)",
"What is a surprise gift you received? (What extra possibilities did it give you?)",
];
callbackOnFullGiftInfo?: (
@@ -116,9 +118,9 @@ export default class GivenPrompts extends Vue {
AppString = AppString;
async open(
callbackOnFullGiftInfo: (
contactInfo: GiverReceiverInputInfo,
description: string,
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string,
) => void,
) {
this.visible = true;
@@ -238,6 +240,7 @@ export default class GivenPrompts extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -0,0 +1,182 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
<button @click="close" class="text-gray-500 hover:text-gray-700">
<fa icon="times" />
</button>
</div>
<!-- Content -->
<!-- This is somewhat similar to ClaimView.vue and ConfirmGiftView.vue -->
<div class="mb-4">
<p class="mb-4">
<span v-if="R.isEmpty(visibleToDids)">
The {{ roleName }} is not visible to you or any of your contacts.
</span>
<span v-else> The {{ roleName }} is not visible to you. </span>
</p>
<div v-if="R.isEmpty(visibleToDids)">
<p class="mt-2">
You can ask one of your contacts to take a look and see if their
contacts can see more details. Someone is connected to people closer
to them; if you don't know who to ask, try the person who registered
you.
</p>
</div>
<div v-else>
<p class="mb-2">
They are visible to some of your contacts. If you'd like an
introduction, ask them if they'll tell you more.
</p>
<div class="ml-4">
<ul>
<li
v-for="(visDid, idx) of visibleToDids"
:key="idx"
class="list-disc ml-4 mb-2"
>
<div class="text-sm">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
<div class="mt-4">
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click here to share the information with them and ask if they'll
tell you more about the {{ roleName }}.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
</span>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end">
<button
@click="close"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Close
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "@/db/tables/contacts";
import * as serverUtil from "@/libs/endorserServer";
import { NotificationIface } from "@/constants/app";
@Component
export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
isOpen = false;
roleName = "";
visibleToDids: string[] = [];
allContacts: Array<Contact> = [];
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
windowLocation = window.location.href;
R = R;
serverUtil = serverUtil;
created() {
// 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;
}
open(
roleName: string,
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>,
) {
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.isOpen = true;
}
close() {
this.isOpen = false;
}
didInfo(did: string) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
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() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
});
}
}
</script>

View File

@@ -18,7 +18,7 @@
<div>
<div class="text-center mt-8">
<div class>
<div>
<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"
@@ -155,6 +155,7 @@ export default class ImageMethodDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -0,0 +1,119 @@
<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 {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,495 @@
<template>
<div class="space-y-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Members List -->
<p
v-if="decryptedMembers.length < members.length"
class="text-center text-red-600 py-4"
>
{{
decryptFailureMessage ||
"Your password failed. Please go back and try again."
}}
</p>
<div v-else class="space-y-4">
<div v-if="missingMyself" class="py-4 text-red-600">
You are not admitted. The organizer will admit you.
</div>
<div>
<span
v-if="showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
Use
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="plus" class="text-sm" />
</span>
and
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
</span>
</div>
<div>
<span class="inline-flex items-center">
Use
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
<fa icon="circle-user" class="text-xl" />
</span>
to add them to your contacts.
</span>
</div>
<div v-if="members.length > 0" class="flex justify-center">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">{{ member.name }}</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
>
<button
@click="addAsContact(member)"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 transition-colors"
title="Add as contact"
>
<fa icon="circle-user" class="text-xl" />
</button>
</div>
<button
v-if="member.did !== activeDid"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Contact info"
>
<fa icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center"
>
<button
@click="checkWhetherContactBeforeAdmitting(member)"
class="mr-2 w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
>
<fa
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/>
</button>
<button
@click="informAboutAdmission()"
class="mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Admission info"
>
<fa icon="circle-info" class="text-base" />
</button>
</span>
</div>
</div>
<p class="text-sm text-gray-600 truncate">
{{ member.did }}
</p>
</div>
<div v-if="members.length > 0" class="flex justify-center mt-4">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
db,
} from "@/db/index";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { NotificationIface } from "@/constants/app";
interface Member {
admitted: boolean;
content: string;
memberId: number;
}
interface DecryptedMember {
member: Member;
name: string;
did: string;
isRegistered: boolean;
}
@Component
export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
@Prop({ required: true }) password!: string;
@Prop({ default: "Your password failed. Please go back and try again." })
decryptFailureMessage!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = [];
missingPassword = false;
missingMyself = false;
isLoading = false;
isOrganizer = false;
members: Member[] = [];
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
await this.fetchMembers();
await this.loadContacts();
}
async fetchMembers() {
this.isLoading = true;
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers },
);
if (response.data && response.data.data) {
this.members = response.data.data;
await this.decryptMemberContents();
}
} catch (error) {
logConsoleAndDb(
"Error fetching members: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) || "Failed to fetch members.",
);
} finally {
this.isLoading = false;
}
}
async decryptMemberContents() {
this.decryptedMembers = [];
if (!this.password) {
this.missingPassword = true;
return;
}
let isFirstEntry = true,
foundMyself = false;
for (const member of this.members) {
try {
const decryptedContent = await decryptMessage(
member.content,
this.password,
);
const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({
member: member,
name: content.name,
did: content.did,
isRegistered: !!content.isRegistered,
});
if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true;
}
if (content.did === this.activeDid) {
foundMyself = true;
}
} catch (error) {
// do nothing, relying on the count of members to determine if there was an error
}
isFirstEntry = false;
}
this.missingMyself = !foundMyself;
}
membersToShow(): DecryptedMember[] {
if (this.isOrganizer) {
if (this.showOrganizerTools) {
return this.decryptedMembers;
} else {
return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted,
);
}
}
// non-organizers only get visible members from server
return this.decryptedMembers;
}
informAboutAdmission() {
this.$notify(
{
group: "alert",
type: "info",
title: "Admission info",
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
},
10000,
);
}
informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
},
10000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
},
10000,
);
}
}
async loadContacts() {
this.contacts = await db.contacts.toArray();
}
getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did);
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
yesText: "Add as Contact",
noText: "Skip Adding Contact",
onYes: async () => {
await this.addAsContact(decrMember);
// After adding as contact, proceed with admission
await this.toggleAdmission(decrMember);
},
onNo: async () => {
// If they choose not to add as contact, show second confirmation
this.$notify(
{
group: "modal",
type: "confirm",
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission even though they are not a contact?",
yesText: "Continue",
onYes: async () => {
await this.toggleAdmission(decrMember);
},
onCancel: async () => {
// Do nothing, effectively canceling the operation
},
},
-1,
);
},
},
-1,
);
} else {
// If already a contact, proceed directly with admission
this.toggleAdmission(decrMember);
}
}
async toggleAdmission(decrMember: DecryptedMember) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !decrMember.member.admitted },
{ headers },
);
// Update local state
decrMember.member.admitted = !decrMember.member.admitted;
const oldContact = this.getContactFor(decrMember.did);
// if admitted, now register that user if they are not registered
if (
decrMember.member.admitted &&
!decrMember.isRegistered &&
!oldContact?.registered
) {
const contactOldOrNew: Contact = oldContact || {
did: decrMember.did,
name: decrMember.name,
};
try {
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contactOldOrNew,
);
if (result.success) {
decrMember.isRegistered = true;
if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true });
oldContact.registered = true;
}
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "Besides being admitted, they were also registered.",
},
3000,
);
} else {
throw result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
// registration failure is likely explained by a message from the server
const additionalInfo =
serverMessageForUser(error) || error?.error || "";
this.$notify(
{
group: "alert",
type: "warning",
title: "Registration failed",
text:
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
additionalInfo,
},
12000,
);
}
}
} catch (error) {
logConsoleAndDb(
"Error toggling admission: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) ||
"Failed to update member admission status.",
);
}
}
async addAsContact(member: DecryptedMember) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await db.contacts.add(newContact);
this.contacts.push(newContact);
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: "They were added to your contacts.",
},
3000,
);
} catch (err) {
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
let message = "An error prevented adding this contact.";
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
message = "This person is already in your contact list.";
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: message,
},
5000,
);
}
}
}
</script>

View File

@@ -83,7 +83,10 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { createAndSubmitOffer } from "@/libs/endorserServer";
import {
createAndSubmitOffer,
serverMessageForUser,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { retrieveSettingsForActiveAccount } from "@/db/index";
@@ -91,8 +94,8 @@ import { retrieveSettingsForActiveAccount } from "@/db/index";
export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId?;
@Prop projectName?;
@Prop projectId?: string;
@Prop projectName?: string;
activeDid = "";
apiServer = "";
@@ -207,9 +210,9 @@ export default class OfferDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record an offer.",
text: "You must select an identity before you can record an offer.",
},
-1,
7000,
);
return;
}
@@ -264,7 +267,7 @@ export default class OfferDialog extends Vue {
title: "Success",
text: "That offer was recorded.",
},
10000,
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -304,9 +307,9 @@ export default class OfferDialog extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOfferCreationErrorMessage(result: any) {
return (
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
result.error?.error
);
}
}
@@ -314,6 +317,7 @@ export default class OfferDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -0,0 +1,286 @@
<!-- 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 & Magnifying Time
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
</div>
</h1>
<p v-if="isRegistered" class="mt-4">
You can now log things that you've seen:
<span v-if="numContacts > 0">
click on any name (like {{ firstContactName }}) or
</span>
click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw" />
</span>
button to express your appreciation for... whatever -- maybe 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 that others have
recognized. Once someone registers you, you can 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 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]" />
</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 to help besides you.
</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]" />
</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" />
</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 {
z-index: 40;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -409,6 +409,7 @@ export default class PhotoDialog extends Vue {
<style>
.dialog-overlay {
z-index: 60;
position: fixed;
top: 0;
left: 0;

View File

@@ -0,0 +1,574 @@
<template>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed z-[100] top-0 inset-x-0 w-full inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p v-if="serviceWorkerReady && vapidKey" class="text-lg mb-4">
<span v-if="pushType === DAILY_CHECK_TITLE">
Would you like to be notified of new activity, up to once a day?
</span>
<span v-else>
Would you like to get a reminder message once a day?
</span>
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 5 seconds...
<fa icon="spinner" spin />
</p>
<div v-if="serviceWorkerReady && vapidKey">
<div v-if="pushType === DAILY_CHECK_TITLE">
<span>Yes, send me a message when there is new data for me</span>
</div>
<div v-else>
<span>Yes, send me this message:</span>
<!-- eslint-disable -->
<textarea
type="text"
id="push-message"
v-model="messageInput"
class="rounded border border-slate-400 mt-2 px-2 py-2 w-full"
maxlength="100"
></textarea
>
<!-- eslint-enable -->
<span class="w-full flex justify-between text-xs text-slate-500">
<span></span>
<span>(100 characters max)</span>
</span>
</div>
<div>
<span class="flex flex-row justify-center">
<span class="mt-2">... at: </span>
<input
type="number"
@change="checkHourInput"
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"
/>
<input
type="number"
@change="checkMinuteInput"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
v-model="minuteInput"
/>
<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>
</div>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
close();
turnOnNotifications();
"
>
Turn on Daily Message
</button>
</div>
<button
@click="close()"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
>
No, Not Now
</button>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
secretDB,
} from "@/db/index";
import { MASTER_SECRET_KEY } from "@/db/tables/secret";
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util";
import * as libsUtil from "@/libs/util";
// Example interface for error
interface ErrorResponse {
message: string;
}
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string;
notifyTime: { utcHour: number; minute: number };
notifyType: string;
}
interface ServiceWorkerMessage {
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean;
message?: string;
}
interface VapidResponse {
data: {
vapidKey: string;
};
}
@Component
export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
callback: (success: boolean, time: string, message?: string) => void =
() => {};
hourAm = true;
hourInput = "8";
isVisible = false;
messageInput = "";
minuteInput = "00";
pushType = "";
serviceWorkerReady = false;
vapidKey = "";
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
) {
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
try {
const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith("http://localhost")) {
logConsoleAndDb("Not checking for VAPID in this local environment.");
} else {
let responseData = "";
await this.axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.vapidKey = response.data?.vapidKey || "";
logConsoleAndDb("Got vapid key: " + this.vapidKey);
responseData = JSON.stringify(response.data);
navigator.serviceWorker?.addEventListener(
"controllerchange",
() => {
logConsoleAndDb(
"New service worker is now controlling the page",
);
},
);
});
if (!this.vapidKey) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
5000,
);
logConsoleAndDb(
"Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData,
true,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
logConsoleAndDb(
"Ignoring the error getting VAPID for local development.",
);
} else {
logConsoleAndDb(
"Got an error initializing notifications: " + JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
5000,
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput =
"Click to share some gratitude with the world -- even if they're unnamed.";
// focus on the message input
setTimeout(function () {
document.getElementById("push-message")?.focus();
}, 100);
} else {
// not critical but doesn't make sense in a daily check
this.messageInput = "";
}
}
private close() {
this.isVisible = false;
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker?.controller) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse);
}
};
navigator.serviceWorker?.controller.postMessage(message, [
messageChannel.port2,
]);
} else {
reject("Service worker controller not available");
}
});
}
private async askPermission(): Promise<NotificationPermission> {
// console.log(
// "Requesting permission for notifications: " + JSON.stringify(navigator),
// );
if (
!("serviceWorker" in navigator && navigator.serviceWorker?.controller)
) {
return Promise.reject("Service worker not available.");
}
await secretDB.open();
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret;
if (!secret) {
return Promise.reject("No secret found.");
}
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
logConsoleAndDb(
"Response from service worker: " + JSON.stringify(response),
);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Browser Notifications Are Not Supported",
text: "This browser does not support notifications.",
},
3000,
);
return Promise.reject("This browser does not support notifications.");
}
if (window.Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return window.Notification.requestPermission().then(
(permission: string) => {
if (permission !== "granted") {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Requesting Notification Permission",
text:
"Allow this app permission to make notifications for personal reminders." +
" You can adjust them at any time in your settings.",
},
-1,
);
throw new Error("Permission was not granted to this app.");
}
return permission;
},
);
}
private checkHourInput() {
const hourNum = parseInt(this.hourInput);
if (isNaN(hourNum)) {
this.hourInput = "12";
} else if (hourNum < 1) {
this.hourInput = "12";
this.hourAm = !this.hourAm;
} else if (hourNum > 12) {
this.hourInput = "1";
this.hourAm = !this.hourAm;
} else {
this.hourInput = hourNum.toString();
}
}
private checkMinuteInput() {
const minuteNum = parseInt(this.minuteInput);
if (isNaN(minuteNum)) {
this.minuteInput = "00";
} else if (minuteNum < 0) {
this.minuteInput = "59";
} else if (minuteNum < 10) {
this.minuteInput = "0" + minuteNum;
} else if (minuteNum > 59) {
this.minuteInput = "00";
} else {
this.minuteInput = minuteNum.toString();
}
}
private async turnOnNotifications() {
let notifyCloser = () => {};
return this.askPermission()
.then((permission) => {
logConsoleAndDb("Permission granted: " + JSON.stringify(permission));
// Call the function and handle promises
return this.subscribeToPush();
})
.then(() => {
logConsoleAndDb("Subscribed successfully.");
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
if (subscription) {
notifyCloser = await this.$notify(
{
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = this.hourAm
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
rawHourNum === 12
? 0
: rawHourNum
: // Otherwise it's PM, so keep a 12 but otherwise add 12
rawHourNum === 12
? 12
: rawHourNum + 12;
const hourNum = adjHourNum % 24; // probably unnecessary now
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const minuteNum = libsUtil.numberOrZero(this.minuteInput);
const utcMinute =
minuteNum + Math.round(new Date().getTimezoneOffset() % 60);
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: this.pushType,
message: this.messageInput,
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
logConsoleAndDb(
"Subscription data sent to server with endpoint: " +
subscription.endpoint,
);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
logConsoleAndDb(
"Subscription data sent to server and all finished successfully.",
);
await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser();
setTimeout(() => {
this.$notify(
{
group: "alert",
type: "success",
title: "Notification Is On",
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
},
7000,
);
}, 500);
const timeText =
// eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
this.callback(true, timeText, this.messageInput);
})
.catch((error) => {
logConsoleAndDb(
"Got an error setting notification permissions: " +
" string " +
error.toString() +
" JSON " +
JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notification Permissions",
text: "Could not set notification permissions.",
},
3000,
);
// if we want to also unsubscribe, be sure to do that only if no other notification is active
});
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (window.Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
logConsoleAndDb(
"Push subscription successful: " + JSON.stringify(subscription),
);
resolve();
})
.catch((error) => {
logConsoleAndDb(
"Push subscription failed: " +
JSON.stringify(error) +
" - " +
JSON.stringify(options),
true,
);
// Inform the user about the issue
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Push Notifications",
text:
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
},
-1,
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
): Promise<void> {
logConsoleAndDb(
"About to send subscription... " + JSON.stringify(subscription),
);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
console.error("Bad response subscribing to web push: ", response);
throw new Error("Failed to send push subscription to server");
}
logConsoleAndDb("Push subscription sent to server successfully.");
});
}
}
</script>
<style scoped>
/* Add any specific styles for this component here */
</style>

View File

@@ -11,8 +11,11 @@
'text-slate-500': selected !== 'Home',
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw" />
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
<div class="flex flex-col items-center">
<fa icon="house-chimney" class="fa-fw" />
<span class="text-xs mt-1">feed</span>
</div>
</router-link>
</li>
<!-- Search -->
@@ -26,9 +29,12 @@
>
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
class="block text-center py-2 px-1"
>
<fa icon="magnifying-glass" class="fa-fw" />
<div class="flex flex-col items-center">
<fa icon="magnifying-glass" class="fa-fw" />
<span class="text-xs mt-1">search</span>
</div>
</router-link>
</li>
<!-- Projects -->
@@ -42,9 +48,12 @@
>
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
class="block text-center py-2 px-1"
>
<fa icon="hand" class="fa-fw" />
<div class="flex flex-col items-center">
<fa icon="hand" class="fa-fw" />
<span class="text-xs mt-1">your work</span>
</div>
</router-link>
</li>
<!-- Contacts -->
@@ -58,9 +67,12 @@
>
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
class="block text-center py-2 px-1"
>
<fa icon="users" class="fa-fw" />
<div class="flex flex-col items-center">
<fa icon="users" class="fa-fw" />
<span class="text-xs mt-1">contacts</span>
</div>
</router-link>
</li>
<!-- Profile -->
@@ -74,9 +86,18 @@
>
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
class="block text-center py-2 px-1"
>
<fa icon="circle-user" class="fa-fw" />
<div class="flex flex-col items-center">
<fa icon="circle-user" class="fa-fw" />
<!--
We used to say "account", so we'll keep that in the code,
but it isn't accurate because we don't hold anything for them.
We'll say "profile" to the users.
(Or: settings, face, registry, cache, repo, vault... or separate preferences from identity.)
-->
<span class="text-xs mt-1">profile</span>
</div>
</router-link>
</li>
</ul>

View File

@@ -1,10 +1,11 @@
<!-- 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>
Note that this is not sent to servers. It is only shared with people when
you choose to send it to them.
This is not sent to servers. It is only shared with people when you send
it to them.
<input
type="text"
placeholder="Name"
@@ -21,7 +22,6 @@
>
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"
@@ -46,11 +46,14 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (string?) => void = () => {};
callback: (name: string) => void = () => {};
givenName = "";
visible = false;
async open(aCallback?: (name?: string) => void) {
/**
* @param aCallback - callback function for name, which may be ""
*/
async open(aCallback?: (name: string) => void) {
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
@@ -73,6 +76,7 @@ export default class UserNameDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -16,8 +16,8 @@ export enum AppString {
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",
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = "https://timesafari.app",
@@ -27,6 +27,9 @@ export enum AppString {
NO_CONTACT_NAME = "(no name)",
}
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;
@@ -49,13 +52,14 @@ export const PASSKEYS_ENABLED =
/**
* The possible values for "group" and "type" are in App.vue.
* From the notiwind package
* Some of this comes from the notiwind package, some is custom.
*/
export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string;
onCancel?: (stopAsking?: boolean) => Promise<void>;
onNo?: (stopAsking?: boolean) => Promise<void>;

View File

@@ -5,6 +5,7 @@ import * as R from "ramda";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs";
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
import {
MASTER_SETTINGS_KEY,
Settings,
@@ -14,6 +15,7 @@ import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> };
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
@@ -23,25 +25,39 @@ type NonsensitiveTables = {
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
//// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema);
// Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside.
//
// This is a promise because the decryption key comes from IndexedDB
// and someday it may come from a password or keystore or external wallet.
// It's important that usages take into account that there may be a delay due
// to a user action required to unlock the data.
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
secretDB,
accountsDexie,
);
//// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// Define the schemas for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
accountsDB.version(1).stores(AccountsSchema);
// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
@@ -73,6 +89,79 @@ db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
});
// Manage the encryption key.
// It's not really secure to maintain the secret next to the user's data.
// However, until we have better hooks into a real wallet or reliable secure
// storage, we'll do this for user convenience. As they sign more records
// and integrate with more people, they'll value it more and want to be more
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
// PWA so it's not in a browser... and then we hope to be integrated with a
// real wallet or something else more secure.
// One might ask: why encrypt at all? We figure a basic encryption is better
// than none. Plus, we expect to support their own password or keystore or
// external wallet as better signing options in the future, so it's gonna be
// important to have the structure where each account access might require
// user action.
// (Once upon a time we stored the secret in localStorage, but it frequently
// got erased, even though the IndexedDB still had the identity data. This
// ended up throwing lots of errors to the user... and they'd end up in a state
// where they couldn't take action because they couldn't unlock that identity.)
// check for the secret in storage
async function useSecretAndInitializeAccountsDB(
secretDB: SecretDexie,
accountsDB: SensitiveDexie,
): Promise<SensitiveDexie> {
return secretDB
.open()
.then(() => {
return secretDB.secret.get(MASTER_SECRET_KEY);
})
.then((secretRow?: Secret) => {
let secret = secretRow?.secret;
if (secret != null) {
// they already have it in IndexedDB, so just pass it along
return secret;
} else {
// check localStorage (for users before v 0.3.37)
const localSecret = localStorage.getItem("secret");
if (localSecret != null) {
// they had one, so we want to move it to IndexedDB
secret = localSecret;
} else {
// they didn't have one, so let's generate one
secret = Encryption.createRandomEncryptionKey();
}
// it is not in IndexedDB, so add it now
return secretDB.secret
.add({ id: MASTER_SECRET_KEY, secret })
.then(() => {
return secret;
});
}
})
.then((secret?: string) => {
if (secret == null) {
throw new Error("No secret found or created.");
} else {
// apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(AccountsSchema);
accountsDB.open();
return accountsDB;
}
})
.catch((error) => {
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
// alert("There was an error processing encrypted data. See the Help page.");
throw error;
});
}
// retrieves default settings
// calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
@@ -131,3 +220,27 @@ export async function updateAccountSettings(
settings.accountDid = accountDid;
await updateSettings(settings);
}
// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
message: string,
isError = false,
): Promise<void> {
if (isError) {
console.error(`${new Date().toISOString()} ${message}`);
} else {
console.log(`${new Date().toISOString()} ${message}`);
}
await db.open();
const todayKey = new Date().toDateString();
// only keep one day's worth of logs
const previous = await db.logs.get(todayKey);
if (!previous) {
// when this is today's first log, clear out everything previous
await db.logs.clear();
}
const prevMessages = (previous && previous.message) || "";
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
await db.logs.update(todayKey, { message: fullMessage });
}

View File

@@ -5,7 +5,7 @@ export type Account = {
/**
* Auto-generated ID by Dexie
*/
id?: number;
id?: number; // this is only blank on input, when the database assigns it
/**
* The date the account was created
@@ -48,7 +48,7 @@ export type Account = {
/**
* Schema for the accounts table in the database.
* Fields starting with a $ character are encrypted.
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon#added-schema-syntax}
*/
export const AccountsSchema = {
accounts:

View File

@@ -1,7 +1,18 @@
export interface ContactMethod {
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
}
export interface Contact {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string;
contactMethods?: Array<ContactMethod>;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
profileImageUrl?: string;
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting

18
src/db/tables/secret.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Represents an account stored in the database.
*/
export type Secret = {
/**
* Auto-generated ID by Dexie
*/
id: number;
/**
* The secret key used to decrypt the identity if they're not using their own password
*/
secret: string;
};
export const SecretSchema = { secret: "++id, secret" };
export const MASTER_SECRET_KEY = 0;

View File

@@ -13,29 +13,41 @@ export type BoundingBox = {
*/
export type Settings = {
// 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
id?: number; // this is erased for all those entries that are keyed with accountDid
// 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
apiServer?: string; // API server URL
apiServer: string; // API server URL
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;
imageServer?: string;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
lastName?: string; // deprecated - put all names in firstName
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;
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
partnerApiServer?: string; // partner server API URL
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
reminderOn?: boolean; // Toggle to enable or disable reminders
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
@@ -52,7 +64,7 @@ export type Settings = {
webPushServer?: string; // Web Push server URL
};
export function isAnyFeedFilterOn(settings: Settings): boolean {
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}

View File

@@ -9,6 +9,4 @@ export type Temp = {
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};
export const TempSchema = { temp: "id" };

View File

@@ -5,11 +5,12 @@ import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
} from "@/libs/endorserServer";
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'";
@@ -51,7 +52,7 @@ export const newIdentifier = (
*
*
* @param {string} mnemonic
* @return {*} {[string, string, string, string]}
* @return {[string, string, string, string]} address, privateHex, publicHex, derivationPath
*/
export const deriveAddress = (
mnemonic: string,
@@ -87,7 +88,8 @@ export const generateSeed = (): string => {
/**
* Retrieve an access token, or "" if no DID is provided.
*
* @return {*}
* @param {string} did
* @return {string} JWT with basic payload
*/
export const accessToken = async (did?: string) => {
if (did) {
@@ -101,24 +103,34 @@ export const accessToken = async (did?: string) => {
};
/**
@return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
Note that similar code is also contained in time-safari
@return payload of JWT pulled out of any recognized URL path (if any)
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
if (endorserContextLoc > -1) {
const appImportConfirmUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
);
if (appImportConfirmUrlLoc > -1) {
jwtText = jwtText.substring(
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
appImportConfirmUrlLoc +
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
);
}
// JWT format: { header, payload, signature, data }
const jwt = decodeEndorserJwt(jwtText);
return jwt.payload;
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
);
if (appImportOneUrlLoc > -1) {
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
);
}
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
}
return jwtText;
};
export const nextDerivationPath = (origDerivPath: string) => {
@@ -136,3 +148,156 @@ export const nextDerivationPath = (origDerivPath: string) => {
.join("/");
return newDerivPath;
};
// Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer));
return btoa(binary);
}
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const KEY_LENGTH = 256;
const ITERATIONS = 100000;
// Encryption helper function
export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["encrypt"],
);
// Encrypt the message
const encryptedContent = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
key,
encoder.encode(message),
);
// Return a JSON structure with base64-encoded components
const result = {
salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv),
encrypted: arrayBufferToBase64(encryptedContent),
};
return btoa(JSON.stringify(result));
}
// Decryption helper function
export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder();
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
// Convert base64 components back to Uint8Arrays
const saltArray = base64ToArrayBuffer(salt);
const ivArray = base64ToArrayBuffer(iv);
const encryptedContent = base64ToArrayBuffer(encrypted);
// Derive the same key using PBKDF2 with the extracted salt
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltArray,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["decrypt"],
);
// Decrypt the content
const decryptedContent = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: ivArray,
},
key,
encryptedContent,
);
// Convert the decrypted content back to a string
return decoder.decode(decryptedContent);
}
// Test function to verify encryption/decryption
export async function testEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123";
console.log("Original message:", testMessage);
// Test encryption
console.log("Encrypting...");
const encrypted = await encryptMessage(testMessage, testPassword);
console.log("Encrypted result:", encrypted);
// Test decryption
console.log("Decrypting...");
const decrypted = await decryptMessage(encrypted, testPassword);
console.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
console.log("Messages match:", success);
// Test with wrong password
console.log("\nTesting with wrong password...");
try {
await decryptMessage(encrypted, "wrongPassword");
console.log("Should not reach here");
} catch (error) {
console.log("Correctly failed with wrong password ✅");
}
return success;
} catch (error) {
console.error("Test failed with error:", error);
return false;
}
}

View File

@@ -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 endorser-ch and 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: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

View File

@@ -6,14 +6,21 @@
*
*/
import { Buffer } from "buffer/";
import * as didJwt from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT";
import { JWTVerified } from "did-jwt";
import { Resolver } from "did-resolver";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
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
@@ -33,6 +40,8 @@ export interface KeyMeta {
passkeyCredIdHex?: string;
}
const ethLocalResolver = 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
@@ -44,16 +53,23 @@ export function isFromPasskey(keyMeta?: KeyMeta): boolean {
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);
return didJwt.createJWT(payload, {
const options = {
// alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests
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 {
@@ -107,6 +123,79 @@ function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
export function decodeEndorserJwt(jwt: string): JWTDecoded {
// We should be calling 'verify' in more places, showing warnings if it fails.
// @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature)
export function decodeEndorserJwt(jwt: string) {
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(".");
const header = JSON.parse(base64urlDecodeString(pieces[0]));
const payload = JSON.parse(base64urlDecodeString(pieces[1]));
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: ethLocalResolver,
});
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,
},
});
}

View File

@@ -470,8 +470,18 @@ ${pubKeyBuffer.toString("base64")}
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 base64urlDecode(input: string) {
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);
@@ -483,9 +493,9 @@ function base64urlDecode(input: string) {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncode(buffer: ArrayBuffer) {
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return base64urlEncodeString(str);
}
// from @simplewebauthn/browser

View File

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

View File

@@ -4,12 +4,17 @@ import { sha256 } from "ethereum-cryptography/sha256";
import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
import {
APP_SERVER,
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
} from "@/constants/app";
import { Contact } from "@/db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto";
import { NonsensitiveDexie } from "@/db/index";
import { logConsoleAndDb, NonsensitiveDexie } from "@/db/index";
import {
getAccount,
retrieveAccountMetadata,
retrieveFullyDecryptedAccount,
getPasskeyExpirationSeconds,
GiverReceiverInputInfo,
} from "@/libs/util";
@@ -21,10 +26,14 @@ export const SCHEMA_ORG_CONTEXT = "https://schema.org";
export const SERVICE_ID = "endorser.ch";
// the header line for contacts exported via Endorser Mobile
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
// the suffix for the contact URL in this app where they are confirmed before import
export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact-import/";
// the suffix for the contact URL in this app where a single one gets imported automatically
export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
// the suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
// unused now that we match on the URL path; just note that it was used for a while to create URLs that showed at endorser.ch
//export const CONTACT_URL_PREFIX_ENDORSER_CH_OLD = "https://endorser.ch";
// the prefix for handle IDs, the permanent ID for claims on Endorser
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
@@ -49,6 +58,7 @@ export interface ClaimResult {
error: { code: string; message: string };
}
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
@@ -56,8 +66,6 @@ export interface GenericVerifiableCredential {
}
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
"@context": string;
"@type": string;
claim: T;
claimType?: string;
handleId: string;
@@ -68,8 +76,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
}
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
claim: { "@type": "" },
handleId: "",
id: "",
@@ -91,6 +97,7 @@ export interface GiveSummaryRecord {
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
}
@@ -114,6 +121,10 @@ export interface OfferSummaryRecord {
validThrough: string;
}
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well
@@ -141,6 +152,7 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential; // typically @type & identifier
recipient?: { identifier: string };
}
@@ -179,13 +191,9 @@ export interface PlanVerifiableCredential extends GenericVerifiableCredential {
* Represents data about a project
*
* @deprecated
* We should use PlanSummaryRecord instead.
* (Maybe we should use PlanSummaryRecord instead, either by adding rowId or by iterating with jwtId.)
**/
export interface PlanData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
@@ -200,9 +208,14 @@ export interface PlanData {
*/
issuerDid: string;
/**
* The identifier of the project -- different from jwtId, needs to be fixed
* Name of the project
**/
rowid?: string;
name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
**/
rowId?: string;
}
export interface EndorserRateLimits {
@@ -221,11 +234,21 @@ export interface ImageRateLimits {
}
export interface VerifiableCredential {
exp?: number;
iat: number;
iss: string;
vc: {
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface WorldProperties {
@@ -233,12 +256,14 @@ export interface WorldProperties {
endTime?: string;
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": string;
"@type": string;
"@context": typeof SCHEMA_ORG_CONTEXT;
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string; // used for invites (when participant ID isn't known)
object: string;
participant: { identifier: string };
participant?: { identifier: string }; // used when person is known (not an invite)
}
// now for some of the error & other wrapper types
@@ -270,6 +295,19 @@ export interface ErrorResult extends ResultWithType {
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
/**
* This is similar to Contact but it grew up in different logic paths.
* We may want to change this to be a Contact.
*/
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
// This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";
@@ -413,6 +451,7 @@ export function didInfoForContact(
activeDid: string | undefined,
contact?: Contact,
allMyDids: string[] = [],
showDidForVisible: boolean = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string; profileImageUrl?: string } {
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
@@ -421,7 +460,7 @@ export function didInfoForContact(
} else if (contact) {
return {
displayName: contact.name || "Contact With No Name",
known: !!contact,
known: true,
profileImageUrl: contact.profileImageUrl,
};
} else {
@@ -429,14 +468,29 @@ export function didInfoForContact(
return myId
? { displayName: "You (Alt ID)", known: true }
: isHiddenDid(did)
? { displayName: "Someone Totally Outside Your View", known: false }
? { displayName: "Someone Outside Your View", known: false }
: {
displayName: "Someone Visible But Outside Your Contact List",
displayName: showDidForVisible
? did
: "Someone Visible But Not In Your Contact List",
known: false,
};
}
}
/**
* @returns full contact info object (never undefined), where did is searched in contacts and allMyDids
*/
export function didInfoObject(
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): { known: boolean; displayName: string; profileImageUrl?: string } {
const contact = contactForDid(did, contacts);
return didInfoForContact(did, activeDid, contact, allMyDids);
}
/**
always returns text, maybe something like "unnamed" or "unknown"
@@ -452,6 +506,22 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
}
/**
* return text description without any references to "you" as user
*/
export function didInfoForCertificate(
did: string | undefined,
contacts: Contact[],
): string {
return didInfoForContact(
did,
undefined,
contactForDid(did, contacts),
[],
true,
).displayName;
}
let passkeyAccessToken: string = "";
let passkeyTokenExpirationEpochSeconds: number = 0;
@@ -477,35 +547,72 @@ export function tokenExpiryTimeDescription() {
/**
* Get the headers for a request, potentially including Authorization
*/
export async function getHeaders(did?: string) {
export async function getHeaders(
did?: string,
$notify?: (notification: NotificationIface, timeout?: number) => void,
failureMessage?: string,
) {
const headers: { "Content-Type": string; Authorization?: string } = {
"Content-Type": "application/json",
};
if (did) {
let token;
const account = await getAccount(did);
if (account?.passkeyCredIdHex) {
if (
passkeyAccessToken &&
passkeyTokenExpirationEpochSeconds > Date.now() / 1000
) {
// there's an active current passkey token
token = passkeyAccessToken;
} else {
// there's no current passkey token or it's expired
token = await accessToken(did);
try {
let token;
const account = await retrieveAccountMetadata(did);
if (account?.passkeyCredIdHex) {
if (
passkeyAccessToken &&
passkeyTokenExpirationEpochSeconds > Date.now() / 1000
) {
// there's an active current passkey token
token = passkeyAccessToken;
} else {
// there's no current passkey token or it's expired
token = await accessToken(did);
passkeyAccessToken = token;
const passkeyExpirationSeconds = await getPasskeyExpirationSeconds();
passkeyTokenExpirationEpochSeconds =
Date.now() / 1000 + passkeyExpirationSeconds;
passkeyAccessToken = token;
const passkeyExpirationSeconds = await getPasskeyExpirationSeconds();
passkeyTokenExpirationEpochSeconds =
Date.now() / 1000 + passkeyExpirationSeconds;
}
} else {
token = await accessToken(did);
}
headers["Authorization"] = "Bearer " + token;
} catch (error) {
// This rarely happens: we've seen it when they have account info but the
// encryption secret got lost. But in most cases we want users to at
// least see their feed -- and anything else that returns results for
// anonymous users.
// We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know.
logConsoleAndDb(
"Something failed in getHeaders call (will proceed anonymously" +
($notify ? " and notify user" : "") +
"): " +
// IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
//JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON
error,
true,
);
if ($notify) {
// remember: only want to do this if they supplied a DID, expecting personal results
const notifyMessage =
failureMessage ||
"Showing anonymous data. See the Help page for help with personal data.";
$notify(
{
group: "alert",
type: "danger",
title: "Personal Data Error",
text: notifyMessage,
},
3000,
);
}
} else {
token = await accessToken(did);
}
headers["Authorization"] = "Bearer " + token;
} else {
// it's often OK to request without auth; we assume necessary checks are done earlier
// it's usually OK to request without auth; we assume we're only here when allowed
}
return headers;
}
@@ -521,7 +628,7 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
* @param apiServer
*/
export async function getPlanFromCache(
handleId: string,
handleId: string | undefined,
axios: Axios,
apiServer: string,
requesterDid?: string,
@@ -568,6 +675,102 @@ export async function setPlanInCache(
planCache.set(handleId, planSummary);
}
/**
*
* @param error that is thrown from an Endorser server call by Axios
* @returns user-friendly message, or undefined if none found
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serverMessageForUser(error: any) {
return (
// this is how most user messages are returned
error?.response?.data?.error?.message
// some are returned as "error" with a string, but those are more for devs and are less helpful to the user
);
}
/**
* Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
* It works with AxiosError, eg handling an error.response intelligently.
*
* @param error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: any) {
let stringifiedError = "" + error;
try {
stringifiedError = JSON.stringify(error);
} catch (e) {
// can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'DexieError2'
// | property '_promise' -> object with constructor 'DexiePromise'
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff
if (R.equals(error?.config, error?.response?.config)) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], error.response),
);
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
}
}
return fullError;
}
/**
*
* @returns { data: Array<OfferSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
*/
export async function getNewOffersToUser(
axios: Axios,
apiServer: string,
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
if (afterOfferJwtId) {
url += "&afterId=" + afterOfferJwtId;
}
if (beforeOfferJwtId) {
url += "&beforeId=" + beforeOfferJwtId;
}
const headers = await getHeaders(activeDid);
const response = await axios.get(url, { headers });
return response.data;
}
/**
*
* @returns { data: Array<OfferToPlanSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
*/
export async function getNewOffersToUserProjects(
axios: Axios,
apiServer: string,
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
if (afterOfferJwtId) {
url += "?afterId=" + afterOfferJwtId;
}
if (beforeOfferJwtId) {
url += afterOfferJwtId ? "&" : "?";
url += "beforeId=" + beforeOfferJwtId;
}
const headers = await getHeaders(activeDid);
const response = await axios.get(url, { headers });
return response.data;
}
/**
* Construct GiveAction VC for submission to server
*
@@ -584,6 +787,7 @@ export function hydrateGive(
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
providerPlanHandleId?: string,
lastClaimId?: string,
): GiveVerifiableCredential {
// Remember: replace values or erase if it's null
@@ -642,6 +846,10 @@ export function hydrateGive(
vcClaim.image = imageUrl || undefined;
vcClaim.provider = providerPlanHandleId
? { "@type": "PlanAction", identifier: providerPlanHandleId }
: undefined;
return vcClaim;
}
@@ -666,6 +874,7 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
providerPlanHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateGive(
undefined,
@@ -678,6 +887,7 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId,
isTrade,
imageUrl,
providerPlanHandleId,
undefined,
);
return createAndSubmitClaim(
@@ -710,6 +920,7 @@ export async function editAndSubmitGive(
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
providerPlanHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateGive(
fullClaim.claim,
@@ -722,6 +933,7 @@ export async function editAndSubmitGive(
fulfillsOfferHandleId,
isTrade,
imageUrl,
providerPlanHandleId,
fullClaim.id,
);
return createAndSubmitClaim(
@@ -916,7 +1128,7 @@ export async function createAndSubmitClaim(
} catch (error: any) {
console.error("Error submitting claim:", error);
const errorMessage: string =
error.response?.data?.error?.message ||
serverMessageForUser(error) ||
error.message ||
"Got some error submitting the claim. Check your permissions, network, and error logs.";
@@ -929,26 +1141,22 @@ export async function createAndSubmitClaim(
}
}
export async function generateEndorserJwtForAccount(
export async function generateEndorserJwtUrlForAccount(
account: Account,
isRegistered?: boolean,
name?: string,
profileImageUrl?: string,
// note that including the next key pushes QR codes to the next resolution smaller
includeNextKeyIfDerived?: boolean,
) {
const publicKeyHex = account.publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
interface UserInfo {
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
const contactInfo = {
iat: Date.now(),
iss: account.did,
own: {
did: account.did,
name: name ?? "",
publicEncKey,
registered: !!isRegistered,
@@ -958,7 +1166,8 @@ export async function generateEndorserJwtForAccount(
contactInfo.own.profileImageUrl = profileImageUrl;
}
if (account?.mnemonic && account?.derivationPath) {
// Add the next key -- not recommended for the QR code for such a high resolution
if (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) {
const newDerivPath = nextDerivationPath(account.derivationPath as string);
const nextPublicHex = deriveAddress(
account.mnemonic as string,
@@ -970,18 +1179,20 @@ export async function generateEndorserJwtForAccount(
Buffer.from(nextPublicEncKeyHash).toString("base64");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
}
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt;
}
export async function createEndorserJwtForDid(
issuerDid: string,
payload: object,
expiresIn?: number,
) {
const account = await getAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload);
const account = await retrieveFullyDecryptedAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn);
}
/**
@@ -1177,7 +1388,7 @@ export const claimSpecialDescription = (
export const BVC_MEETUPS_PROJECT_CLAIM_ID =
import.meta.env.VITE_BVC_MEETUPS_PROJECT_CLAIM_ID ||
"https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F"; // this won't resolve as a URL on production; it's a URN only found in the test system
"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H"; // production value, which seems like the safest value if forgotten
export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
return {
@@ -1211,19 +1422,24 @@ export async function createEndorserJwtVcFromClaim(
return createEndorserJwtForDid(issuerDid, vcPayload);
}
export async function register(
export async function createInviteJwt(
activeDid: string,
apiServer: string,
axios: Axios,
contact: Contact,
) {
contact?: Contact,
inviteId?: string,
expiresIn?: number,
): Promise<string> {
const vcClaim: RegisterVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction",
agent: { identifier: activeDid },
object: SERVICE_ID,
participant: { identifier: contact.did },
};
if (contact) {
vcClaim.participant = { identifier: contact.did };
}
if (inviteId) {
vcClaim.identifier = inviteId;
}
// Make a payload for the claim
const vcPayload = {
vc: {
@@ -1233,7 +1449,17 @@ export async function register(
},
};
// Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn);
return vcJwt;
}
export async function register(
activeDid: string,
apiServer: string,
axios: Axios,
contact: Contact,
): Promise<{ success?: boolean; error?: string }> {
const vcJwt = await createInviteJwt(activeDid, contact);
const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt });

View File

@@ -0,0 +1,9 @@
export interface UserProfile {
description: string;
locLat?: number;
locLon?: number;
locLat2?: number;
locLon2?: number;
issuerDid: string;
rowId?: string; // set on profile retrieved from server
}

View File

@@ -5,9 +5,9 @@ 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, NotificationIface } from "@/constants/app";
import {
accountsDB,
accountsDBPromise,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
@@ -21,6 +21,7 @@ import {
containsHiddenDid,
GenericCredWrapper,
GenericVerifiableCredential,
GiveSummaryRecord,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import { KeyMeta } from "@/libs/crypto/vc";
@@ -32,6 +33,14 @@ export interface GiverReceiverInputInfo {
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";
@@ -93,10 +102,29 @@ export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};
export const isGiveClaimType = (claimType?: string) => {
return claimType === "GiveAction";
};
export const isGiveAction = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return veriClaim.claimType === "GiveAction";
return isGiveClaimType(veriClaim.claimType);
};
export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) {
return (
did.substring(0, "did:peer:".length + 2) +
"..." +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
"..."
);
} else if (did.startsWith("did:ethr:")) {
return did.substring(0, "did:ethr:".length + 9) + "...";
} else {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
};
export const nameForDid = (
@@ -128,16 +156,92 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
.then(() => setTimeout(fn, 2000));
};
export interface ConfirmerData {
confirmerIdList: string[];
confsVisibleToIdList: string[];
numConfsNotVisible: number;
}
// // This is meant to be a second argument to JSON.stringify to avoid circular references.
// // Usage: JSON.stringify(error, getCircularReplacer())
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
// function getCircularReplacer() {
// const seen = new WeakSet();
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// return (obj: any, key: string, value: any): any => {
// if (typeof value === "object" && value !== null) {
// if (seen.has(value)) {
// return "[circular ref]";
// }
// seen.add(value);
// }
// return value;
// };
// }
/**
* @return only confirmers, excluding the issuer and hidden DIDs
*/
export async function retrieveConfirmerIdList(
apiServer: string,
claimId: string,
claimIssuerId: string,
userDid: string,
): Promise<ConfirmerData | undefined> {
const confirmUrl =
apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await serverUtil.getHeaders(userDid);
const response = await axios.get(confirmUrl, {
headers: confirmHeaders,
});
if (response.status === 200) {
const resultList1 = response.data.result || [];
//const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls;
// exclude hidden DIDs
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
// exclude the issuer
const resultList3 = R.reject(
(did: string) => did === claimIssuerId,
resultList2,
);
const confirmerIdList = resultList3;
let 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
numConfsNotVisible = numConfsNotVisible - 1;
}
const confsVisibleToIdList = response.data.result.resultVisibleToDids || [];
const result: ConfirmerData = {
confirmerIdList,
confsVisibleToIdList,
numConfsNotVisible,
};
return result;
} else {
console.error(
"Bad response status of",
response.status,
"for confirmers:",
response,
);
return undefined;
}
}
/**
* @returns true if the user can confirm the claim
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export const isGiveRecordTheUserCanConfirm = (
export function isGiveRecordTheUserCanConfirm(
isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string,
confirmerIdList: string[] = [],
) => {
): boolean {
return (
isRegistered &&
isGiveAction(veriClaim) &&
@@ -145,7 +249,78 @@ export const isGiveRecordTheUserCanConfirm = (
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
);
};
}
export function notifyWhyCannotConfirm(
notifyFun: (notification: NotificationIface, timeout: number) => void,
isRegistered: boolean,
claimType: string | undefined,
giveDetails: GiveSummaryRecord | undefined,
activeDid: string,
confirmerIdList: string[] = [],
) {
if (!isRegistered) {
notifyFun(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can confirm.",
},
3000,
);
} else if (!isGiveClaimType(claimType)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (confirmerIdList.includes(activeDid)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You already confirmed this claim.",
},
3000,
);
} else if (giveDetails?.issuerDid == activeDid) {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because some people are hidden.",
},
3000,
);
} else {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.",
},
3000,
);
}
}
export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -183,9 +358,9 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @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) => {
export function offerGiverDid(
veriClaim: GenericCredWrapper<OfferVerifiableCredential>,
): string | undefined {
let giver;
if (
veriClaim.claim.offeredBy?.identifier &&
@@ -196,7 +371,7 @@ export const offerGiverDid: (
giver = veriClaim.issuer;
}
return giver;
};
}
/**
* @returns true if the user can fulfill the offer
@@ -205,9 +380,9 @@ export const offerGiverDid: (
export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return !!(
return (
veriClaim.claimType === "Offer" &&
offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
);
};
@@ -279,10 +454,56 @@ export function findAllVisibleToDids(
export interface AccountKeyInfo extends Account, KeyMeta {}
export const getAccount = async (
export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
return await accountsDB.accounts.count();
};
export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
const allDids = allAccounts.map((acc) => acc.did);
return allDids;
};
// This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material.
export const retrieveAccountMetadata = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
await accountsDB.open();
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
} else {
return undefined;
}
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
return array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
});
};
export const retrieveFullyDecryptedAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
@@ -290,6 +511,15 @@ export const getAccount = async (
return account;
};
// let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo>
> => {
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
return allAccounts;
};
/**
* 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
@@ -303,7 +533,8 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
await accountsDB.open();
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
@@ -314,7 +545,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
});
await updateDefaultSettings({ activeDid: newId.did });
console.log("Updated default settings in util");
//console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;
@@ -334,7 +565,8 @@ export const registerAndSavePasskey = async (
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
await accountsDB.open();
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
return account;
};
@@ -356,6 +588,11 @@ export const getPasskeyExpirationSeconds = async (): Promise<number> => {
);
};
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean,
@@ -366,16 +603,12 @@ export const sendTestThroughPushServer = async (
pushUrl = settings.webPushServer;
}
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const newPayload = {
...subscriptionJSON,
// ... overridden with the following
// eslint-disable-next-line prettier/prettier
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
...subscriptionJSON,
};
console.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload);

View File

@@ -22,6 +22,8 @@ import {
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@@ -39,9 +41,13 @@ import {
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
@@ -54,6 +60,8 @@ import {
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
@@ -66,6 +74,7 @@ import {
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
@@ -92,6 +101,8 @@ library.add(
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@@ -109,9 +120,13 @@ library.add(
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
@@ -124,6 +139,8 @@ library.add(
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
@@ -137,6 +154,7 @@ library.add(
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,
@@ -162,11 +180,14 @@ function setupGlobalErrorHandler(app: VueApp) {
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Ouch! Global Error Handler.",
"Error:",
err,
"Instance:",
"- Error toString:",
err.toString(),
"- Info:",
info,
"- Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.

View File

@@ -5,7 +5,7 @@ import {
RouteLocationNormalized,
RouteRecordRaw,
} from "vue-router";
import { accountsDB } from "@/db/index";
import { accountsDBPromise } from "@/db/index";
/**
*
@@ -18,7 +18,8 @@ const enterOrStart = async (
from: RouteLocationNormalized,
next: NavigationGuardNext,
) => {
await accountsDB.open();
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
@@ -43,6 +44,11 @@ const routes: Array<RouteRecordRaw> = [
name: "claim-add-raw",
component: () => import("../views/ClaimAddRawView.vue"),
},
{
path: "/claim-cert/:id",
name: "claim-cert",
component: () => import("../views/ClaimCertificateView.vue"),
},
{
path: "/confirm-contact",
name: "confirm-contact",
@@ -58,13 +64,18 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-amounts",
component: () => import("../views/ContactAmountsView.vue"),
},
{
path: "/contact-edit/:did",
name: "contact-edit",
component: () => import("../views/ContactEditView.vue"),
},
{
path: "/contact-gift",
name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"),
},
{
path: "/contact-import",
path: "/contact-import/:jwt?",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
@@ -103,6 +114,11 @@ const routes: Array<RouteRecordRaw> = [
name: "help-notifications",
component: () => import("../views/HelpNotificationsView.vue"),
},
{
path: "/help-notification-types",
name: "help-notification-types",
component: () => import("../views/HelpNotificationTypesView.vue"),
},
{
path: "/help-onboarding",
name: "help-onboarding",
@@ -128,6 +144,21 @@ const routes: Array<RouteRecordRaw> = [
name: "import-derive",
component: () => import("../views/ImportDerivedAccountView.vue"),
},
{
path: "/invite-one",
name: "invite-one",
component: () => import("../views/InviteOneView.vue"),
},
{
path: "/invite-one-accept/:jwt?",
name: "InviteOneAcceptView",
component: () => import("@/views/InviteOneAcceptView.vue"),
},
{
path: "/new-activity",
name: "new-activity",
component: () => import("../views/NewActivityView.vue"),
},
{
path: "/new-edit-account",
name: "new-edit-account",
@@ -148,6 +179,21 @@ const routes: Array<RouteRecordRaw> = [
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{
path: "/onboard-meeting-list",
name: "onboard-meeting-list",
component: () => import("../views/OnboardMeetingListView.vue"),
},
{
path: "/onboard-meeting-members/:groupId",
name: "onboard-meeting-members",
component: () => import("../views/OnboardMeetingMembersView.vue"),
},
{
path: "/onboard-meeting-setup",
name: "onboard-meeting-setup",
component: () => import("../views/OnboardMeetingSetupView.vue"),
},
{
path: "/project/:id?",
name: "project",
@@ -174,6 +220,16 @@ const routes: Array<RouteRecordRaw> = [
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",
name: "scan-contact",
@@ -217,6 +273,11 @@ const routes: Array<RouteRecordRaw> = [
name: "test",
component: () => import("../views/TestView.vue"),
},
{
path: "/userProfile/:id?",
name: "userProfile",
component: () => import("../views/UserProfileView.vue"),
},
];
/** @type {*} */
@@ -233,6 +294,7 @@ const errorHandler = (
) => {
// Handle the error here
console.error("Caught in top level error handler:", error, to, from);
alert("Something is very wrong. Try reloading or restarting the app.");
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
};

File diff suppressed because it is too large Load Diff

View File

@@ -32,10 +32,12 @@
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";
import { NotificationIface } from "@/constants/app";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { errorStringForLog } from "@/libs/endorserServer";
@Component({
components: { QuickNav },
@@ -54,11 +56,55 @@ export default class ClaimAddRawView extends Vue {
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
if (this.claimStr) {
try {
const veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(veriClaim, null, 2);
} catch (e) {
// ignore a parse error
}
} else {
// there may be no link that uses this, meaning you'd have to enter it in a browser
const claimJwtId = (this.$route as Router).query["claimJwtId"];
if (claimJwtId) {
const urlPath = libsUtil.isGlobalUri(claimJwtId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimJwtId);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const response = await this.axios.get(url, { headers });
if (response.status === 200) {
const claim = response.data?.claim;
claim.lastClaimId = serverUtil.stripEndorserPrefix(claimJwtId);
this.claimStr = JSON.stringify(claim, null, 2);
} else {
throw {
message: "Got an error loading that claim.",
response: {
status: response.status,
statusText: response.statusText,
// url is in "fetch" response but not in AxiosResponse
},
};
}
} catch (error: unknown) {
logConsoleAndDb(
"Error retrieving claim: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error retrieving claim data.",
},
3000,
);
}
}
}
}
@@ -87,9 +133,9 @@ export default class ClaimAddRawView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the claim. See logs for more info.",
text: "There was a problem submitting the claim.",
},
-1,
5000,
);
}
}

View File

@@ -0,0 +1,270 @@
<template>
<section id="Content">
<div class="flex items-center justify-center h-screen">
<div v-if="claimData">
<router-link :to="'/claim/' + this.claimId">
<canvas class="w-full block mx-auto" ref="claimCanvas"></canvas>
</router-link>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as serverUtil from "@/libs/endorserServer";
@Component
export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
serverUtil = serverUtil;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,
);
this.claimId = pathParams;
await this.fetchClaim();
}
async fetchClaim() {
try {
const headers = await serverUtil.getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/claim/${this.claimId}`,
{ headers },
);
if (response.status === 200) {
this.claimData = await response.data;
const claimEntryIds = [this.claimId];
const headers = await serverUtil.getHeaders(this.activeDid);
const confirmerResponse = await this.axios.post(
`${this.apiServer}/api/v2/report/confirmers/?claimEntryIds=${this.claimId}`,
{ claimEntryIds },
{ headers },
);
let confirmerIds: Array<string> = [];
if (confirmerResponse.status === 200) {
confirmerIds = await confirmerResponse.data.data;
}
await nextTick(); // Wait for the DOM to update
if (this.claimData) {
this.drawCanvas(this.claimData, confirmerIds);
}
} else {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the claim.",
});
}
}
async drawCanvas(
claimData: serverUtil.GenericCredWrapper<serverUtil.GenericVerifiableCredential>,
confirmerIds: Array<string>,
) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
const backgroundImage = new Image();
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Set font and styles
ctx.fillStyle = "black";
// Draw claim type
ctx.font = "bold 20px Arial";
const claimTypeText =
claimData.claimType === "GiveAction"
? "Gift"
: claimData.claimType === "PlanAction"
? "Project"
: this.serverUtil.capitalizeAndInsertSpacesBeforeCaps(
claimData.claimType || "",
);
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
);
if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
const presentedText = "Thanks To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
const agentDid =
claimData.claim.agent.identifier || claimData.claim.agent;
const agentText = serverUtil.didInfoForCertificate(
agentDid,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.41,
);
}
// alternatively, show some offer details
if (claimData.claimType === "Offer") {
const presentedText = "To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
// fulfills
const agentDid =
claimData.claim.agent.identifier || claimData.claim.agent;
const agentText = serverUtil.didInfoForCertificate(
agentDid,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.41,
);
}
const descriptionText =
claimData.claim.name ||
claimData.claim.description ||
claimData.claim.itemOffered?.description; // for Offers
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
? descriptionText.substring(0, 75) + "..."
: descriptionText;
ctx.font = "14px Arial";
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.495,
);
}
const possibleObject =
claimData.claim.object || // for GiveActions
claimData.claim.includesObject; // for Offers
if (possibleObject?.amountOfThisGood && possibleObject?.unitCode) {
const amount = possibleObject.amountOfThisGood;
const unit = possibleObject.unitCode;
const amountText = serverUtil.displayAmount(unit, amount);
const amountWidth = ctx.measureText(amountText).width;
// if there was no description then put this in that spot, otherwise put it below the description
const yPos = descriptionText
? CANVAS_HEIGHT * 0.525
: CANVAS_HEIGHT * 0.495;
ctx.font = "14px Arial";
ctx.fillText(amountText, (CANVAS_WIDTH - amountWidth) / 2, yPos);
}
// Draw claim issuer
if (
claimData.issuer == null ||
serverUtil.isHiddenDid(claimData.issuer) ||
// don't show if issuer claimed for themselves
// (The confirmations are the good stuff anyway, and self-issued certs shouldn't detract from that.)
claimData.issuer !== claimData.claim.agent?.identifier
) {
ctx.font = "14px Arial";
let fullIssuer = serverUtil.didInfoForCertificate(
claimData.issuer,
allContacts,
);
if (fullIssuer.length > 30) {
fullIssuer = fullIssuer.substring(0, 30) + "...";
}
const issuerText = "Issued by " + fullIssuer;
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
}
// Draw number of claim confirmers
if (confirmerIds.length > 0) {
const confirmerText =
"Confirmed by " +
confirmerIds.length +
(confirmerIds.length === 1 ? " person" : " people");
ctx.font = "14px Arial";
ctx.fillText(
confirmerText,
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.63,
);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
};
}
}
}
}
</script>

View File

@@ -0,0 +1,190 @@
<template>
<section id="Content">
<div v-if="claimData">
<canvas ref="claimCanvas"></canvas>
</div>
</section>
</template>
<style scoped>
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as endorserServer from "@/libs/endorserServer";
@Component
export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
endorserServer = endorserServer;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,
);
this.claimId = pathParams;
await this.fetchClaim();
}
async fetchClaim() {
try {
const response = await fetch(
`${this.apiServer}/api/claim/${this.claimId}`,
);
if (response.ok) {
this.claimData = await response.json();
await nextTick(); // Wait for the DOM to update
if (this.claimData) {
this.drawCanvas(this.claimData);
}
} else {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the claim.",
});
}
}
async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
const backgroundImage = new Image();
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Set font and styles
ctx.fillStyle = "black";
// Draw claim type
ctx.font = "bold 20px Arial";
const claimTypeText =
this.endorserServer.capitalizeAndInsertSpacesBeforeCaps(
claimData.claimType || "",
);
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
);
if (claimData.claim.agent) {
const presentedText = "Presented to ";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
const agentText = endorserServer.didInfoForCertificate(
claimData.claim.agent,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.4,
);
}
const descriptionText =
claimData.claim.name || claimData.claim.description;
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
? descriptionText.substring(0, 75) + "..."
: descriptionText;
ctx.font = "14px Arial";
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.45,
);
}
// Draw claim issuer & recipient
if (claimData.issuer) {
ctx.font = "14px Arial";
const issuerText =
"Issued by " +
endorserServer.didInfoForCertificate(
claimData.issuer,
allContacts,
);
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
};
}
}
}
}
</script>

View File

@@ -17,40 +17,50 @@
</div>
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<h2 class="text-md font-bold">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<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>
{{ veriClaim.id }}
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4 w-full">
<div class="block flex gap-4 overflow-hidden w-full">
<div class="w-full">
<div class="flex columns-3">
<h2 class="text-md font-bold w-full">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button
@click="
libsUtil.doCopyTwoSecRedo(
veriClaim.id as string,
() => (showIdCopy = !showIdCopy),
)
v-if="
['GiveAction', 'Offer', 'PlanAction'].includes(
veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
// a PlanAction agent also could edit one of those,
// but rather than add more Plan-specific logic to detect the agent
// we'll let them click the Project link and edit from there
"
class="ml-2 mr-2"
@click="onClickEditClaim"
title="Edit"
data-testId="editClaimButton"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<span v-show="showIdCopy">Copied ID</span>
</h2>
<div class="flex justify-center w-full">
<router-link
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2"
title="Printable Certificate"
>
<fa icon="square" class="text-white bg-yellow-500 p-1" />
</router-link>
</div>
<!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full">
<button
title="Copy Link"
@click="
copyToClipboard('A link to this page', window.location.href)
"
>
<fa icon="link" class="text-slate-500" />
</button>
</div>
</div>
<div class="text-sm">
<div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" />
{{
@@ -60,21 +70,7 @@
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400" />
{{ veriClaim.issuer }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
veriClaim.issuer as string,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
{{ didInfo(veriClaim.issuer) }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
@@ -86,10 +82,19 @@
</a>
</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>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId">
<div v-if="detailsForGive?.fulfillsPlanHandleId" class="mt-4">
<router-link
:to="
'/project/' +
@@ -113,7 +118,7 @@
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
class="text-blue-500 mt-4"
class="text-blue-500 mt-4 cursor-pointer"
>
Fulfills
{{
@@ -136,10 +141,52 @@
Offered to a bigger plan...
</router-link>
</div>
<!-- Providers -->
<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-2">
<fa icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<!--
<div>
<router-link :to="'/claim-cert/' + encodeURIComponent(veriClaim.id)">
<fa icon="file-contract" class="text-slate-400" />
<span class="ml-2 text-blue-500">Printable Certificate</span>
</router-link>
</div>
-->
<div class="mt-8">
<button
@@ -151,6 +198,7 @@
<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">
@@ -182,13 +230,16 @@
</router-link>
</span>
</div>
<GiftedDialog ref="customGiveDialog" />
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person has confirmed this.
</span>
<span v-else> {{ totalConfirmers() }} people have confirmed this. </span>
<div class="mt-2">
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person has confirmed this.
</span>
<span v-else>
{{ totalConfirmers() }} people have confirmed this.
</span>
</div>
<div v-if="totalConfirmers() > 0">
<div
@@ -207,7 +258,7 @@
Nobody that you know has issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim.
The following people have confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
@@ -219,16 +270,13 @@
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
<a
:href="`/did/${confirmerId}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</div>
</div>
@@ -260,16 +308,13 @@
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
<a
:href="`/did/${confsVisibleTo}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</div>
</div>
@@ -293,10 +338,16 @@
</div>
</div>
<div>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
</h2>
<!-- Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue -->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
@@ -307,24 +358,26 @@
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,
You can ask one 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
>click to send them this page 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.
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, 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,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('This page location', windowLocation)"
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>share this page with them</a
>click to copy this page info</a
>
and see if they are willing to make an introduction.
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span>
</div>
@@ -341,7 +394,7 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation)"
@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
@@ -368,18 +421,22 @@
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="copyToClipboard('The DID of ' + visDid, visDid)"
<a
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a
>, found at&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]"
target="_blank"
class="text-blue-500"
>{{
>
<fa icon="globe" class="fa-fw" />
{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
@@ -394,53 +451,51 @@
</div>
</div>
<span v-if="isEditedGlobalId" class="mt-2">
This record is an edited version. The latest version is here.
This record is an edited version. The latest version is shown.
</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
v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Full Claim</h2>
<p class="mb-4">
The full claim includes the claim as it was originally issued, including
the signature (ie. the proof of issuance by that person).
</p>
<div v-if="!fullClaim">
<p v-if="fullClaimMessage" class="mb-4">
{{ fullClaimMessage }}
<h2 class="text-xl mt-8 mb-2">Full Claim</h2>
<p class="mb-4">
The full claim includes the claim as it was originally issued, including
the signature (ie. the proof of issuance by that person).
</p>
<button
v-else
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
@click="showFullClaim(veriClaim.id as string)"
>
Load Full Claim Details
</button>
</div>
<div v-else>
<pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div>
<div v-if="!fullClaim">
<p v-if="fullClaimMessage" class="mb-4">
{{ fullClaimMessage }}
</p>
<button
v-else
class="text-blue-500 cursor-pointer"
@click="showFullClaim(veriClaim.id as string)"
>
<fa icon="file-lines" class="fa-fw" />
Load Full Claim Details
</button>
</div>
<div v-else>
<pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div>
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
>
View on the Public Server
</a>
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="fa-fw" />
<fa icon="arrow-up-right-from-square" class="ml-1 fa-fw" />
View on the Public Server
</a>
</div>
</section>
</template>
@@ -453,17 +508,25 @@ import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
import {
GenericCredWrapper,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
interface ProviderInfo {
identifier: string; // could be a DID or a handleId
linkConfirmed: boolean;
}
@Component({
components: { GiftedDialog, QuickNav },
@@ -487,8 +550,9 @@ export default class ClaimView extends Vue {
fullClaimMessage = "";
isEditedGlobalId = false;
isRegistered = false;
issuerName = "";
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
showDidCopy = false;
providersForGive: ProviderInfo[] = [];
showIdCopy = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
@@ -500,6 +564,7 @@ export default class ClaimView extends Vue {
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
window = window;
resetThisValues() {
this.confirmerIdList = [];
@@ -511,10 +576,11 @@ export default class ClaimView extends Vue {
this.fullClaimDump = "";
this.fullClaimMessage = "";
this.isEditedGlobalId = false;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.providersForGive = [];
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
this.veriClaimDidsVisible = {};
}
async created() {
@@ -524,10 +590,24 @@ export default class ClaimView extends Vue {
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);
try {
this.allMyDids = await libsUtil.retrieveAccountDids();
} catch (error) {
// continue because we want to see claims, even anonymously
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "See the Help page for problems with your personal data.",
},
5000,
);
}
const pathParam = window.location.pathname.substring("/claim/".length);
let claimId;
@@ -542,7 +622,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "No claim ID was provided.",
},
-1,
5000,
);
}
@@ -588,6 +668,7 @@ export default class ClaimView extends Vue {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.veriClaim = resp.data;
this.issuerName = this.didInfo(this.veriClaim.issuer);
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
@@ -603,7 +684,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "There was a problem retrieving that claim.",
},
-1,
5000,
);
return;
}
@@ -620,11 +701,39 @@ export default class ClaimView extends Vue {
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
if (giveResp.status === 200) {
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(
{
group: "alert",
type: "warning",
title: "Error",
text: "Got error retrieving linked provider data.",
},
5000,
);
}
} else if (this.veriClaim.claimType === "Offer") {
const offerUrl =
this.apiServer +
@@ -638,36 +747,29 @@ export default class ClaimView extends Vue {
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.",
},
5000,
);
}
}
// 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;
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject(
(did: string) => did === this.veriClaim.issuer,
resultList2,
);
this.confirmerIdList = resultList3;
this.numConfsNotVisible = resultList1.length - resultList2.length;
if (resultList3.length === resultList2.length) {
// the issuer was not in the "visible" list so they must be hidden
// so subtract them from the non-visible confirmers count
this.numConfsNotVisible = this.numConfsNotVisible - 1;
}
this.confsVisibleToIdList =
response.data.result.resultVisibleToDids || [];
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
@@ -682,7 +784,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "Something went wrong retrieving claim data.",
},
-1,
3000,
);
}
}
@@ -705,31 +807,53 @@ export default class ClaimView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem getting that claim. See logs for more info.",
text: "There was a problem getting that claim.",
},
-1,
5000,
);
}
} catch (error: unknown) {
console.error("Error retrieving full claim:", error);
const serverError = error as AxiosError;
if (serverError.response?.status === 403) {
let issuerPhrase = "";
const issuerContact = serverUtil.contactForDid(
this.veriClaim.issuer,
this.allContacts,
);
if (issuerContact?.name) {
issuerPhrase +=
"Ask " +
issuerContact.name +
" to show you the full claim details.";
}
if (
this.confirmerIdList.length > 0 ||
this.confsVisibleToIdList.length > 0
) {
if (issuerContact?.name) {
issuerPhrase +=
"You could also ask someone in the Confirmations section to make an introduction.";
} else {
issuerPhrase +=
"Ask someone in the Confirmations section to make an introduction.";
}
}
this.fullClaimMessage =
"You are not authorized to view the full contents of this claim." +
" To see all the details, ask the issuer to allow you to see their claims." +
" If you cannot see the issuer's DID, ask someone in the Confirmations section above." +
" If there are no connections, you will have to ask people in your" +
" network for their help, some other way; send them to this page and" +
" see if they can make a connection for you.";
issuerPhrase +
" You might ask someone in your network -- like the person who registered you --" +
" if they can find out more and make an introduction: " +
" send them this page and see if they can make a connection for you.";
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving that claim. See logs for more info.",
text: "Something went wrong retrieving that claim.",
},
-1,
5000,
);
}
}
@@ -790,9 +914,9 @@ export default class ClaimView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
text: "There was a problem submitting the confirmation.",
},
-1,
5000,
);
}
}
@@ -838,9 +962,10 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
});
}
@@ -866,6 +991,12 @@ export default class ClaimView extends Vue {
},
};
(this.$router as Router).push(route);
} else if (this.veriClaim.claimType === "PlanAction") {
const route = {
name: "new-edit-project",
query: { projectId: this.veriClaim.handleId },
};
(this.$router as Router).push(route);
} else {
console.error(
"Unrecognized claim type for edit:",
@@ -884,3 +1015,37 @@ export default class ClaimView extends Vue {
}
}
</script>
<style>
/*
Tooltip, generated on "title" attributes on "fa" icons
Kudos to https://www.w3schools.com/css/css_tooltip.asp
*/
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
}
.tooltip:hover .tooltiptext-left {
visibility: visible;
}
</style>

View File

@@ -25,7 +25,7 @@
>
Do you agree?
</span>
<span v-else> Details </span>
<span v-else> Confirmation Details </span>
</h1>
</div>
@@ -54,13 +54,6 @@
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 -->
@@ -69,7 +62,7 @@
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-down" class="fa-fw text-slate-400" />
<fa icon="arrow-left" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
@@ -84,7 +77,7 @@
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-up" class="fa-fw text-slate-400" />
<fa icon="arrow-right" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
@@ -172,7 +165,7 @@
Nobody that you know issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people issued or confirmed this claim.
The following people confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
@@ -257,19 +250,20 @@
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
You cannot confirm this because some people are hidden.
</div>
</div>
<!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
@click="showDetails = !showDetails"
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
<span v-else><fa icon="chevron-up" /></span>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
</h2>
<div v-if="showDetails">
<div v-if="showVeriClaimDump">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
@@ -280,22 +274,26 @@
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,
You can ask one 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
>click to send them this page info</a
>
and see if they are willing to make an introduction.
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, 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,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('Location', windowLocation.href)"
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>share this page with them</a
>click to copy this page info</a
>
and see if they are willing to make an introduction.
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span>
</div>
@@ -312,7 +310,7 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation.href)"
@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
@@ -372,20 +370,29 @@
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
<div class="mt-2 ml-2">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" />
See All Generic Info
</a>
</div>
<div class="mt-2 ml-2">
<a
v-if="isRegistered"
class="text-blue-500 cursor-pointer"
:href="urlForNewGive"
>
<fa icon="file-lines" />
Record a Give Similar to the Original
</a>
</div>
</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"
@@ -405,13 +412,12 @@ 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 { db, retrieveSettingsForActiveAccount } from "@/db/index";
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 { isGiveAction, retrieveAccountDids } from "@/libs/util";
import TopMessage from "@/components/TopMessage.vue";
@Component({
@@ -437,12 +443,12 @@ export default class ClaimView extends Vue {
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showDetails = false;
showVeriClaimDump = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location;
windowLocation = window.location.href;
R = R;
yaml = yaml;
@@ -469,10 +475,7 @@ export default class ClaimView extends Vue {
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);
this.allMyDids = await retrieveAccountDids();
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
@@ -655,39 +658,21 @@ export default class ClaimView extends Vue {
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&projectId=" +
"&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 || [];
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
@@ -762,7 +747,7 @@ export default class ClaimView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
text: "There was a problem submitting the confirmation.",
},
5000,
);
@@ -796,6 +781,17 @@ export default class ClaimView extends Vue {
}
notifyWhyCannotConfirm() {
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.veriClaim.claimType,
this.giveDetails,
this.activeDid,
this.confirmerIdList,
);
}
notifyWhyCannotConfirmBak() {
if (!this.isRegistered) {
this.$notify(
{
@@ -842,7 +838,7 @@ export default class ClaimView extends Vue {
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because it contains hidden identifiers.",
text: "You cannot confirm this because some people are hidden.",
},
3000,
);
@@ -852,7 +848,7 @@ export default class ClaimView extends Vue {
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim.",
text: "You cannot confirm this claim. There are no other details, but we can help more if you contact us and send us screenshots.",
},
3000,
);
@@ -860,10 +856,11 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
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,
url: this.windowLocation,
});
}
}

View File

@@ -112,7 +112,7 @@ import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
AgreeVerifiableCredential,
@@ -123,6 +123,7 @@ import {
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import { retrieveAccountCount } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue {
@@ -137,8 +138,7 @@ export default class ContactAmountssView extends Vue {
displayAmount = displayAmount;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
this.numAccounts = await retrieveAccountCount();
}
async created() {
@@ -165,7 +165,7 @@ export default class ContactAmountssView extends Vue {
err.userMessage ||
"There was an error retrieving your settings or contacts or gives.",
},
-1,
5000,
);
}
}
@@ -196,7 +196,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
5000,
);
}
@@ -223,7 +223,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
5000,
);
}
@@ -241,7 +241,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: error as string,
},
-1,
5000,
);
}
}
@@ -278,7 +278,7 @@ export default class ContactAmountssView extends Vue {
(origClaim.object?.amountOfThisGood as number) || 1;
}
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
@@ -297,7 +297,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: userMessage,
},
-1,
5000,
);
}
}
@@ -310,7 +310,7 @@ export default class ContactAmountssView extends Vue {
title: "Not Allowed",
text: "Only the recipient can confirm final receipt.",
},
-1,
5000,
);
}
}

View File

@@ -0,0 +1,238 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="ContactEdit" class="p-6 max-w-3xl mx-auto">
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-4xl 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>
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h1>
</div>
<!-- Contact Name -->
<div class="mt-4 flex" data-testId="contactName">
<label
for="contactName"
class="block text-sm font-medium text-gray-700 mt-2"
>
Name
</label>
<input
type="text"
class="block w-full ml-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactName"
/>
</div>
<!-- Contact Notes -->
<div class="mt-4">
<label for="contactNotes" class="block text-sm font-medium text-gray-700">
Notes
</label>
<textarea
id="contactNotes"
rows="4"
class="block w-full mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactNotes"
></textarea>
</div>
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
type="text"
v-model="method.label"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
type="text"
v-model="method.type"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
@click="toggleDropdown(index)"
class="px-2 py-1 bg-gray-200 rounded-md"
>
<fa icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
@click="setMethodType(index, 'CELL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
CELL
</div>
<div
@click="setMethodType(index, 'EMAIL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
EMAIL
</div>
<div
@click="setMethodType(index, 'WHATSAPP')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
WHATSAPP
</div>
</div>
</div>
<input
type="text"
v-model="method.value"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button @click="removeContactMethod(index)" class="ml-2 text-red-500">
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<button @click="addContactMethod" class="mt-2">
<fa
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
/>
</button>
</div>
<!-- Save Button -->
<div class="mt-8 flex justify-between">
<button
class="px-4 py-2 bg-blue-500 text-white rounded-md"
@click="saveEdit"
>
Save
</button>
<button
class="ml-4 px-4 py-2 bg-slate-500 text-white rounded-md"
@click="$router.go(-1)"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation, Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class ContactEditView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
contact: Contact = {
did: "",
name: "",
notes: "",
};
contactName = "";
contactNotes = "";
contactMethods: Array<ContactMethod> = [];
dropdownIndex: number | null = null;
AppString = AppString;
async created() {
const contactDid = (this.$route as RouteLocation).params.did;
const contact = await db.contacts.get(contactDid || "");
if (contact) {
this.contact = contact;
this.contactName = contact.name || "";
this.contactNotes = contact.notes || "";
this.contactMethods = contact.contactMethods || [];
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Contact Not Found",
text: "There is no contact with DID " + contactDid,
});
(this.$router as Router).push({ path: "/contacts" });
return;
}
}
addContactMethod() {
this.contactMethods.push({ label: "", type: "", value: "" });
}
removeContactMethod(index: number) {
this.contactMethods.splice(index, 1);
}
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
async saveEdit() {
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
R.set(R.lensProp("type"), method.type.toUpperCase(), method),
);
if (!R.equals(contactMethodsObj, contactMethods)) {
this.contactMethods = contactMethods;
this.$notify(
{
group: "alert",
type: "warning",
title: "Contact Methods Updated",
text: "Note that some methods have been updated, such as uppercasing 'email' to 'EMAIL'. Save again if the changes are acceptable.",
},
15000,
);
return;
}
await db.contacts.update(this.contact.did, {
name: this.contactName,
notes: this.contactNotes,
contactMethods: contactMethods,
});
this.$notify({
group: "alert",
type: "success",
title: "Contact Saved",
text: "The contact info has been updated successfully.",
});
(this.$router as Router).push({
path: "/did/" + encodeURIComponent(this.contact.did),
});
}
}
</script>

View File

@@ -65,7 +65,7 @@
</li>
</ul>
<GiftedDialog ref="customDialog" :projectId="projectId" />
<GiftedDialog ref="customDialog" :toProjectId="projectId" />
</section>
</template>
@@ -123,7 +123,7 @@ export default class ContactGiftingView extends Vue {
err.message ||
"There was an error retrieving your settings or contacts.",
},
-1,
5000,
);
}
}

View File

@@ -16,87 +16,134 @@
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 v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<!-- 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"
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<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"
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
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
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
<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></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 font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<button
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-if="contactsImporting.length > 0">
All those contacts are already in your list with the same information.
</p>
<div v-else>
There are no contacts in that import. If some were sent, try again to
get the full text and paste it. (Note that iOS cuts off data in text
messages.) Ask the person to send the data a different way, eg. email.
<div class="mt-4 text-center">
<textarea
v-model="inputJwt"
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br />
<button
@click="() => processContactJwt(inputJwt)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Check Import
</button>
</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>
</div>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, 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";
import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import {
capitalizeAndInsertSpacesBeforeCaps,
errorStringForLog,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { getContactJwtFromJwtUrl } from "@/libs/crypto";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
@@ -105,6 +152,7 @@ export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil;
R = R;
@@ -115,9 +163,16 @@ export default class ContactImportView extends Vue {
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<string, { new: string; old: string }>
Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false;
checkingImports = false;
inputJwt: string = "";
makeVisible = true;
sameCount = 0;
@@ -126,13 +181,54 @@ export default class ContactImportView extends Vue {
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(
false,
);
// look for any imported contact array from the query parameter
const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
.query["contacts"] as string;
if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts));
}
// look for a JWT after /contact-import/ in the window.location.pathname
const jwt = window.location.pathname.match(
/\/contact-import\/(ey.+)$/,
)?.[1];
if (jwt) {
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
// eslint-disable-next-line prettier/prettier
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
// decode the JWT
const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> =
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
(this.$router as Router).push({
name: "contacts",
query: { contactJwt: jwt },
});
}
if (contacts) {
await this.setContactsSelected(contacts);
} else {
// no contacts found so default message should be OK
}
}
if (
this.contactsImporting.length === 1 &&
R.isEmpty(this.contactsExisting)
) {
// if there is only one contact and it's new, then we will automatically import it
this.contactsSelected[0] = true;
this.importContacts(); // ... which routes to the contacts list
}
}
async setContactsSelected(contacts: Array<Contact>) {
this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open();
const baseContacts = await db.contacts.toArray();
@@ -145,12 +241,19 @@ export default class ContactImportView extends Vue {
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<string, { new: string; old: string }> = {};
const differences: Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> = {};
Object.keys(contactIn).forEach((key) => {
if (contactIn[key] !== existingContact[key]) {
// eslint-disable-next-line prettier/prettier
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
differences[key] = {
old: existingContact[key],
new: contactIn[key],
old: existingContact[key as keyof Contact],
new: contactIn[key as keyof Contact],
};
}
});
@@ -158,15 +261,66 @@ export default class ContactImportView extends Vue {
if (R.isEmpty(differences)) {
this.sameCount++;
}
} else {
// automatically import new data
this.contactsSelected[i] = true;
// don't automatically import previous data
this.contactsSelected[i] = false;
}
}
}
// check the contact-import JWT
async checkContactJwt(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("contact-import") ||
jwtInput.endsWith("contact-import/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
},
5000,
);
}
}
// process the invite JWT and/or text message containing the URL with the JWT
async processContactJwt(jwtInput: string) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {
await this.setContactsSelected(payload.contacts);
} else {
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
}
} catch (error) {
const fullError = "Error importing contacts: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing the contact-import data.",
},
3000,
);
}
this.checkingImports = false;
}
async importContacts() {
this.importing = true;
this.checkingImports = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
@@ -178,6 +332,7 @@ export default class ContactImportView extends Vue {
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
// DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key.
await db.contacts.add(R.clone(contact));
importedCount++;
}
@@ -186,22 +341,24 @@ export default class ContactImportView extends Vue {
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 (this.contactsSelected[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) {
if (failedVisibileToContacts.length > 0) {
this.$notify(
{
group: "alert",
@@ -216,7 +373,7 @@ export default class ContactImportView extends Vue {
}
}
this.importing = false;
this.checkingImports = false;
this.$notify(
{

View File

@@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<fa icon="chevron-left" class="fa-fw" />
</h1>
</div>
@@ -34,8 +34,8 @@
click here to set it for them.
</span>
</p>
<UserNameDialog ref="userNameDialog" />
</div>
<UserNameDialog ref="userNameDialog" />
<div
@click="onCopyUrlToClipboard()"
@@ -90,10 +90,7 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
@@ -101,24 +98,18 @@ import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import UserNameDialog from "@/components/UserNameDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getContactJwtFromJwtUrl } from "@/libs/crypto";
import {
deriveAddress,
getContactPayloadFromJwtUrl,
nextDerivationPath,
} from "@/libs/crypto";
import {
CONTACT_URL_PREFIX,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
generateEndorserJwtForAccount,
generateEndorserJwtUrlForAccount,
isDid,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { retrieveAccountMetadata } from "@/libs/util";
@Component({
components: {
@@ -149,50 +140,18 @@ export default class ContactQRScanShow extends Vue {
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
const publicKeyHex = account.publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
own: {
name:
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""), // lastName is deprecated, pre v 0.1.3
publicEncKey,
profileImageUrl: settings.profileImageUrl,
registered: settings.isRegistered,
},
};
if (account?.mnemonic && account?.derivationPath) {
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");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
}
const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
viewPrefix + vcJwt;
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3
this.qrValue = await generateEndorserJwtForAccount(
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl,
false,
);
}
}
@@ -220,8 +179,8 @@ export default class ContactQRScanShow extends Vue {
if (url) {
let newContact: Contact;
try {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) {
this.$notify(
{
group: "alert",
@@ -233,8 +192,9 @@ export default class ContactQRScanShow extends Vue {
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.iss as string,
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
@@ -401,7 +361,7 @@ export default class ContactQRScanShow extends Vue {
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
@@ -446,7 +406,7 @@ export default class ContactQRScanShow extends Vue {
useClipboard()
.copy(this.qrValue)
.then(() => {
console.log("Contact URL:", this.qrValue);
// console.log("Contact URL:", this.qrValue);
this.$notify(
{
group: "alert",

View File

@@ -23,20 +23,67 @@
<!-- New Contact -->
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<span class="flex" v-if="isRegistered">
<router-link
:to="{ name: 'invite-one' }"
class="flex items-center 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-1 mr-1 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<button
@click="showOnboardMeetingDialog()"
class="flex items-center 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-1 mr-1 rounded-md"
>
<fa icon="chair" class="fa-fw text-2xl" />
</button>
</span>
<span v-else class="flex">
<span
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="
warning(
'You must get registered before you can create invites.',
'Not Registered',
)
"
/>
</span>
<span
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa
icon="chair"
class="fa-fw text-2xl"
@click="
warning(
'You must get registered before you can initiate an onboarding meeting.',
'Not Registered',
)
"
/>
</span>
</span>
<router-link
:to="{ name: 'contact-qr' }"
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
class="flex items-center 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-1 mr-1 rounded-md"
>
<fa icon="qrcode" class="fa-fw text-2xl" />
</router-link>
<textarea
type="text"
placeholder="URL or DID, Name, Public Key, Next Public Key Hash"
placeholder="New URL or DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
v-model="contactInput"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
class="px-4 rounded-r bg-green-200 border border-l-0 border-green-400"
@click="onClickNewContact()"
>
<fa icon="plus" class="fa-fw" />
@@ -45,32 +92,36 @@
<div class="flex justify-between" v-if="contacts.length > 0">
<div class="w-full text-left">
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<div v-if="!showGiveNumbers">
<input
type="checkbox"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<button @click="showCopySelectionsInfo()">
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</button>
</div>
</div>
<div class="w-full text-right">
@@ -79,7 +130,9 @@
class="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 py-1 rounded-md"
@click="toggleShowContactAmounts()"
>
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }}
{{
showGiveNumbers ? "Hide Hours, Offer, etc" : "See Hours, Offer, etc"
}}
</button>
</div>
</div>
@@ -144,26 +197,35 @@
)
: contactsSelected.push(contact.did)
"
class="ml-2 h-6 w-6"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
/>
<h2 class="text-base font-semibold ml-2">
{{ contact.name || AppString.NO_CONTACT_NAME }}
<h2
class="text-base font-semibold ml-2 w-1/3 truncate flex-shrink-0"
>
{{ contactNameNonBreakingSpace(contact.name) }}
</h2>
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-blue-500 ml-4" />
</router-link>
<span>
<div class="flex items-center">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</router-link>
<span class="ml-4 text-sm overflow-hidden"
>{{ shortDid(contact.did) }}...</span
><!-- The first 18 characters of did:peer are the same. -->
<span class="ml-4 text-sm overflow-hidden">{{
libsUtil.shortDid(contact.did)
}}</span>
</div>
<div class="ml-4 text-sm">
{{ contact.notes }}
</div>
</span>
</div>
<div id="ContactActions" class="flex gap-1.5 mt-2">
<div
@@ -172,6 +234,25 @@
>
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md"
@click="confirmShowGiftedDialog(contact.did, activeDid)"
:title="givenToMeDescriptions[contact.did] || ''"
>
From:
<br />
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
</button>
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l"
@click="confirmShowGiftedDialog(activeDid, contact.did)"
:title="givenByMeDescriptions[contact.did] || ''"
>
@@ -187,34 +268,12 @@
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<br />
<fa icon="plus" />
</button>
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l"
@click="confirmShowGiftedDialog(contact.did, this.activeDid)"
:title="givenToMeDescriptions[contact.did] || ''"
>
From:
<br />
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<br />
<fa icon="plus" />
</button>
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did, contact.name)"
data-testId="offerButton"
>
Offer
</button>
@@ -266,6 +325,7 @@
<GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full">
<div
@@ -286,37 +346,56 @@
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { IndexableType } from "dexie";
import { JWTPayload } from "did-jwt";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import ContactNameDialog from "@/components/ContactNameDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
import { getContactJwtFromJwtUrl } from "@/libs/crypto";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import {
CONTACT_CSV_HEADER,
CONTACT_URL_PREFIX,
createEndorserJwtForDid,
errorStringForLog,
GiveSummaryRecord,
getHeaders,
isDid,
register,
setVisibilityUtil,
UserInfo,
VerifiableCredential,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import { generateSaveAndActivateIdentity } from "@/libs/util";
@Component({
components: { TopMessage, GiftedDialog, EntityIcon, OfferDialog, QuickNav },
components: {
GiftedDialog,
EntityIcon,
OfferDialog,
QuickNav,
ContactNameDialog,
TopMessage,
},
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -360,6 +439,11 @@ export default class ContactsView extends Vue {
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
// if these detect a query parameter, they can and then redirect to this URL without a query parameter
// to avoid problems when they reload or they go forward & back and it tries to reprocess
await this.processContactJwt();
await this.processInviteJwt();
this.showGiveNumbers = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
@@ -376,6 +460,144 @@ export default class ContactsView extends Vue {
);
}
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
}
}
private async processInviteJwt() {
// handle an invite JWT sent via URL
const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["inviteJwt"] as string;
if (importedInviteJwt === "") {
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.$notify(
{
group: "alert",
type: "danger",
title: "Blank Invite",
text: "The invite was not included, which can happen when your iOS device cuts off the link. Try pasting the full link into a browser.",
},
7000,
);
} else if (importedInviteJwt) {
// make sure user is created
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
}
// send invite directly to server, with auth for this user
const headers = await getHeaders(this.activeDid);
try {
const response = await this.axios.post(
this.apiServer + "/api/v2/claim",
{ jwtEncoded: importedInviteJwt },
{ headers },
);
if (response.status != 201) {
throw { error: { response: response } };
}
await updateAccountSettings(this.activeDid, { isRegistered: true });
this.isRegistered = true;
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "You are now registered.",
},
3000,
);
// wait for a second before continuing so they see the registration message
await new Promise((resolve) => setTimeout(resolve, 1000));
// now add the inviter as a contact
// (similar code is in InviteOneAcceptView.vue)
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?",
"",
async (name) => {
await this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: name,
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
async () => {
// on cancel, will still add the contact
await this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: "(person who invited you)",
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
const fullError = "Error redeeming invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
let message = "Got an error sending the invite.";
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;
}
} else if (error.message) {
message = error.message;
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error with Invite",
text: message,
},
5000,
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
}
}
private contactNameNonBreakingSpace(contactName?: string) {
return (contactName || AppString.NO_CONTACT_NAME).replace(/\s/g, "\u00A0");
}
private danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
@@ -388,6 +610,33 @@ export default class ContactsView extends Vue {
);
}
private warning(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "warning",
title: title,
text: message,
},
timeout,
);
}
private showOnboardingInfo() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "They're Added To Your List",
text: "Would you like to go to the main page now?",
onYes: async () => {
(this.$router as Router).push({ name: "home" });
},
},
-1,
);
}
private filteredContacts() {
return this.showGiveNumbers
? this.contactsSelected.length === 0
@@ -443,13 +692,13 @@ export default class ContactsView extends Vue {
(useRecipient ? "given" : "received") +
" data from the server.",
},
-1,
3000,
);
}
};
try {
const headers = await getHeaders(this.activeDid);
const headers = await getHeaders(this.activeDid, this.$notify);
const givenByUrl =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
@@ -492,7 +741,8 @@ export default class ContactsView extends Vue {
this.givenToMeConfirmed = givenToMeConfirmed;
this.givenToMeUnconfirmed = givenToMeUnconfirmed;
} catch (error) {
console.error("Error loading gives", error);
const fullError = "Error loading gives: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
@@ -500,7 +750,7 @@ export default class ContactsView extends Vue {
title: "Load Error",
text: "Got an error loading your gives.",
},
5000,
3000,
);
}
}
@@ -508,12 +758,37 @@ export default class ContactsView extends Vue {
private async onClickNewContact(): Promise<void> {
const contactInput = this.contactInput.trim();
if (!contactInput) {
this.danger("There was no contact info to add.", "No Contact");
this.danger(
"There was no contact info to add. Try the other green buttons.",
"No Contact",
);
return;
}
if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(contactInput);
if (contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(contactInput);
(this.$router as Router).push({
path: "/contact-import/" + jwt,
});
return;
}
if (
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
) {
const jwt = getContactJwtFromJwtUrl(contactInput);
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // "did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return;
}
@@ -538,6 +813,9 @@ export default class ContactsView extends Vue {
3000, // keeping it up so that the "visibility" message is seen
);
} catch (e) {
const fullError =
"Error adding contacts from CSV: " + errorStringForLog(e);
logConsoleAndDb(fullError, true);
this.danger("An error occurred. Some contacts may have been added.");
}
@@ -604,6 +882,9 @@ export default class ContactsView extends Vue {
query: { contacts: JSON.stringify(contacts) },
});
} catch (e) {
const fullError =
"Error adding contacts from array: " + errorStringForLog(e);
logConsoleAndDb(fullError, true);
this.danger("The input could not be parsed.", "Invalid Contact List");
}
return;
@@ -655,31 +936,6 @@ export default class ContactsView extends Vue {
return db.contacts.add(newContact);
}
private async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
} else {
return this.addContact({
did: payload.iss,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
} as Contact);
}
}
private async addContact(newContact: Contact) {
if (!newContact.did) {
this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
@@ -716,7 +972,7 @@ export default class ContactsView extends Vue {
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await updateDefaultSettings({
hideRegisterPromptOnNewContact: stopAsking,
@@ -724,7 +980,7 @@ export default class ContactsView extends Vue {
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await updateDefaultSettings({
hideRegisterPromptOnNewContact: stopAsking,
@@ -739,7 +995,7 @@ export default class ContactsView extends Vue {
},
-1,
);
}, 500);
}, 1000);
}
}
this.$notify(
@@ -753,7 +1009,9 @@ export default class ContactsView extends Vue {
);
})
.catch((err) => {
console.error("Error when adding contact to storage:", err);
const fullError =
"Error when adding contact to storage: " + errorStringForLog(err);
logConsoleAndDb(fullError, true);
let message = "An error prevented this import.";
if (
err.message?.indexOf("Key already exists in the object store.") > -1
@@ -765,7 +1023,7 @@ export default class ContactsView extends Vue {
message +=
" Check that the contact doesn't conflict with any you already have.";
}
this.danger(message, "Contact Not Added", -1);
this.danger(message, "Contact Not Added", 5000);
});
}
@@ -814,7 +1072,7 @@ export default class ContactsView extends Vue {
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
3000,
);
} else {
this.$notify(
@@ -830,12 +1088,20 @@ export default class ContactsView extends Vue {
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
const fullError = "Error when registering: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
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;
if (serverError.isAxiosError) {
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"error" in serverError.response.data &&
typeof serverError.response.data.error === "object" &&
serverError.response.data.error !== null &&
"message" in serverError.response.data.error
) {
userMessage = serverError.response.data.error.message as string;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
@@ -891,7 +1157,10 @@ export default class ContactsView extends Vue {
}
return true;
} else {
console.error("Got strange result from setting visibility:", result);
console.error(
"Got strange result from setting visibility. It can happen when setting visibility on oneself.",
result,
);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
@@ -948,8 +1217,8 @@ export default class ContactsView extends Vue {
}
private showGiftedDialog(giverDid: string, recipientDid: string) {
let giver: libsUtil.GiverReceiverInputInfo;
let receiver: libsUtil.GiverReceiverInputInfo;
let giver: libsUtil.GiverReceiverInputInfo | undefined;
let receiver: libsUtil.GiverReceiverInputInfo | undefined;
if (giverDid) {
giver = {
did: giverDid,
@@ -972,7 +1241,7 @@ export default class ContactsView extends Vue {
newList[recipientDid] = (newList[recipientDid] || 0) + amount;
this.givenByMeUnconfirmed = newList;
};
customTitle = "Given to " + receiver.name;
customTitle = "Given to " + (receiver?.name || "Someone Unnamed");
} else {
// must be (recipientDid == this.activeDid)
callback = (amount: number) => {
@@ -980,14 +1249,14 @@ export default class ContactsView extends Vue {
newList[giverDid] = (newList[giverDid] || 0) + amount;
this.givenToMeUnconfirmed = newList;
};
customTitle = "Received from " + giver.name;
customTitle = "Received from " + (giver?.name || "Someone Unnamed");
}
(this.$refs.customGivenDialog as GiftedDialog).open(
giver,
receiver,
undefined as string,
undefined as unknown as string,
customTitle,
undefined as string,
undefined as unknown as string,
callback,
);
}
@@ -1006,6 +1275,9 @@ export default class ContactsView extends Vue {
showContactGivesInline: newShowValue,
});
} catch (err) {
const fullError =
"Error updating contact-amounts setting: " + errorStringForLog(err);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
@@ -1013,11 +1285,7 @@ export default class ContactsView extends Vue {
title: "Error Updating Contact Setting",
text: "The setting may not have saved. Try again, maybe after restarting the app.",
},
-1,
);
console.error(
"Telling user to try again after contact-amounts setting update because:",
err,
5000,
);
}
this.showGiveNumbers = newShowValue;
@@ -1058,44 +1326,116 @@ export default class ContactsView extends Vue {
};
}
private copySelectedContacts() {
private async copySelectedContacts() {
if (this.contactsSelected.length === 0) {
this.danger("You must select contacts to copy.");
return;
}
const selectedContacts = this.contacts.filter((c) =>
const selectedContactsFull = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
const message =
"To add contacts, paste this into the box on the 'Contacts' screen.\n\n" +
JSON.stringify(selectedContacts);
const selectedContacts: Array<Contact> = selectedContactsFull.map((c) => {
const contact: Contact = {
did: c.did,
name: c.name,
};
if (c.nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
}
if (c.profileImageUrl) {
contact.profileImageUrl = c.profileImageUrl;
}
if (c.publicKeyBase64) {
contact.publicKeyBase64 = c.publicKeyBase64;
}
return contact;
});
// console.log(
// "Array of selected contacts:",
// JSON.stringify(selectedContacts),
// );
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
useClipboard()
.copy(message)
.copy(contactsJwtUrl)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Those contacts were copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
text: "The link for those contacts is now in the clipboard.",
},
5000,
3000,
);
});
}
private shortDid(did: string) {
if (did.startsWith("did:peer:")) {
return (
did.substring(0, "did:peer:".length + 2) +
"..." +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
"..."
private showCopySelectionsInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Copying Contacts",
text: "Contact info will include name, ID, profile image, and public key.",
},
5000,
);
}
private async showOnboardMeetingDialog() {
try {
// First check if they're in a meeting
const headers = await getHeaders(this.activeDid);
const memberResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
if (memberResponse.data.data) {
// They're in a meeting, check if they're the host
const hostResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers },
);
if (hostResponse.data.data) {
// They're the host, take them to setup
(this.$router as Router).push({ name: "onboard-meeting-setup" });
} else {
// They're not the host, take them to list
(this.$router as Router).push({ name: "onboard-meeting-list" });
}
} else {
// They're not in a meeting, show the dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
onYes: async () => {
(this.$router as Router).push({ name: "onboard-meeting-setup" });
},
yesText: "Start New Meeting",
onNo: async () => {
(this.$router as Router).push({ name: "onboard-meeting-list" });
},
noText: "Join Existing Meeting",
},
-1,
);
}
} catch (error) {
logConsoleAndDb(
"Error checking meeting status:" + errorStringForLog(error),
);
this.danger(
"There was an error checking your meeting status.",
"Meeting Error",
);
} else if (did.startsWith("did:ethr:")) {
return did.substring(0, "did:ethr:".length + 9) + "...";
} else {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
}
}

View File

@@ -6,7 +6,7 @@
<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">
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
@@ -26,23 +26,19 @@
<div>
<h2 class="text-xl font-semibold">
{{ contactFromDid?.name || "(no name)" }}
<button
@click="
contactEdit = true;
contactNewName = (contactFromDid?.name as string) || '';
"
title="Edit"
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</router-link>
</h2>
<button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
>
Details
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
<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
@@ -163,33 +159,6 @@
</div>
</div>
<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"
@@ -199,7 +168,9 @@
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">Claims That Involve Them</div>
<div class="text-l font-bold text-center">
Claims That Involve {{ isMyDid ? "You" : "Them" }}
</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
@@ -251,7 +222,7 @@ 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 { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox } from "@/db/tables/settings";
import {
@@ -287,8 +258,6 @@ export default class DIDView extends Vue {
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contactFromDid?: Contact;
contactEdit = false;
contactNewName: string = "";
contactYaml = "";
hitEnd = false;
isLoading = false;
@@ -309,22 +278,31 @@ export default class DIDView extends Vue {
this.apiServer = settings.apiServer || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.$notify(
{
group: "alert",
type: "toast",
title: "Your Info",
text: "No user was specified so showing your info.",
},
3000,
);
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
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;
}
}
const allAccountDids = await libsUtil.retrieveAccountDids();
this.isMyDid = allAccountDids.includes(this.viewingDid);
}
}
@@ -439,7 +417,7 @@ export default class DIDView extends Vue {
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
@@ -516,7 +494,7 @@ export default class DIDView extends Vue {
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
3000,
);
} finally {
this.isLoading = false;
@@ -562,29 +540,6 @@ export default class DIDView extends Vue {
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
@@ -729,6 +684,7 @@ export default class DIDView extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -6,30 +6,70 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Discover Projects
Discover Projects & People
</h1>
<OnboardingDialog ref="onboardingDialog" />
<!-- Quick Search -->
<div
id="QuickSearch"
class="mt-8 mb-4 flex"
v-on:keyup.enter="searchSelected()"
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
>
<input
type="text"
v-model="searchTerms"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-on:keyup.enter="searchSelected()"
/>
<button
@click="searchSelected()"
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="searchSelected()"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</div>
<!-- Result Tabs -->
<!-- Top Level Selection -->
<div class="text-center text-slate-500 border-b border-slate-300 mb-4">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
<a
href="#"
@click="
projects = [];
userProfiles = [];
isProjectsActive = true;
isPeopleActive = false;
searchSelected();
"
v-bind:class="computedProjectsTabStyleClassNames()"
>
Projects
</a>
</li>
<li>
<a
href="#"
@click="
projects = [];
userProfiles = [];
isProjectsActive = false;
isPeopleActive = true;
searchSelected();
"
v-bind:class="computedPeopleTabStyleClassNames()"
>
People
</a>
</li>
</ul>
</div>
<!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
@@ -37,19 +77,25 @@
href="#"
@click="
projects = [];
userProfiles = [];
isLocalActive = true;
isRemoteActive = false;
isMappedActive = false;
isAnywhereActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchLocal();
"
v-bind:class="computedLocalTabStyleClassNames()"
>
Nearby
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
-->
</a>
</li>
<li>
@@ -57,35 +103,80 @@
href="#"
@click="
projects = [];
isRemoteActive = true;
userProfiles = [];
isLocalActive = false;
isMappedActive = true;
isAnywhereActive = false;
isSearchVisible = false;
searchTerms = '';
tempSearchBox = null;
"
v-bind:class="computedMappedTabStyleClassNames()"
>
<!-- search is triggered when map component gets to "ready" state -->
Mapped
</a>
</li>
<li>
<a
href="#"
@click="
projects = [];
userProfiles = [];
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isSearchVisible = true;
tempSearchBox = null;
searchAll();
"
v-bind:class="computedRemoteTabStyleClassNames()"
>
Anywhere
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isRemoteActive"
v-if="isAnywhereActive"
>
{{ remoteCount > -1 ? remoteCount : "?" }}
</span>
-->
</a>
</li>
</ul>
</div>
<div v-if="isLocalActive">
<div>
<div class="text-center">
<button
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })"
>
<fa icon="location-dot" class="fa-fw" />
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
</button>
</div>
</div>
<div v-if="isMappedActive && !tempSearchBox">
<div class="mt-4 h-96 w-5/6 mx-auto">
<l-map
ref="projectMap"
@ready="onMapReady"
@moveend="onMoveEnd"
@movestart="onMoveStart"
@zoomend="onZoomEnd"
@zoomstart="onZoomStart"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
</l-map>
</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"
@@ -93,67 +184,157 @@
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<div v-else-if="projects.length === 0" class="text-center mt-8">
<div
v-else-if="projects.length === 0 && userProfiles.length === 0"
class="text-center mt-8"
>
<p class="text-lg text-slate-500">
No projects were found with that search.
<span v-if="isLocalActive">Try looking Anywhere.</span>
<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-if="isAnywhereActive"
>No projects were found with that search.</span
>
</p>
</div>
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<ul id="listDiscoverResults">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
<!-- Projects List -->
<template v-if="isProjectsActive">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
>
<div>
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
:imageUrl="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
}}
<a
@click="onClickLoadItem(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
>
<div>
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
:imageUrl="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
</div>
</a>
</li>
<div class="grow">
<h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(
project.issuerDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
</div>
</a>
</li>
</template>
<!-- Profiles List -->
<template v-else>
<li
class="border-b border-slate-300"
v-for="profile in userProfiles"
:key="profile.issuerDid"
>
<a
@click="onClickLoadItem(profile?.rowId || '')"
class="block py-4 flex gap-4 cursor-pointer"
>
<div class="grow">
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(
profile.issuerDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
<p
v-if="profile.description"
class="mt-1 text-sm text-slate-600"
>
{{ profile.description }}
</p>
<div
v-if="isAnywhereActive && profile.locLat && profile.locLon"
class="mt-1 text-xs text-slate-500"
>
<fa icon="location-dot" class="fa-fw"></fa>
{{
(profile.locLat > 0 ? "North" : "South") +
" in " +
(profile.locLon > 0 ? "Eastern" : "Western") +
" Hemisphere"
}}
</div>
</div>
</a>
</li>
</template>
</ul>
</InfiniteScroll>
</section>
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import * as L from "leaflet";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import OnboardingDialog from "@/components/OnboardingDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { DEFAULT_PARTNER_API_SERVER, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox } from "@/db/tables/settings";
import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
import {
didInfo,
errorStringForLog,
getHeaders,
PlanData,
} from "@/libs/endorserServer";
import { UserProfile } from "@/libs/partnerServer";
import { OnboardPage, retrieveAccountDids } from "@/libs/util";
interface Tile {
indexLat: number;
indexLon: number;
minFoundLat: number;
maxFoundLat: number;
minFoundLon: number;
maxFoundLon: number;
recordCount: number;
}
@Component({
components: {
InfiniteScroll,
LMap,
LTileLayer,
OnboardingDialog,
ProjectIcon,
QuickNav,
TopMessage,
@@ -161,19 +342,32 @@ import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
})
export default class DiscoverView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
searchTerms = "";
projects: PlanData[] = [];
isLoading = false;
isLocalActive = true;
isRemoteActive = false;
isMappedActive = false;
isAnywhereActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
localCenterLat = 0;
localCenterLong = 0;
localCount = -1;
markers: { [key: string]: L.Marker } = {};
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
projects: PlanData[] = [];
remoteCount = -1;
searchBox: { name: string; bbox: BoundingBox } | null = null;
searchTerms = "";
tempSearchBox: BoundingBox | null = null;
userProfiles: UserProfile[] = [];
zoomedSoDoNotMove = false;
// make this function available to the Vue template
didInfo = didInfo;
@@ -182,21 +376,32 @@ export default class DiscoverView extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = (settings.activeDid as string) || "";
this.apiServer = (settings.apiServer as string) || "";
this.partnerApiServer =
(settings.partnerApiServer as string) || this.partnerApiServer;
this.searchBox = settings.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
this.allMyDids = await retrieveAccountDids();
this.searchTerms = (this.$route as Router).query["searchText"] || "";
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);
}
if (this.searchBox) {
await this.searchLocal();
const bbox = this.searchBox.bbox;
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
} else {
this.isLocalActive = false;
this.isRemoteActive = true;
this.isMappedActive = false;
this.isAnywhereActive = true;
await this.searchAll();
}
}
@@ -209,6 +414,9 @@ export default class DiscoverView extends Vue {
public async searchSelected() {
if (this.isLocalActive) {
await this.searchLocal();
} else if (this.isMappedActive) {
const mapRef = this.$refs.projectMap as L.Map;
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else {
await this.searchAll();
}
@@ -220,6 +428,7 @@ export default class DiscoverView extends Vue {
if (!beforeId) {
// this was an initial search so clear any previous results
this.projects = [];
this.userProfiles = [];
}
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
@@ -228,64 +437,60 @@ export default class DiscoverView extends Vue {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plans"
: this.partnerApiServer + "/api/partner/userProfile";
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/plans?" + queryParams,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
const response = await fetch(endpoint + "?" + queryParams, {
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.`,
},
-1,
);
throw details;
}
const results = await response.json();
const plans: PlanData[] = results.data;
if (plans) {
for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
if (this.isProjectsActive) {
this.userProfiles = [];
const plans: PlanData[] = results.data;
if (plans) {
this.projects.push(...plans);
this.remoteCount = this.projects.length;
} else {
throw JSON.stringify(results);
}
this.remoteCount = this.projects.length;
} else {
throw JSON.stringify(results);
this.projects = [];
const profiles: UserProfile[] = results.data;
if (profiles) {
this.userProfiles.push(...profiles);
this.remoteCount = this.userProfiles.length;
} else {
throw JSON.stringify(results);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
console.error("Error with search all:", e);
// this sometimes gives different information
console.error("Error with feed load (error added): " + e);
console.error("Error with search all (error added): " + e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
title: "Error Searching",
text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
},
-1,
5000,
);
} finally {
this.isLoading = false;
@@ -295,14 +500,20 @@ export default class DiscoverView extends Vue {
public async searchLocal(beforeId?: string) {
this.resetCounts();
if (!this.searchBox) {
const searchBox =
(this.isMappedActive && this.tempSearchBox) ||
(this.isLocalActive && this.searchBox?.bbox);
if (!searchBox) {
this.projects = [];
this.userProfiles = [];
return;
}
if (!beforeId) {
// this was an initial search so clear any previous results
this.projects = [];
this.userProfiles = [];
}
const claimContents =
@@ -310,74 +521,68 @@ export default class DiscoverView extends Vue {
let queryParams = [
claimContents,
"minLocLat=" + this.searchBox.bbox.minLat,
"maxLocLat=" + this.searchBox.bbox.maxLat,
"westLocLon=" + this.searchBox.bbox.westLong,
"eastLocLon=" + this.searchBox.bbox.eastLong,
"minLocLat=" + searchBox.minLat,
"maxLocLat=" + searchBox.maxLat,
"minLocLon=" + searchBox.westLong,
"maxLocLon=" + searchBox.eastLong,
].join("&");
if (beforeId) {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plansByLocation"
: this.partnerApiServer + "/api/partner/userProfile";
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
const response = await fetch(endpoint + "?" + queryParams, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with nearby search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem accessing the server. Try again later.",
},
-1,
);
throw await response.text();
throw details;
}
const results = await response.json();
if (results.data) {
if (beforeId) {
const plans: PlanData[] = results.data;
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
issuerDid,
rowid,
});
}
if (this.isProjectsActive) {
this.userProfiles = [];
const plans: PlanData[] = results.data;
if (plans) {
this.projects.push(...plans);
this.localCount = this.projects.length;
} else {
this.projects = results.data;
throw JSON.stringify(results);
}
this.localCount = this.projects.length;
} else {
throw JSON.stringify(results);
this.projects = [];
const profiles: UserProfile[] = results.data;
if (profiles) {
this.userProfiles.push(...profiles);
this.localCount = this.userProfiles.length;
} else {
throw JSON.stringify(results);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
console.error("Error with search local:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
},
-1,
5000,
);
} finally {
this.isLoading = false;
@@ -389,25 +594,156 @@ export default class DiscoverView extends Vue {
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive) {
this.searchLocal(latestProject["rowid"]);
} else if (this.isRemoteActive) {
this.searchAll(latestProject["rowid"]);
if (payload) {
if (this.isProjectsActive && this.projects.length > 0) {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId);
} else if (this.isAnywhereActive) {
this.searchAll(latestProject.rowId);
}
} else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProfile.rowId || "");
} else if (this.isAnywhereActive) {
this.searchAll(latestProfile.rowId || "");
}
}
}
}
clearMarkers() {
Object.values(this.markers).forEach((marker) => marker.remove());
this.markers = {};
}
async onMapReady(map: L.Map) {
// doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup
map.setView([this.localCenterLat, this.localCenterLong], 2);
this.requestTiles(map);
}
// Tried but failed to use other vue-leaflet methods update:zoom and update:bounds
// To access the from this.$refs, use this.$refs.projectMap.leafletObject (or maybe mapObject)
onMoveStart(/* event: L.LocationEvent */) {
// don't remove markers because they follow the map when moving (and the experience is jarring)
}
async onMoveEnd(event: L.LocationEvent) {
if (this.zoomedSoDoNotMove) {
// since a zoom triggers a moveend, too, don't duplicate a tile request
this.zoomedSoDoNotMove = false;
} else {
// not part of a zoom so request tiles
await this.requestTiles(event.target);
}
}
onZoomStart(/* event: L.LocationEvent */) {
// remove markers because otherwise they jump around at zoom end
this.clearMarkers();
this.zoomedSoDoNotMove = true;
}
async onZoomEnd(event: L.LocationEvent) {
await this.requestTiles(event.target);
}
async requestTiles(targetMap: L.Map) {
try {
const bounds = targetMap.getBounds();
const queryParams = [
"minLocLat=" + bounds?.getSouthWest().lat,
"maxLocLat=" + bounds?.getNorthEast().lat,
"westLocLon=" + bounds?.getSouthWest().lng,
"eastLocLon=" + bounds?.getNorthEast().lng,
].join("&");
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/planCountsByBBox"
: this.partnerApiServer + "/api/partner/userProfileCountsByBBox";
const response = await fetch(endpoint + "?" + queryParams);
if (response.status === 200) {
this.clearMarkers();
const results = await response.json();
if (results.data?.tiles?.length > 0) {
for (const tile: Tile of results.data.tiles) {
const pinLat = (tile.minFoundLat + tile.maxFoundLat) / 2;
const pinLon = (tile.minFoundLon + tile.maxFoundLon) / 2;
const numberIcon = L.divIcon({
className: "numbered-marker",
html: `<strong>${tile.recordCount}</strong>`,
iconSize: [24, 24],
// Why isn't this showing?
iconAnchor: [12, 12], // coordinates of the tip of the icon relative to the top-left corner of the icon
});
const marker = L.marker([pinLat, pinLon], { icon: numberIcon });
marker.addTo(targetMap);
marker.on("click", () => {
this.tempSearchBox = {
minLat: tile.minFoundLat,
maxLat: tile.maxFoundLat,
westLong: tile.minFoundLon,
eastLong: tile.maxFoundLon,
};
this.searchLocal();
});
this.markers[
"" +
tile.indexLat +
"X" +
tile.indexLon +
"_" +
tile.minFoundLat +
"X" +
tile.minFoundLon +
"-" +
tile.maxFoundLat +
"X" +
tile.maxFoundLon
] = marker;
}
}
} else {
throw {
message: "Got an error loading projects on the map.",
response: {
status: response.status,
statusText: response.statusText,
url: response.url,
},
};
}
} catch (e) {
logConsoleAndDb(
"Error loading projects on the map: " + errorStringForLog(e),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Map Error",
text: "There was a problem loading projects on the map.",
},
3000,
);
}
}
/**
* Handle clicking on a project entry found in the list
* @param id of the project
* Handle clicking on a project or profile entry found in the list
* @param id of the project or profile
**/
onClickLoadProject(id: string) {
onClickLoadItem(id: string) {
const route = {
path: "/project/" + encodeURIComponent(id),
path: this.isProjectsActive
? "/project/" + encodeURIComponent(id)
: "/userProfile/" + encodeURIComponent(id),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
public computedLocalTabStyleClassNames() {
@@ -428,6 +764,24 @@ export default class DiscoverView extends Vue {
};
}
public computedMappedTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isMappedActive,
"text-black": this.isMappedActive,
"border-black": this.isMappedActive,
"font-semibold": this.isMappedActive,
"text-blue-600": !this.isMappedActive,
"border-transparent": !this.isMappedActive,
"hover:border-slate-400": !this.isMappedActive,
};
}
public computedRemoteTabStyleClassNames() {
return {
"inline-block": true,
@@ -435,15 +789,67 @@ export default class DiscoverView extends Vue {
"rounded-t-lg": true,
"border-b-2": true,
active: this.isRemoteActive,
"text-black": this.isRemoteActive,
"border-black": this.isRemoteActive,
"font-semibold": this.isRemoteActive,
active: this.isAnywhereActive,
"text-black": this.isAnywhereActive,
"border-black": this.isAnywhereActive,
"font-semibold": this.isAnywhereActive,
"text-blue-600": !this.isRemoteActive,
"border-transparent": !this.isRemoteActive,
"hover:border-slate-400": !this.isRemoteActive,
"text-blue-600": !this.isAnywhereActive,
"border-transparent": !this.isAnywhereActive,
"hover:border-slate-400": !this.isAnywhereActive,
};
}
public computedProjectsTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isProjectsActive,
"text-black": this.isProjectsActive,
"border-black": this.isProjectsActive,
"font-semibold": this.isProjectsActive,
"text-blue-600": !this.isProjectsActive,
"border-transparent": !this.isProjectsActive,
"hover:border-slate-400": !this.isProjectsActive,
};
}
public computedPeopleTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isPeopleActive,
"text-black": this.isPeopleActive,
"border-black": this.isPeopleActive,
"font-semibold": this.isPeopleActive,
"text-blue-600": !this.isPeopleActive,
"border-transparent": !this.isPeopleActive,
"hover:border-slate-400": !this.isPeopleActive,
};
}
}
</script>
<style>
.numbered-marker {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 14px;
font-weight: bold;
color: white;
background: blue;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid white;
}
</style>

View File

@@ -21,15 +21,25 @@
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>From {{ giverName || "someone not named" }}</span>
<span>
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</span>
<br />
<span>
to
{{
givenToProject
? projectName
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone unidentified"
: "someone not named"
}}</span
>
</h1>
@@ -64,7 +74,7 @@
</div>
</div>
<div class="flex justify-center mt-4" data-testid="imagery">
<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" />
@@ -85,51 +95,127 @@
</div>
<ImageMethodDialog ref="imageDialog" />
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !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="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
<div class="mt-4 flex justify-between gap-2">
<!-- First Column for Giver -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<input
v-if="giverDid && !providedByProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByGiver"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
giverDid
? "This was provided by " + giverName + "."
: "No named individual gave."
}}
</label>
<fa
v-if="!giverDid || providedByProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfGiver()"
/>
</div>
<div class="flex">
<input
v-if="providerProjectId && !providedByGiver"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByProject"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
providerProjectId
? "This was provided by " + providerProjectName + "."
: "This was not provided by a project."
}}
</label>
<fa
v-if="!providerProjectId || providedByGiver"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfProvidingProject()"
/>
</div>
</div>
<div class="flex-shrink flex justify-center items-center">
<fa icon="arrow-right" class="fa-fw h-7" />
</div>
<!-- Third Column for Recipient -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<input
v-if="recipientDid && !givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName + "."
: "No individual benefitted."
}}
</label>
<fa
v-if="!recipientDid || givenToProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfRecipient()"
/>
</div>
<div class="flex">
<input
v-if="fulfillsProjectId && !givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
fulfillsProjectId
? "This was given to " + fulfillsProjectName + ". "
: "No project benefitted."
}}
</label>
<fa
v-if="!fulfillsProjectId || givenToRecipient"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserFulfillsProject()"
/>
</div>
</div>
</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">
<div class="mt-8 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>
@@ -144,7 +230,7 @@
}"
class="text-blue-500"
>
Edit & Submit Raw
Edit Raw Data
</router-link>
</div>
@@ -181,7 +267,7 @@ 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 { db, retrieveSettingsForActiveAccount } from "@/db/index";
import {
createAndSubmitGive,
didInfo,
@@ -193,7 +279,7 @@ import {
hydrateGive,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: {
@@ -211,9 +297,11 @@ export default class GiftedDetails extends Vue {
amountInput = "0";
description = "";
destinationPathAfter = "";
givenToProject = false;
givenToRecipient = false;
giverDid: string | undefined;
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 = "";
giverName = "";
hideBackButton = false;
imageUrl = "";
@@ -221,8 +309,10 @@ export default class GiftedDetails extends Vue {
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
projectId = "";
projectName = "a project";
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;
@@ -282,11 +372,31 @@ export default class GiftedDetails extends Vue {
offer?.identifier ||
this.offerId) as string;
// find any project ID
const project = fulfillsArray.find((rec) => rec["@type"] === "PlanAction");
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) 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;
@@ -318,69 +428,65 @@ export default class GiftedDetails extends Vue {
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
}
try {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
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,
);
}
if (
(this.giverDid && !this.giverName) ||
(this.recipientDid && !this.recipientName)
) {
const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids();
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.projectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
// 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,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.fulfillsProjectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
if (this.projectId) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
// 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.projectName = project?.name
? "the project: " + project.name
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";
}
}
@@ -471,7 +577,7 @@ export default class GiftedDetails extends Vue {
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);
console.log("Weird: the image was already deleted.", error);
localStorage.removeItem("imageUrl");
this.imageUrl = "";
@@ -545,25 +651,24 @@ export default class GiftedDetails extends Vue {
await this.recordGive();
}
notifyUserOfProject() {
if (!this.projectId) {
notifyUserOfGiver() {
if (!this.giverDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this page through a project.",
title: "Go To The Contacts Page",
text: "To assign a giver, you must open this page from a contact.",
},
3000,
);
} else {
// must be because givenToRecipient is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
title: "Unavailable",
text: "You cannot assign both a giver and a project.",
},
3000,
);
@@ -576,7 +681,7 @@ export default class GiftedDetails extends Vue {
{
group: "alert",
type: "warning",
title: "Error",
title: "Go To The Contacts Page",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
@@ -587,7 +692,7 @@ export default class GiftedDetails extends Vue {
{
group: "alert",
type: "warning",
title: "Error",
title: "Unavailable",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
@@ -595,6 +700,58 @@ export default class GiftedDetails extends Vue {
}
}
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: "Go To The Project Page",
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: "Unavailable",
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: "Go To The Project Page",
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: "Unavailable",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
/**
*
* @param giverDid may be null
@@ -604,10 +761,13 @@ export default class GiftedDetails extends Vue {
*/
public async recordGive() {
try {
const giverDid = this.providedByGiver ? this.giverDid : undefined;
const recipientDid = this.givenToRecipient
? this.recipientDid
: undefined;
const projectId = this.givenToProject ? this.projectId : 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
@@ -616,30 +776,32 @@ export default class GiftedDetails extends Vue {
this.apiServer,
this.prevCredToEdit,
this.activeDid,
this.giverDid,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
} else {
result = await createAndSubmitGive(
this.axios,
this.apiServer,
this.activeDid,
this.giverDid,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
}
@@ -656,7 +818,7 @@ export default class GiftedDetails extends Vue {
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
5000,
);
} else {
this.$notify(
@@ -666,7 +828,7 @@ export default class GiftedDetails extends Vue {
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
3000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
@@ -689,25 +851,29 @@ export default class GiftedDetails extends Vue {
title: "Error",
text: errorMessage,
},
-1,
5000,
);
}
}
constructGiveParam() {
const giverDid = this.providedByGiver ? this.giverDid : undefined;
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined;
const fulfillsProjectId = this.givenToProject
? this.fulfillsProjectId
: undefined;
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
@@ -746,7 +912,7 @@ export default class GiftedDetails extends Vue {
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
7000,
);
}
}

View File

@@ -0,0 +1,68 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Notification Types
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p>There are two types of notifications:</p>
<h2 class="text-xl font-semibold mt-4">Reminder Notifications</h2>
<div>
<p>
The Reminder Notification will be sent to you daily with a specific message,
at whatever time you choose. Use it to remind
yourself to act, for example: pause and consider who has given you
something, so you can record thanks in here.
</p>
<p>
This is a reliable message, but it doesn't contain any details about
activity that might be especially interesting to you.
</p>
</div>
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
<div>
<p>
The New Activity Notification will be sent to you when there is new, relevant activity for you.
It will only trigger if something involves you or a project of interest; it will not
bug you for other, general activity.
</p>
<p>
This type is not as reliable as a Reminder Notification because mobile devices often suppress
such notifications to save battery. (If you want to quickly check for relevant activity daily,
use the Reminder Notification and open the app and look for a large green button that points out new
activity that is personal to you. We are working on other ways to notify you more
reliably -- <router-link class="text-blue-500" to="/help">go here to follow us or contact us</router-link>.)
</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 HelpNotificationTypesView extends Vue {}
</script>

View File

@@ -39,6 +39,15 @@
</p>
</div>
<h2 class="text-xl font-semibold mt-4">Android Users</h2>
<div>
<p>
Note that you may not receive notifications when the app is in the
background. When you're done working, close the app, and then you'll
get the reminder notifications.
</p>
</div>
<h2 class="text-xl font-semibold mt-4">
If this app doesn't support notifications...
<!-- Note that that exact verbiage shows in a message elsewhere. -->
@@ -66,6 +75,7 @@
<button class="text-blue-500" @click="showNotificationChoice()">
Click here.
</button>
<PushNotificationPermission ref="pushNotificationPermission" />
</p>
</div>
@@ -184,14 +194,18 @@
<h2 class="text-xl font-semibold mt-4">Reinstall</h2>
<div>
<p>
If all else fails, uninstall the app, ensure all the browser tabs with
it are closed, and clear out caches and storage.
If all else fails, it's best to start over.
</p>
<p>
Of course, you'll want to back up all your data first -- all seeds as
well as the contacts & settings -- on the Account
well as the contacts & settings -- on the Profile
<fa icon="circle-user" /> page.
</p>
<p>
Here are instructions to uninstall the app and clear out caches and storage.
Note that you should first ensure check that the browser tabs with Time Safari are closed.
(If any are open then that will interfere with your refresh.)
</p>
<ul class="ml-4 list-disc">
<li>
Clear cache.
@@ -295,9 +309,12 @@ import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { sendTestThroughPushServer } from "@/libs/util";
import { DIRECT_PUSH_TITLE, sendTestThroughPushServer } from "@/libs/util";
import PushNotificationPermission from "@/components/PushNotificationPermission.vue";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({ components: { QuickNav } })
@Component({ components: { PushNotificationPermission, QuickNav } })
export default class HelpNotificationsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -305,8 +322,8 @@ export default class HelpNotificationsView extends Vue {
async mounted() {
try {
const registration = await navigator.serviceWorker.ready;
const fullSub = await registration.pushManager.getSubscription();
const registration = await navigator.serviceWorker?.ready;
const fullSub = await registration?.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) {
console.error("Mount error:", error);
@@ -314,10 +331,10 @@ export default class HelpNotificationsView extends Vue {
}
alertWebPushSubscription() {
console.log(
"Web push subscription:",
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
);
// console.log(
// "Web push subscription:",
// JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
// );
alert(JSON.stringify(this.subscriptionJSON));
}
@@ -331,7 +348,7 @@ export default class HelpNotificationsView extends Vue {
// Note that this exact verbiage shows in help text.
text: "You must enable notifications before testing the web push.",
},
-1,
5000,
);
return;
}
@@ -348,7 +365,7 @@ export default class HelpNotificationsView extends Vue {
"Check your device for the test web push message" +
(skipFilter ? "." : " if there are new items in your feed."),
},
-1,
5000,
);
} catch (error) {
console.error("Got an error sending test notification:", error);
@@ -359,14 +376,14 @@ export default class HelpNotificationsView extends Vue {
title: "Error Sending Test",
text: "Got an error sending the test web push notification.",
},
-1,
5000,
);
}
}
showTestNotification() {
const TEST_NOTIFICATION_TITLE = "It Worked";
navigator.serviceWorker.ready
navigator.serviceWorker?.ready
.then((registration) => {
return registration.showNotification(TEST_NOTIFICATION_TITLE, {
body: "This is your test notification.",
@@ -392,20 +409,25 @@ export default class HelpNotificationsView extends Vue {
title: "Failed",
text: "Got an error sending a notification.",
},
-1,
5000,
);
});
}
showNotificationChoice() {
this.$notify(
{
group: "modal",
type: "notification-permission",
title: "", // unused, only here to satisfy type check
text: "", // unused, only here to satisfy type check
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
this.notifyingReminder = true;
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
}
},
-1,
);
}
}

View File

@@ -12,46 +12,91 @@
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<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">Install</h1>
<h1 class="font-bold text-xl">Without a backup, you can lose data.</h1>
<div>
<p>
1) Have them visit TimeSafari.app in a browser, preferably Chrome or Safari.
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) Have them "Install" the site to their desktop.
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">Add Contact & Register</h1>
<h1 class="font-bold text-xl">Install</h1>
<div>
<p>
3) Have them follow their yellow prompts.
</p>
<p>
4) Add them to your contacts <fa icon="users" />
</p>
<p>
5) Register them <fa icon="person-circle-question" />
</p>
<p>
6) Add yourself to their contacts <fa icon="users" />
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>
7) Enable notifications from <fa icon="circle-user" />
</p>
</div>
<h1 class="font-bold text-xl">Discuss Backups</h1>
<div>
<p>
8) Exporting backups <fa icon="circle-user" /> are important if they lose their phone
--- especially for the Identifier Seed!
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, set an alarm or do some other ritual to record gratitude every day.
</p>
</div>

View File

@@ -27,6 +27,14 @@
This app focuses on gifts & gratitude, using them to build cool things together with your network.
</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>
<h2 class="text-xl font-semibold">What is the idea here?</h2>
<p>
We are building networks of people who want to grow good society from the ground up, using modern
@@ -375,11 +383,11 @@
How do I access even more functionality?
</h2>
<p>
There is an "Advanced" section at the bottom of the Account
There is an "Advanced" section at the bottom of the Profile
<fa icon="circle-user" /> page.
</p>
<p>
There is a even more functionality in a mobile app (and more
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
@@ -414,19 +422,19 @@
</p>
<h2 class="text-xl font-semibold">
My app is misbehaving, like showing me a blank screen or failing to show a feed.
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
What can I do?
</h2>
<p>
First, note that clearing the cache will clear all your identity and contact info,
so we recommend doing other things first (unless you know you have your backups ready).
so we recommend doing other things first -- and only clearing when have your backups ready.
</p>
<ul class="list-disc list-outside ml-4">
<li>
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the current version.
it sometimes takes multiple tries for the app to refresh to the latest 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
way to determine the latest version is to open this page in an incognito/private
browser window and look at the version there.
</li>
<li>
@@ -472,7 +480,7 @@
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center">
This work is public domain. If you like rules, reference
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">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
@@ -488,14 +496,32 @@
style="display: inline"
/>
</a>
.) This is offered freely, with the hope that it helps but without any warranty or guarantee;
if it helps you then enjoy using it,
but if you may try to forcibly collect damages for things you think it should do (or not do)
then don't use it.
<br />
For notifications, this service stores push token data; that can be revoked at any time
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br />
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
As for data & privacy:
<ul class="list-disc list-outside ml-4">
<li>
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <fa icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request.
</li>
<li>
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
</p>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
@@ -512,9 +538,11 @@
class="text-blue-500 ml-2"
>
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
<fa v-show="!showDidCopy" icon="copy" class="text-slate-400 fa-fw" />
<fa v-show="!showDidCopy" icon="copy" class="text-sm text-slate-400 fa-fw" />
<fa v-show="showDidCopy" icon="circle-check" class="text-sm text-green-500 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>
@@ -531,7 +559,7 @@
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
I have other questions, like getting a new account or removing all my data from the public ledger.
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
</h2>
<p>
Contact us at
@@ -546,11 +574,16 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import * as Package from "../../package.json";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import {
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
@Component({ components: { QuickNav } })
export default class Help extends Vue {
@@ -573,5 +606,15 @@ export default class Help extends Vue {
.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>

View File

@@ -8,6 +8,8 @@
{{ AppString.APP_NAME }}
</h1>
<OnboardingDialog ref="onboardingDialog" />
<!-- prompt to install notifications with notificationsSupported, which we're making an advanced feature -->
<div class="mb-8 mt-8">
<div
@@ -71,7 +73,8 @@
<div class="mb-8">
<div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
<fa icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</p>
</div>
@@ -110,16 +113,14 @@
<!-- !isCreatingIdentifier && isRegistered -->
<!-- show the actions for recognizing a give -->
<div class="flex justify-between">
<h2 class="text-xl font-bold">Record Something Given By:</h2>
<div class="flex justify-end">
<button
@click="openGiftedPrompts()"
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
</div>
<div class="flex">
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
<button
@click="openGiftedPrompts()"
class="ml-2 block text-xs 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 rounded-md"
>
<fa icon="lightbulb" class="fa-fw" />
</button>
</div>
<ul
@@ -128,16 +129,16 @@
<li @click="openDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
Unnamed/Unknown
</h3>
</li>
<li v-if="allContacts.length === 0" class="text-sm">
(Add contacts to see more people worthy of recognition.)
(Add friends to see more people worthy of recognition.)
</li>
<li
v-for="contact in allContacts.slice(0, 6)"
@@ -147,10 +148,10 @@
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
{{ contact.name || contact.did }}
</h3>
@@ -159,9 +160,9 @@
<router-link
v-if="allContacts.length >= 6"
:to="{ name: 'contact-gift' }"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
>
Choose From All Contacts
... or someone else...
</router-link>
</li>
</ul>
@@ -174,27 +175,79 @@
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
<div class="relative">
<button
v-if="isRegistered"
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="openDialog()"
>
<fa icon="plus" class="fa-fw" />
</button>
</div>
<!-- Results List -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4 mb-4">
<div class="bg-slate-100 rounded-md px-4 py-3 mt-4 mb-4">
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold">Latest Activity</h2>
<button @click="openFeedFilters()" class="block text-center ml-auto">
<span class="text-sm text-white">
<span
v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
>
Filtered
<h2 class="text-xl font-bold">
Latest Activity
<button @click="openFeedFilters()">
<span class="text-xs text-white">
<fa
v-if="resultsAreFiltered()"
icon="filter"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
<fa
v-else
icon="filter"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
</span>
<span
v-else
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
>
Unfiltered
</span>
</span>
</button>
</button>
</h2>
</div>
<div
@click="goToActivityToUserPage()"
class="border-t p-2 border-slate-300"
>
<div class="flex justify-center">
<div
v-if="numNewOffersToUser"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
>
<span
class="block text-center text-6xl"
data-testId="newDirectOffersActivityNumber"
>
{{ numNewOffersToUser }}{{ newOffersToUserHitLimit ? "+" : "" }}
</span>
<p class="text-center">
new offer{{ numNewOffersToUser === 1 ? "" : "s" }} to you
</p>
</div>
<div
v-if="numNewOffersToUserProjects"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
>
<span
class="block text-center text-6xl"
data-testId="newOffersToUserProjectsActivityNumber"
>
{{ numNewOffersToUserProjects
}}{{ newOffersToUserProjectsHitLimit ? "+" : "" }}
</span>
<p class="text-center">
new offer{{ numNewOffersToUserProjects === 1 ? "" : "s" }} to your
projects
</p>
</div>
</div>
<div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button>
</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul id="listLatestActivity" class="border-t border-slate-300">
<li
@@ -203,7 +256,7 @@
:key="record.jwtId"
>
<div
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
>
You've already seen all the following
@@ -268,7 +321,7 @@
<a @click="onClickLoadClaim(record.jwtId)">
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
class="pl-2 text-slate-500 cursor-pointer"
/>
</a>
</span>
@@ -282,11 +335,20 @@
>
<fa icon="hammer" class="text-blue-500" />
</router-link>
<router-link
v-if="record.providerPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.providerPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
<div v-if="record.image" class="flex justify-center">
<a :href="record.image" target="_blank">
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
<img :src="record.image" class="h-48 mt-2 rounded-xl" />
</a>
</div>
</li>
@@ -304,6 +366,8 @@
</div>
</div>
</section>
<ChoiceButtonDialog ref="choiceButtonDialog" />
</template>
<script lang="ts">
@@ -317,9 +381,11 @@ import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPrompts from "@/components/GiftedPrompts.vue";
import FeedFilters from "@/components/FeedFilters.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import OnboardingDialog from "@/components/OnboardingDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import UserNameDialog from "@/components/UserNameDialog.vue";
import ChoiceButtonDialog from "@/components/ChoiceButtonDialog.vue";
import {
AppString,
NotificationIface,
@@ -327,14 +393,14 @@ import {
} from "@/constants/app";
import {
db,
accountsDB,
updateAccountSettings,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
BoundingBox,
isAnyFeedFilterOn,
checkIsAnyFeedFilterOn,
MASTER_SETTINGS_KEY,
} from "@/db/tables/settings";
import {
@@ -343,12 +409,16 @@ import {
didInfoForContact,
fetchEndorserRateLimits,
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getPlanFromCache,
GiveSummaryRecord,
} from "@/libs/endorserServer";
import {
generateSaveAndActivateIdentity,
retrieveAccountDids,
GiverReceiverInputInfo,
OnboardPage,
registerSaveAndActivatePasskey,
} from "@/libs/util";
@@ -359,6 +429,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
profileImageUrl?: string;
};
image?: string;
providerPlanName?: string;
recipientProjectName?: string;
receiver: {
displayName: string;
@@ -374,12 +445,14 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
},
},
components: {
EntityIcon,
FeedFilters,
GiftedDialog,
GiftedPrompts,
FeedFilters,
QuickNav,
EntityIcon,
InfiniteScroll,
OnboardingDialog,
ChoiceButtonDialog,
QuickNav,
TopMessage,
UserNameDialog,
},
@@ -404,6 +477,12 @@ export default class HomeView extends Vue {
isFeedFilteredByNearby = false;
isFeedLoading = true;
isRegistered = false;
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
newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
@@ -413,15 +492,21 @@ export default class HomeView extends Vue {
async mounted() {
try {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
if (allAccounts.length > 0) {
this.allMyDids = allAccounts.map((acc) => acc.did);
} else {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
try {
this.allMyDids = await retrieveAccountDids();
if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
} catch (error) {
// continue because we want the feed to work, even anonymously
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
);
// some other piece will display an error about personal info
}
const settings = await retrieveSettingsForActiveAccount();
@@ -433,10 +518,19 @@ export default class HomeView extends Vue {
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isRegistered = !!settings.isRegistered;
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Home,
);
}
// someone may have have registered after sharing contact info, so recheck
if (!this.isRegistered && this.activeDid) {
@@ -460,9 +554,31 @@ export default class HomeView extends Vue {
// this returns a Promise but we don't need to wait for it
this.updateAllFeed();
if (this.activeDid) {
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
}
if (this.activeDid) {
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings or feed.", err);
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
this.$notify(
{
group: "alert",
@@ -472,7 +588,7 @@ export default class HomeView extends Vue {
err.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
-1,
5000,
);
}
}
@@ -499,7 +615,7 @@ export default class HomeView extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
this.feedData = [];
this.feedPreviousOldestId = undefined;
@@ -554,8 +670,8 @@ export default class HomeView extends Vue {
// This has indeed proven problematic. See loadMoreGives
// We should display it immediately and then get the plan later.
const plan = await getPlanFromCache(
record.fulfillsPlanHandleId || "",
const fulfillsPlan = await getPlanFromCache(
record.fulfillsPlanHandleId,
this.axios,
this.apiServer,
this.activeDid,
@@ -570,8 +686,13 @@ export default class HomeView extends Vue {
if (!anyMatch && this.isFeedFilteredByNearby) {
// check if the associated project has a location inside user's search box
if (record.fulfillsPlanHandleId) {
if (plan?.locLat && plan?.locLon) {
if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
if (
this.latLongInAnySearchBox(
fulfillsPlan.locLat,
fulfillsPlan.locLon,
)
) {
anyMatch = true;
}
}
@@ -581,6 +702,17 @@ export default class HomeView extends Vue {
continue;
}
// checking for arrays due to legacy data
const provider = Array.isArray(claim.provider)
? claim.provider[0]
: claim.provider;
const providedByPlan = await getPlanFromCache(
provider?.identifier as string,
this.axios,
this.apiServer,
this.activeDid,
);
const newRecord: GiveRecordWithContactInfo = {
...record,
giver: didInfoForContact(
@@ -590,7 +722,9 @@ export default class HomeView extends Vue {
this.allMyDids,
),
image: claim.image,
recipientProjectName: plan?.name as string,
providerPlanHandleId: provider?.identifier as string,
providerPlanName: providedByPlan?.name as string,
recipientProjectName: fulfillsPlan?.name as string,
receiver: didInfoForContact(
recipientDid,
this.activeDid,
@@ -641,13 +775,19 @@ export default class HomeView extends Vue {
*/
async retrieveGives(endorserApiServer: string, beforeId?: string) {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more
const headers = await getHeaders(
this.activeDid,
doNotShowErrorAgain ? undefined : this.$notify,
);
// retrieve headers for this user, but if an error happens then report it but proceed with the fetch with no header
const response = await fetch(
endorserApiServer +
"/api/v2/report/gives?giftNotTrade=true" +
beforeQuery,
{
method: "GET",
headers: await getHeaders(this.activeDid),
headers: headers,
},
);
@@ -684,50 +824,70 @@ export default class HomeView extends Vue {
}
/**
* Only show giver and/or receiver info first if they're named.
* Only show giver and/or receiver info first if they're named in your contacts.
* - If only giver is named, show "... gave"
* - If only receiver is named, show "... received"
*/
const giverInfo = giveRecord.giver;
const recipientInfo = giveRecord.receiver;
// any specific names should be shown first
if (giverInfo.known && recipientInfo.known) {
// both giver and recipient are named
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
} else if (giverInfo.known) {
// giver is named but recipient is not
// giver is known but recipient is not
// show the project name if to one
if (giveRecord.recipientProjectName) {
// retrieve the project name
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project "${giveRecord.recipientProjectName}")`;
} else {
// it's not to a project
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
}
// it's not to a project
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
} else if (recipientInfo.known) {
// recipient is named but giver is not
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
// recipient is known but giver is not
// show the project name if from one
if (giveRecord.providerPlanName) {
return `${recipientInfo.displayName} received: ${gaveAmount} (from the project "${giveRecord.providerPlanName}")`;
} else {
// it's not from a project
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
}
} else {
// neither giver nor recipient are named
// show the project name if to one
if (giveRecord.recipientProjectName) {
// retrieve the project name
return `${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
// create the part in parens
let peopleInfo = "";
if (giveRecord.providerPlanName || giveRecord.recipientProjectName) {
if (giveRecord.providerPlanName) {
peopleInfo = `from the project "${giveRecord.providerPlanName}"`;
} else {
peopleInfo = `from ${giverInfo.displayName}`;
}
if (giveRecord.recipientProjectName) {
peopleInfo += ` to the project "${giveRecord.recipientProjectName}"`;
} else {
peopleInfo += ` to ${recipientInfo.displayName}`;
}
} else {
if (giverInfo.displayName === recipientInfo.displayName) {
peopleInfo = `between two who are ${giverInfo.displayName}`;
} else {
peopleInfo = `from ${giverInfo.displayName} to ${recipientInfo.displayName}`;
}
}
// it's not to a project
let peopleInfo;
if (giverInfo.displayName === recipientInfo.displayName) {
peopleInfo = `between two who are ${giverInfo.displayName}`;
} else {
peopleInfo = `from ${giverInfo.displayName} to ${recipientInfo.displayName}`;
}
return gaveAmount + " (" + peopleInfo + ")";
}
}
goToActivityToUserPage() {
(this.$router as Router).push({ name: "new-activity" });
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
@@ -766,7 +926,7 @@ export default class HomeView extends Vue {
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
}
toastUser(message) {
toastUser(message: string) {
this.$notify(
{
group: "alert",
@@ -793,24 +953,22 @@ export default class HomeView extends Vue {
}
promptForShareMethod() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you nearby with cameras?",
text: "If so, we'll use those with QR codes to share.",
onCancel: async () => {},
onNo: async () => {
(this.$router as Router).push({ name: "share-my-contact-info" });
},
onYes: async () => {
(this.$router as Router).push({ name: "contact-qr" });
},
noText: "we will share another way",
yesText: "we are nearby with cameras",
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are in a meeting together",
option2Text: "We are nearby with cameras",
option3Text: "We will share some other way",
onOption1: () => {
(this.$router as Router).push({ name: "onboard-meeting-list" });
},
-1,
);
onOption2: () => {
(this.$router as Router).push({ name: "contact-qr" });
},
onOption3: () => {
(this.$router as Router).push({ name: "share-my-contact-info" });
},
});
}
}
</script>

View File

@@ -101,10 +101,15 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import { db, accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { retrieveAllAccountsMetadata } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
@@ -123,8 +128,7 @@ export default class IdentitySwitcherView extends Vue {
this.apiServer = settings.apiServer || "";
this.apiServerInput = settings.apiServer || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const accounts = await retrieveAllAccountsMetadata();
for (let n = 0; n < accounts.length; n++) {
const acct = accounts[n];
this.otherIdentities.push({ id: acct.id as string, did: acct.did });
@@ -140,7 +144,7 @@ export default class IdentitySwitcherView extends Vue {
title: "Error Loading Accounts",
text: "Clear your cache and start over (after data backup).",
},
-1,
5000,
);
console.error("Telling user to clear cache at page create because:", err);
}
@@ -166,7 +170,8 @@ export default class IdentitySwitcherView extends Vue {
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();
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
@@ -183,7 +188,7 @@ export default class IdentitySwitcherView extends Vue {
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity.",
text: "You cannot delete the active identity. Set to another identity or 'no identity' first.",
},
3000,
);

View File

@@ -53,6 +53,13 @@
<input type="checkbox" class="mr-2" v-model="shouldErase" />
<label>Erase the previous identifier.</label>
</div>
<div v-if="isNotProdServer()" class="mt-4 text-blue-500">
<!-- if they click this, fill in the mnemonic seed-input with the test mnemonic -->
<button @click="mnemonic = TEST_USER_0_MNEMONIC">
Use mnemonic for Test User #0
</button>
</div>
</div>
<div class="mt-8">
@@ -79,41 +86,57 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { AppString, NotificationIface } from "@/constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "@/libs/crypto";
import { retrieveAccountCount } from "@/libs/util";
@Component({
components: {},
})
export default class ImportAccountView extends Vue {
TEST_USER_0_MNEMONIC =
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
AppString = AppString;
$notify!: (notification: NotificationIface, timeout?: number) => void;
mnemonic = "";
apiServer = "";
address = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
mnemonic = "";
numAccounts = 0;
privateHex = "";
publicHex = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
showAdvanced = false;
shouldErase = false;
async created() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
this.numAccounts = await retrieveAccountCount();
// get the server, to help with import on the test server
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
}
public onCancelClick() {
(this.$router as Router).back();
}
public isNotProdServer() {
return this.apiServer !== AppString.PROD_ENDORSER_API_SERVER;
}
public async fromMnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
try {
@@ -129,7 +152,7 @@ export default class ImportAccountView extends Vue {
this.derivationPath,
);
await accountsDB.open();
const accountsDB = await accountsDBPromise;
if (this.shouldErase) {
await accountsDB.accounts.clear();
}
@@ -159,7 +182,7 @@ export default class ImportAccountView extends Vue {
title: "Invalid Mnemonic",
text: "Please check your mnemonic and try again.",
},
-1,
5000,
);
} else {
this.$notify(
@@ -169,7 +192,7 @@ export default class ImportAccountView extends Vue {
title: "Error",
text: "Got an error creating that identifier.",
},
-1,
5000,
);
}
}

View File

@@ -33,7 +33,7 @@
<fa
v-if="dids[0] == selectedArrayFirstDid"
icon="circle"
class="fa-fw text-blue-400 text-xl mr-3"
class="fa-fw text-blue-500 text-xl mr-3"
></fa>
<fa
v-else
@@ -78,8 +78,9 @@ import {
newIdentifier,
nextDerivationPath,
} from "@/libs/crypto";
import { accountsDB, db } from "@/db/index";
import { accountsDBPromise, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { retrieveAllFullyDecryptedAccounts } from "@/libs/util";
@Component({
components: {},
@@ -90,8 +91,7 @@ export default class ImportAccountView extends Vue {
selectedArrayFirstDid = "";
async mounted() {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const accounts = await retrieveAllFullyDecryptedAccounts(); // let's match derived accounts differently so we don't need the private info
const seedDids: Record<string, Array<string>> = {};
accounts.forEach((account) => {
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
@@ -110,11 +110,11 @@ export default class ImportAccountView extends Vue {
}
public async incrementDerivation() {
await accountsDB.open();
// find the maximum derivation path for the selected DIDs
const selectedArray: Array<string> =
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
[];
const accountsDB = await accountsDBPromise; // let's match derived accounts differently so we don't need the private info
const allMatchingAccounts = await accountsDB.accounts
.where("did")
.anyOf(...selectedArray)

View File

@@ -0,0 +1,169 @@
<template>
<QuickNav selected="Invite" />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else class="text-center mt-4">
<p>That invitation did not work.</p>
<p class="mt-2">
Go back to your invite message and copy the entire text, then paste it
here.
</p>
<p class="mt-2">
If the data looks correct, try Chrome. (For example, iOS may have cut
off the invite data, or it may have shown a preview that stole your
invite.) If it still complains, you may need the person who invited you
to send a new one.
</p>
<textarea
v-model="inputJwt"
placeholder="Paste invitation..."
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkInvite(inputJwt)"
/>
<br />
<button
@click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Accept
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import { errorStringForLog } from "@/libs/endorserServer";
import { generateSaveAndActivateIdentity } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class InviteOneAcceptView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid: string = "";
apiServer: string = "";
checkingInvite: boolean = true;
inputJwt: string = "";
async mounted() {
this.checkingInvite = true;
await db.open();
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
}
const jwt = window.location.pathname.substring(
"/invite-one-accept/".length,
);
await this.processInvite(jwt, false);
this.checkingInvite = false;
}
// process the invite JWT and/or text message containing the URL with the JWT
async processInvite(jwtInput: string, notifyOnFailure: boolean) {
this.checkingInvite = true;
try {
let jwt: string = jwtInput ?? "";
// parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch && internalMatch[1]) {
jwt = internalMatch[1];
}
} else {
// extract the JWT (which starts with "ey") if it is surrounded by other input
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch && spaceMatch[1]) {
jwt = spaceMatch[1];
}
}
if (!jwt) {
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
// That's good enough for an initial check.
// Send them to the contacts page to finish, with inviteJwt in the query string.
(this.$router as Router).push({
name: "contacts",
query: { inviteJwt: jwt },
});
}
} catch (error) {
const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
);
}
}
this.checkingInvite = false;
}
// check the invite JWT
async checkInvite(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("invite-one-accept") ||
jwtInput.endsWith("invite-one-accept/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the invite data; it's missing some at the end. Try another way to get the full data.",
},
5000,
);
}
}
}
</script>

399
src/views/InviteOneView.vue Normal file
View File

@@ -0,0 +1,399 @@
<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.redeemedAt ? "" : 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, invite.notes)"
/>
</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, AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
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: { [key: string]: Contact } = {};
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: Contact[] = await db.contacts.toArray();
for (const invite of this.invites) {
const contact = baseContacts.find(
(contact) => contact.did === invite.redeemedBy,
);
if (contact && invite.redeemedBy) {
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 || AppString.NO_CONTACT_NAME
);
}
if (redeemedBy.length <= 19) return redeemedBy;
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
}
inviteLink(jwt: string): string {
return APP_SERVER + "/invite-one-accept/" + 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,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lookForErrorAndNotify(error: any, 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 },
);
const newInvite = {
inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt,
jwt: inviteJwt,
notes: notes,
redeemedAt: null,
redeemedBy: null,
};
this.invites = [newInvite, ...this.invites];
// 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: string, notes: string) {
(this.$refs.contactNameDialog as ContactNameDialog).open(
"To Whom Did You Send 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,
);
},
() => {},
notes,
);
}
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>

View File

@@ -0,0 +1,330 @@
<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 or when the offer is clicked -->
<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 {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
didInfo,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
OfferSummaryRecord,
OfferToPlanSummaryRecord,
} from "@/libs/endorserServer";
import { retrieveAccountDids } from "@/libs/util";
@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();
this.allMyDids = await retrieveAccountDids();
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 line 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 now 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 line are marked as unread.",
},
3000,
);
}
}
</script>

View File

@@ -6,12 +6,14 @@
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<router-link
:to="{ name: 'project' }"
<!-- 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
></router-link>
Edit Idea
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Edit Project Idea
</h1>
</div>
@@ -75,7 +77,9 @@
maxlength="5000"
></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.
If you want to be contacted, be sure to include your contact information
-- just remember that this information is public and saved in a public
history.
</div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description?.length }}/5000 max. characters
@@ -105,13 +109,11 @@
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
</div>
<div class="flex items-center mb-4">
<input
type="checkbox"
class="mr-2"
v-model="includeLocation"
@click="includeLocation = !includeLocation"
/>
<div
class="flex items-center mb-4"
@click="includeLocation = !includeLocation"
>
<input type="checkbox" class="mr-2" v-model="includeLocation" />
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" class="mb-4 aspect-video">
@@ -146,27 +148,25 @@
</div>
<div
v-if="showGeneralAdvanced && includeLocation && false"
v-if="showGeneralAdvanced && includeLocation"
class="items-center mb-4"
>
<div class="flex">
<input
type="checkbox"
class="mr-2"
v-model="sendToTrustroots"
@click="sendToTrustroots = !sendToTrustroots"
/>
<div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<label>Send to Trustroots</label>
</div>
<div class="flex">
<input
type="checkbox"
class="mr-2"
v-model="sendToTripHopping"
@click="sendToTripHopping = !sendToTripHopping"
<fa
icon="circle-info"
class="text-blue-500 ml-2 cursor-pointer"
@click.stop="showNostrPartnerInfo"
/>
<label>Send to TripHopping</label>
</div>
<!--
<div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label>
<fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" />
</div>
-->
</div>
<div class="mt-8">
@@ -202,10 +202,21 @@
import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { accountFromSeedWords } from "nostr-tools/nip06";
import { hexToBytes } from "@noble/hashes/utils";
// these core imports could also be included as "import type ..."
import {
EventTemplate,
UnsignedEvent,
VerifiedEvent,
} from "nostr-tools/lib/types/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/nip06";
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
@@ -214,20 +225,23 @@ import {
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
} from "@/constants/app";
import { accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import {
createEndorserJwtVcFromClaim,
getHeaders,
PlanVerifiableCredential,
} from "@/libs/endorserServer";
import { getAccount } from "@/libs/util";
import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "@/libs/util";
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
errNote(message: string) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
@@ -263,15 +277,15 @@ export default class NewEditProjectView extends Vue {
zoom = 2;
async mounted() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
this.numAccounts = await retrieveAccountCount();
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route as Router).query["projectId"] || "";
this.projectId =
(this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
if (this.projectId) {
if (this.numAccounts === 0) {
@@ -409,13 +423,26 @@ export default class NewEditProjectView extends Vue {
delete vcClaim.image;
}
if (this.includeLocation) {
vcClaim.location = {
geo: {
"@type": "GeoCoordinates",
latitude: this.latitude,
longitude: this.longitude,
},
};
if (!this.latitude || !this.longitude) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Location Error",
text: "The location was invalid so it was not set.",
},
5000,
);
delete vcClaim.location;
} else {
vcClaim.location = {
geo: {
"@type": "GeoCoordinates",
latitude: this.latitude,
longitude: this.longitude,
},
};
}
} else {
delete vcClaim.location;
}
@@ -432,7 +459,7 @@ export default class NewEditProjectView extends Vue {
{
group: "alert",
type: "danger",
title: "Error",
title: "Date Error",
text: "The date was invalid so it was not set.",
},
5000,
@@ -452,23 +479,58 @@ export default class NewEditProjectView extends Vue {
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
this.$notify(
{
group: "alert",
type: "success",
title: "Saved",
text: "The project was saved successfully.",
},
3000,
);
this.errorMessage = "";
const projectPath = encodeURIComponent(resp.data.success.handleId);
if (this.sendToTrustroots) {
this.sendToNostrPartner(
"NOSTR-EVENT-TRUSTROOTS",
"Trustroots",
resp.data.success.claimId,
);
}
if (this.sendToTripHopping) {
this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
);
if (this.sendToTrustroots || this.sendToTripHopping) {
if (this.latitude && this.longitude) {
let payloadAndKey; // sign something to prove ownership of pubkey
if (this.sendToTrustroots) {
payloadAndKey = await this.signSomePayload();
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRUSTROOTS",
"Trustroots",
resp.data.success.claimId,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
if (this.sendToTripHopping) {
if (!payloadAndKey) {
payloadAndKey = await this.signSomePayload();
}
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Partner Error",
text: "A partner was selected but the location was not set, so it was not sent to any partner.",
},
5000,
);
}
}
(this.$router as Router).push({ path: "/project/" + projectPath });
@@ -484,7 +546,7 @@ export default class NewEditProjectView extends Vue {
title: "Error Saving Idea",
text: "Server did not save the idea. Try again.",
},
-1,
5000,
);
}
} catch (error) {
@@ -505,7 +567,7 @@ export default class NewEditProjectView extends Vue {
title: "User Message",
text: userMessage,
},
-1,
5000,
);
} else {
this.$notify(
@@ -515,7 +577,7 @@ export default class NewEditProjectView extends Vue {
title: "Server Message",
text: JSON.stringify(serverError.toJSON()),
},
-1,
5000,
);
}
} else {
@@ -527,7 +589,7 @@ export default class NewEditProjectView extends Vue {
title: "Claim Error",
text: error as string,
},
-1,
5000,
);
}
// Now set that error for the user to see.
@@ -535,40 +597,84 @@ export default class NewEditProjectView extends Vue {
}
}
private async sendToNostrPartner(
linkCode: string,
serviceName: string,
jwtId: string,
) {
// first, get the public key for nostr
const account = await getAccount(this.activeDid);
/**
* @return a signed payload and an extended public key for later transmission
*/
private async signSomePayload(): Promise<{
signedEvent: VerifiedEvent;
publicExtendedKey: string;
}> {
const account = await retrieveFullyDecryptedAccount(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(
const extPubPri = extendedKeysFromSeedWords(
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;
const trustrootsParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
nostrPubKeyHex: nostrPubKey,
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
const privateExtendedKey = extPubPri?.privateExtendedKey;
const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey;
const privateBytes = hexToBytes(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,
};
const fullTrustrootsUrl = trustrootsUrl;
const headers = await getHeaders(this.activeDid);
const signedEvent: VerifiedEvent = finalizeEvent(
// Why does IntelliJ not see matching types?
event as EventTemplate,
privateBytes,
) as VerifiedEvent;
return { signedEvent, publicExtendedKey };
}
private async sendToNostrPartner(
linkCode: string,
serviceName: string,
jwtId: string,
signedPayload: VerifiedEvent,
publicExtendedKey: string,
) {
try {
let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await retrieveSettingsForActiveAccount();
if (settings.partnerApiServer) {
partnerServer = settings.partnerApiServer;
}
const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey;
const unsignedPayload: UnsignedEvent = {
// why doesn't "...signedPayload" work?
kind: signedPayload.kind,
tags: signedPayload.tags,
content: signedPayload.content,
created_at: signedPayload.created_at,
pubkey: publicKeyHex,
};
// Why does IntelliJ not see matching types?
const payload = serializeEvent(unsignedPayload as UnsignedEvent);
const partnerParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: publicKeyHex,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const headers = await getHeaders(this.activeDid);
const linkResp = await this.axios.post(
fullTrustrootsUrl,
trustrootsParams,
endorserPartnerUrl,
partnerParams,
{ headers },
);
if (linkResp.status === 201) {
@@ -593,7 +699,8 @@ export default class NewEditProjectView extends Vue {
5000,
);
}
} catch (error) {
// 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) {
@@ -606,7 +713,7 @@ export default class NewEditProjectView extends Vue {
title: `Error Sending to ${serviceName}`,
text: errorMessage,
},
5000,
7000,
);
}
}
@@ -646,5 +753,17 @@ export default class NewEditProjectView extends Vue {
public onCancelClick() {
(this.$router as Router).back();
}
public showNostrPartnerInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "About Nostr Events",
text: "This will submit this project to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.",
},
7000,
);
}
}
</script>

View File

@@ -28,7 +28,7 @@
? projectName
: offeredToRecipient
? recipientName
: "someone unidentified"
: "someone not named"
}}</span
>
</h1>
@@ -181,7 +181,7 @@ import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import {
createAndSubmitOffer,
didInfo,
@@ -192,7 +192,7 @@ import {
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: {
@@ -242,7 +242,7 @@ export default class OfferDetailsView extends Vue {
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
5000,
);
}
@@ -301,14 +301,9 @@ export default class OfferDetailsView extends Vue {
this.activeDid = settings.activeDid ?? "";
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (this.recipientDid && !this.recipientName) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids();
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
@@ -330,7 +325,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
5000,
);
}
@@ -535,7 +530,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: errorMessage || "There was an error creating the offer.",
},
-1,
5000,
);
} else {
this.$notify(
@@ -568,7 +563,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: errorMessage,
},
-1,
5000,
);
}
}
@@ -626,7 +621,7 @@ export default class OfferDetailsView extends Vue {
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
7000,
);
}
}

View File

@@ -0,0 +1,343 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Onboarding Meetings
</h1>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="attendingMeeting">
<p>You are in this meeting.</p>
<div
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
@click="promptPassword(attendingMeeting)"
>
<div class="flex justify-between items-center">
<h2 class="text-xl font-medium">{{ attendingMeeting.name }}</h2>
<button
@click.stop="leaveMeeting"
class="text-red-600 hover:text-red-700 p-2"
title="Leave Meeting"
>
<fa icon="right-from-bracket" />
</button>
</div>
</div>
</div>
<!-- Meeting List -->
<div v-else class="space-y-4">
<div
v-for="meeting in meetings"
:key="meeting.groupId"
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
@click="promptPassword(meeting)"
>
<h2 class="text-xl font-medium">{{ meeting.name }}</h2>
</div>
<p v-if="meetings.length === 0" class="text-center text-gray-500 py-8">
No onboarding meetings available
</p>
</div>
<!-- Password Dialog -->
<div
v-if="showPasswordDialog"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 class="text-lg font-medium mb-4">Enter Meeting Password</h3>
<input
ref="passwordInput"
v-model="password"
type="text"
class="w-full px-3 py-2 border rounded-md mb-4"
placeholder="Enter password"
@keyup.enter="submitPassword"
/>
<div class="flex justify-end space-x-4">
<button
@click="cancelPasswordDialog"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
@click="submitPassword"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Submit
</button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface Meeting {
name: string;
groupId: number;
}
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OnboardMeetingListView extends Vue {
$notify!: (
notification: {
group: string;
type: string;
title: string;
text: string;
onYes?: () => void;
yesText?: string;
},
timeout?: number,
) => void;
activeDid = "";
apiServer = "";
attendingMeeting: Meeting | null = null;
firstName = "";
isLoading = false;
isRegistered = false;
meetings: Meeting[] = [];
password = "";
selectedMeeting: Meeting | null = null;
showPasswordDialog = false;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
await this.fetchMeetings();
}
async fetchMeetings() {
this.isLoading = true;
try {
// get the meeting that the user is attending
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
if (response.data?.data) {
// they're in a meeting already
const attendingMeetingId = response.data.data.groupId;
// retrieve the meeting details
const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard/" + attendingMeetingId,
{ headers: headers2 },
);
if (response2.data?.data) {
this.attendingMeeting = response2.data.data;
return;
} else {
// this should never happen
logConsoleAndDb(
"Error fetching meeting for user after saying they are in one.",
true,
);
}
}
const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get(
this.apiServer + "/api/partner/groupsOnboarding",
{ headers: headers2 },
);
if (response2.data?.data) {
this.meetings = response2.data.data;
}
} catch (error) {
logConsoleAndDb(
"Error fetching meetings: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: serverMessageForUser(error) || "Failed to fetch meetings.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
promptPassword(meeting: Meeting) {
this.password = "";
this.selectedMeeting = meeting;
this.showPasswordDialog = true;
nextTick(() => {
const input = this.$refs.passwordInput as HTMLInputElement;
if (input) {
input.focus();
}
});
}
cancelPasswordDialog() {
this.password = "";
this.selectedMeeting = null;
this.showPasswordDialog = false;
}
async submitPassword() {
if (!this.selectedMeeting) {
// this should never happen
logConsoleAndDb(
"No meeting selected when prompting for password, which should never happen.",
true,
);
return;
}
try {
// Create member data object
const memberData = {
name: this.firstName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
// Get headers for authentication
const headers = await getHeaders(this.activeDid);
// Encrypt the member data
const postResult = await this.axios.post(
this.apiServer + "/api/partner/groupOnboardMember",
{
groupId: this.selectedMeeting.groupId,
content: encryptedMemberData,
},
{ headers },
);
if (postResult.data && postResult.data.success) {
// Navigate to members view with password and groupId
(this.$router as Router).push({
name: "onboard-meeting-members",
params: {
groupId: this.selectedMeeting.groupId.toString(),
},
query: {
password: this.password,
memberId: postResult.data.memberId,
},
});
this.cancelPasswordDialog();
} else {
throw { response: postResult };
}
} catch (error) {
logConsoleAndDb(
"Error joining meeting: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
serverMessageForUser(error) || "You failed to join the meeting.",
},
5000,
);
}
}
async leaveMeeting() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Leave Meeting",
text: "Are you sure you want to leave this meeting?",
onYes: async () => {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.delete(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
this.attendingMeeting = null;
await this.fetchMeetings();
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "You left the meeting.",
},
5000,
);
} catch (error) {
logConsoleAndDb(
"Error leaving meeting: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
serverMessageForUser(error) ||
"You failed to leave the meeting.",
},
5000,
);
}
},
},
-1,
);
}
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Meeting Members
</h1>
<!-- Error State -->
<div v-if="errorMessage">
<div class="text-center text-red-600 py-8">
{{ errorMessage }}
</div>
<div class="text-center">
For authorization, wait for your meeting organizer to approve you.
</div>
</div>
<!-- Members List -->
<MembersList
v-else
:password="password"
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
@error="handleError"
/>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
@Component({
components: {
QuickNav,
TopMessage,
MembersList,
},
})
export default class OnboardMeetingMembersView extends Vue {
errorMessage = "";
get groupId(): string {
return (this.$route as RouteLocation).params.groupId as string;
}
get password(): string {
return (this.$route as RouteLocation).query.password as string;
}
async created() {
if (!this.groupId) {
this.errorMessage = "The group info is missing. Go back and try again.";
return;
}
if (!this.password) {
this.errorMessage = "The password is missing. Go back and try again.";
return;
}
}
handleError(message: string) {
this.errorMessage = message;
}
}
</script>

View File

@@ -0,0 +1,676 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Onboarding Meeting
</h1>
<!-- Existing Meeting Section -->
<div
v-if="!isLoading && currentMeeting != null && !isInEditOrCreateMode()"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<h2 class="text-2xl">Current Meeting</h2>
<button
@click="startEditing"
class="mb-4 text-blue-600 hover:text-blue-800 transition-colors duration-200 ml-2"
title="Edit Meeting"
>
<fa icon="pen" class="fa-fw" />
<span class="sr-only">{{
isInCreateMode() ? "Create Meeting" : "Edit Meeting"
}}</span>
</button>
</div>
<button
@click="confirmDelete"
class="text-red-600 hover:text-red-800 transition-colors duration-200"
:disabled="isDeleting"
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }"
title="Delete Meeting"
>
<fa icon="trash-can" class="fa-fw" />
<span class="sr-only">{{
isDeleting ? "Deleting..." : "Delete Meeting"
}}</span>
</button>
</div>
<div class="space-y-2">
<p><strong>Name:</strong> {{ currentMeeting.name }}</p>
<p>
<strong>Expires:</strong>
{{ formatExpirationTime(currentMeeting.expiresAt) }}
</p>
<div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">
Share the password with the people you want to onboard.
</p>
</div>
<div v-else class="text-red-600">
Your copy of the password is not saved. Edit the meeting, or delete it
and create a new meeting.
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div
v-if="showDeleteConfirm"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 class="text-lg font-medium mb-4">Delete Meeting?</h3>
<p class="text-gray-600 mb-6">
This action cannot be undone. Are you sure you want to delete this
meeting?
</p>
<div class="flex justify-between space-x-4">
<button
@click="showDeleteConfirm = false"
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
>
Cancel
</button>
<button
@click="deleteMeeting"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
<!-- Create/Edit Meeting Form -->
<div
v-if="
!isLoading &&
isInEditOrCreateMode() &&
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
"
class="mt-8"
>
<h2 class="text-2xl mb-4">
{{ isInCreateMode() ? "Create New Meeting" : "Edit Meeting" }}
</h2>
<!-- This is my first form. Not sure if I like it; will see if the browser benefits extend to the native app. -->
<form
@submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()"
class="space-y-4"
>
<div>
<label
for="meetingName"
class="block text-sm font-medium text-gray-700"
>Meeting Name</label
>
<input
id="meetingName"
v-model="newOrUpdatedMeeting.name"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Enter meeting name"
/>
</div>
<div>
<label
for="expirationTime"
class="block text-sm font-medium text-gray-700"
>Meeting Expiration Time</label
>
<input
id="expirationTime"
v-model="newOrUpdatedMeeting.expiresAt"
type="datetime-local"
required
:min="minDateTime"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700"
>Meeting Password</label
>
<input
id="password"
v-model="newOrUpdatedMeeting.password"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Enter meeting password"
/>
</div>
<div>
<label for="userName" class="block text-sm font-medium text-gray-700"
>Your Name</label
>
<input
id="userName"
v-model="newOrUpdatedMeeting.userFullName"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Your name"
/>
</div>
<button
type="submit"
class="w-full 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-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
:disabled="isLoading"
>
{{
isLoading
? isInCreateMode()
? "Creating..."
: "Updating..."
: isInCreateMode()
? "Create Meeting"
: "Update Meeting"
}}
</button>
<button
v-if="isInEditOrCreateMode()"
type="button"
@click="cancelEditing"
class="w-full bg-slate-500 text-white px-4 py-2 rounded-md hover:bg-slate-600"
>
Cancel
</button>
</form>
</div>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<router-link
:to="onboardMeetingMembersLink()"
class="inline-block text-blue-600"
target="_blank"
>
Open shortcut page for members <fa icon="external-link" />
</router-link>
<MembersList
:password="currentMeeting.password || ''"
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
:show-organizer-tools="true"
@error="handleMembersError"
class="mt-4"
/>
</div>
<div v-else-if="isLoading">
<div class="flex justify-center items-center h-full">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface ServerMeeting {
groupId: number; // from the server
name: string; // from the server
expiresAt: string; // from the server
userFullName?: string; // from the user's session
password?: string; // from the user's session
}
interface MeetingSetupInfo {
name: string;
expiresAt: string;
userFullName: string;
password: string;
}
@Component({
components: {
QuickNav,
TopMessage,
MembersList,
},
})
export default class OnboardMeetingView extends Vue {
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
DECRYPT_FAILURE_MESSAGE =
"Unable to decrypt some member information. Check your password, or have them reset theirs if they don't show here.";
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = "";
apiServer = "";
isDeleting = false;
isLoading = true;
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
return this.formatDateForInput(now);
}
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.fullName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
await this.fetchCurrentMeeting();
this.isLoading = false;
}
isInCreateMode(): boolean {
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
}
isInEditOrCreateMode(): boolean {
return this.newOrUpdatedMeeting != null;
}
getDefaultExpirationTime(): string {
const date = new Date();
// Round up to the next hour
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setHours(date.getHours() + 1); // Round up to next hour
date.setHours(date.getHours() + 2); // Add 2 more hours
return this.formatDateForInput(date);
}
// Format a date object to YYYY-MM-DDTHH:mm format for datetime-local input
private formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
blankMeeting(): MeetingSetupInfo {
return {
// no groupId yet
name: "",
expiresAt: this.getDefaultExpirationTime(),
userFullName: this.fullName,
password: (this.currentMeeting?.password as string) || "",
};
}
async fetchCurrentMeeting() {
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers },
);
if (response?.data?.data) {
this.currentMeeting = {
...response.data.data,
userFullName: this.fullName,
password: this.currentMeeting?.password || "",
};
} else {
// no meeting found
this.newOrUpdatedMeeting = this.blankMeeting();
}
} catch (error) {
// no meeting found
this.newOrUpdatedMeeting = this.blankMeeting();
}
}
async createMeeting() {
this.isLoading = true;
try {
if (!this.newOrUpdatedMeeting) {
throw Error(
"There was no meeting data to create. We should never get here.",
);
}
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Time",
text: "Select a future time for the meeting expiration.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Name",
text: "Please enter your name.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Password",
text: "Please enter a password.",
},
5000,
);
return;
}
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.post(
this.apiServer + "/api/partner/groupOnboard",
{
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
},
{ headers },
);
if (response.data && response.data.success) {
this.currentMeeting = {
...this.newOrUpdatedMeeting,
groupId: response.data.success.groupId,
};
this.newOrUpdatedMeeting = null;
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Meeting created.",
},
3000,
);
} else {
throw { response: response };
}
} catch (error) {
logConsoleAndDb(
"Error creating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
errorMessage ||
"Failed to create meeting. Try reloading or submitting again.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
formatExpirationTime(expiresAt: string): string {
const expiration = new Date(expiresAt); // Server time is in UTC
const now = new Date();
const diffHours = Math.round(
(expiration.getTime() - now.getTime()) / (1000 * 60 * 60),
);
if (diffHours < 0) {
return "Expired";
} else if (diffHours < 1) {
return "Less than an hour";
} else if (diffHours === 1) {
return "1 hour";
} else {
return `${diffHours} hours`;
}
}
confirmDelete() {
this.showDeleteConfirm = true;
}
async deleteMeeting() {
this.isDeleting = true;
try {
const headers = await getHeaders(this.activeDid);
await this.axios.delete(this.apiServer + "/api/partner/groupOnboard", {
headers,
});
this.currentMeeting = null;
this.newOrUpdatedMeeting = this.blankMeeting();
this.showDeleteConfirm = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Meeting deleted successfully.",
},
3000,
);
} catch (error) {
console.error("Error deleting meeting:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: serverMessageForUser(error) || "Failed to delete meeting.",
},
5000,
);
} finally {
this.isDeleting = false;
}
}
startEditing() {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
this.newOrUpdatedMeeting = {
name: this.currentMeeting.name,
expiresAt: this.formatDateForInput(localExpiresAt),
userFullName: this.currentMeeting.userFullName || "",
password: this.currentMeeting.password || "",
};
} else {
console.error(
"There is no current meeting to edit. We should never get here.",
);
}
}
cancelEditing() {
// Reset form data
this.newOrUpdatedMeeting = null;
}
async updateMeeting() {
this.isLoading = true;
if (!this.newOrUpdatedMeeting) {
throw Error("There was no meeting data to update.");
}
try {
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Time",
text: "Select a future time for the meeting expiration.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Name",
text: "Please enter your name.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Password",
text: "Please enter a password.",
},
5000,
);
return;
}
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.put(
this.apiServer + "/api/partner/groupOnboard",
{
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
},
{ headers },
);
if (response.data && response.data.success) {
// Update the current meeting with only the necessary fields
this.currentMeeting = {
...this.newOrUpdatedMeeting,
groupId: (this.currentMeeting?.groupId as number) || -1,
};
this.newOrUpdatedMeeting = null;
} else {
throw { response: response };
}
} catch (error) {
logConsoleAndDb(
"Error updating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
errorMessage ||
"Failed to update meeting. Try reloading or submitting again.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
onboardMeetingMembersLink(): string {
if (this.currentMeeting) {
return `/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "",
)}`;
}
return "";
}
handleMembersError(message: string) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
5000,
);
}
}
</script>

View File

@@ -6,17 +6,29 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb">
<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>
Idea
<h2 class="text-xl font-semibold">{{ name }}</h2>
</h1>
<div>
<h1 class="text-center text-lg 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>
Project Idea
</h1>
<h2 class="text-center text-xl font-semibold">
{{ name }}
<button
v-if="activeDid === issuer || activeDid === agentDid"
@click="onEditClick()"
title="Edit"
data-testId="editClaimButton"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2>
</div>
</div>
<!-- Project Details -->
@@ -37,22 +49,22 @@
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
}}
{{ issuerInfoObject?.displayName }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
issuer,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
<a
:href="`/did/${issuer}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
<span v-else-if="serverUtil.isHiddenDid(issuer)">
<fa
icon="info-circle"
class="fa-fw text-blue-500 cursor-pointer"
@click="openHiddenDidDialog()"
/>
</span>
</div>
<div v-if="startTime">
@@ -64,14 +76,21 @@
<a
:href="getOpenStreetMapUrl()"
target="_blank"
class="underline"
class="underline text-blue-500"
>Map View
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<fa
icon="arrow-up-right-from-square"
class="fa-fw text-blue-500"
/>
</a>
</div>
<div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa>
<a :href="addScheme(url)" target="_blank" class="underline">
<a
:href="addScheme(url)"
target="_blank"
class="underline text-blue-500"
>
{{ domainForWebsite(this.url) }}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
@@ -104,15 +123,6 @@
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</div>
<button
v-if="activeDid === issuer || activeDid === agentDid"
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onEditClick()"
>
Edit
</button>
</div>
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
@@ -159,86 +169,83 @@
</div>
</div>
<div v-if="activeDid && isRegistered" class="mt-4">
<div class="text-center">
<button
data-testId="offerButton"
@click="openOfferDialog()"
class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Offer (maybe with conditions)...
</button>
</div>
</div>
<OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<div v-if="activeDid && isRegistered">
<div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div>
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
>
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
<fa icon="hand" class="fa-fw text-blue-500 text-5xl cursor-pointer" />
<h3
class="mt-5 text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
You
</h3>
</li>
<li @click="openGiftDialog()">
<li @click="openGiftDialogToProject()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 6)"
v-for="contact in allContacts.slice(0, 5)"
:key="contact.did"
@click="openGiftDialog(contact)"
@click="openGiftDialogToProject(contact)"
>
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
{{ contact.name || "(no name)" }}
</h3>
</li>
<li>
<span
v-if="allContacts.length >= 5"
@click="onClickAllContactsGifting()"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
>
... or someone else...
</span>
</li>
</ul>
<!--
Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list
(we want to limit the grid count above to 8 or 12 accounts to keep it compact)
-->
<a
v-if="allContacts.length >= 7"
@click="onClickAllContactsGifting()"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Show More Contacts&hellip;
</a>
<GiftedDialog ref="customGiveDialog" :projectId="this.projectId" />
<GiftedDialog ref="giveDialogToThis" :toProjectId="this.projectId" />
</div>
<!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<!-- First, offers on the left-->
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3>
<div v-if="activeDid && isRegistered">
<div class="text-center">
<button
data-testId="offerButton"
@click="openOfferDialog()"
class="block w-full 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 rounded-md"
>
Offer to this (maybe with conditions)...
</button>
</div>
</div>
<OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<h3 class="text-lg font-bold mb-3 mt-4">Offered To This Idea</h3>
<div v-if="offersToThis.length === 0">
(None yet. Wanna
@@ -300,15 +307,27 @@
</div>
</div>
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm font-semibold mb-3">Given To This Idea</h3>
<!-- Now, gives TO this project in the middle -->
<!-- (similar to "FROM" gift display below) -->
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-to">
<div v-if="activeDid && isRegistered">
<div class="text-center">
<button
@click="openGiftDialogToProject()"
class="block w-full 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-1rounded-md"
>
Given To This...
</button>
</div>
</div>
<h3 class="text-lg font-bold mb-3 mt-4">Given To This Idea</h3>
<div v-if="givesToThis.length === 0">
(None yet. If you've seen something, say something by clicking a
contact above.)
</div>
<!-- similar to gift display below -->
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
@@ -346,12 +365,22 @@
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
<a
v-if="checkIsConfirmable(give)"
@click="confirmConfirmClaim(give)"
v-if="
checkIsConfirmable(give) &&
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
"
@click="deepCheckConfirmable(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
<fa icon="spinner" class="fa-spin-pulse" />
</a>
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
<fa icon="circle-check" class="text-slate-500 cursor-pointer" />
</a>
</div>
<div v-if="give.fullClaim.image" class="flex justify-center">
<a :href="give.fullClaim.image" target="_blank">
@@ -365,61 +394,98 @@
</div>
</div>
<div class="grid items-start grid-cols-1 gap-4">
<div
v-if="givesProvidedByThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<div>
<h3 class="text-sm font-semibold border-b">
Individuals Getting Contributions From This
</h3>
<!-- similar to gift display above -->
<ul class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
</li>
</ul>
<div v-if="givesProvidedByHitLimit" class="text-center">
<button @click="loadGivesProvidedBy()">Load More</button>
</div>
<!-- Finally, gives FROM this project on the right -->
<!-- (similar to "TO" gift display above) -->
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-from">
<div v-if="activeDid && isRegistered">
<div class="text-center">
<button
@click="openGiftDialogFromProject()"
class="block w-full 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 rounded-md"
>
Given By This...
</button>
</div>
</div>
<GiftedDialog
ref="giveDialogFromThis"
:fromProjectId="this.projectId"
/>
<h3 class="text-lg font-bold mb-3 mt-4">
Benefitted From This Project
</h3>
<div v-if="givesProvidedByThis.length === 0">(None yet.)</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
{{
serverUtil.didInfo(
give.recipientDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
<a
v-if="
checkIsConfirmable(give) &&
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
"
@click="deepCheckConfirmable(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
<fa icon="spinner" class="fa-spin-pulse" />
</a>
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
<fa icon="circle-check" class="text-slate-500 cursor-pointer" />
</a>
</div>
<div v-if="give.fullClaim.image" class="flex justify-center">
<a :href="give.fullClaim.image" target="_blank">
<img :src="give.fullClaim.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li>
</ul>
<div v-if="givesProvidedByHitLimit" class="text-center">
<button @click="loadGivesProvidedBy()">Load More</button>
</div>
</div>
</div>
</section>
<HiddenDidDialog ref="hiddenDidDialog" />
</template>
<script lang="ts">
@@ -434,14 +500,15 @@ import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import {
BLANK_GENERIC_SERVER_RECORD,
GenericCredWrapper,
getHeaders,
GiveSummaryRecord,
GiveVerifiableCredential,
OfferSummaryRecord,
@@ -449,11 +516,14 @@ import {
PlanSummaryRecord,
} from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer";
import { retrieveAccountDids } from "@/libs/util";
import HiddenDidDialog from "@/components/HiddenDidDialog.vue";
@Component({
components: {
EntityIcon,
GiftedDialog,
HiddenDidDialog,
OfferDialog,
ProjectIcon,
QuickNav,
@@ -465,9 +535,11 @@ export default class ProjectViewView extends Vue {
activeDid = "";
agentDid = "";
agentDidVisibleToDids: Array<string> = [];
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
checkingConfirmationForJwtId = "";
description = "";
expanded = false;
fulfilledByThis: PlanSummaryRecord | null = null;
@@ -480,13 +552,19 @@ export default class ProjectViewView extends Vue {
imageUrl = "";
isRegistered = false;
issuer = "";
issuerInfoObject: {
known: boolean;
displayName: string;
profileImageUrl?: string;
} | null = null;
issuerVisibleToDids: Array<string> = [];
latitude = 0;
longitude = 0;
name = "";
offersToThis: Array<OfferSummaryRecord> = [];
offersHitLimit = false;
projectId = ""; // handle ID
showDidCopy = false;
recentlyCheckedAndUnconfirmableJwts: string[] = [];
startTime = "";
truncatedDesc = "";
truncateLength = 40;
@@ -502,10 +580,24 @@ export default class ProjectViewView extends Vue {
this.allContacts = await db.contacts.toArray();
this.isRegistered = !!settings.isRegistered;
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Account[] = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
try {
this.allMyDids = await retrieveAccountDids();
} catch (error) {
// continue because we want to see claims, even anonymously
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "See the Help page to fix problems with your personal data.",
},
5000,
);
}
const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) {
@@ -536,7 +628,7 @@ export default class ProjectViewView extends Vue {
const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers = await getHeaders(userDid);
const headers = await serverUtil.getHeaders(userDid);
try {
const resp = await this.axios.get(url, { headers });
@@ -550,8 +642,17 @@ export default class ProjectViewView extends Vue {
startDateTime.toLocaleTimeString();
}
this.agentDid = resp.data.claim?.agent?.identifier;
this.agentDidVisibleToDids =
resp.data.claim?.agent?.identifierVisibleToDids || [];
this.imageUrl = resp.data.claim?.image;
this.issuer = resp.data.issuer;
this.issuerInfoObject = serverUtil.didInfoObject(
this.issuer,
this.activeDid,
this.allMyDids,
this.allContacts,
);
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
@@ -566,80 +667,38 @@ export default class ProjectViewView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem getting that project. See logs for more info.",
text: "There was a problem getting that project.",
},
5000,
);
}
} catch (error: unknown) {
console.error("Error retrieving project:", error);
const serverError = error as AxiosError;
if (serverError.response?.status === 404) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That project does not exist.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.",
},
5000,
);
}
}
this.loadGives();
this.loadGivesProvidedBy();
this.loadOffers();
this.loadPlanFulfillersTo();
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfilledByUrl, { headers });
if (resp.status === 200) {
this.fulfilledByThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
text: "Something went wrong retrieving that project.",
},
5000,
);
console.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
this.givesToThis = [];
this.loadGives();
this.givesProvidedByThis = [];
this.loadGivesProvidedBy();
this.offersToThis = [];
this.loadOffers();
this.fulfillersToThis = [];
this.loadPlanFulfillersTo();
this.fulfilledByThis = null;
this.loadPlanFulfilledBy();
}
async loadGives() {
@@ -654,7 +713,7 @@ export default class ProjectViewView extends Vue {
}
const givesInUrl = givesUrl + postfix;
const headers = await getHeaders(this.activeDid);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
@@ -689,6 +748,56 @@ export default class ProjectViewView extends Vue {
}
}
async loadGivesProvidedBy() {
const providedByUrl =
this.apiServer +
"/api/v2/report/givesProvidedBy?providerId=" +
encodeURIComponent(this.projectId);
let postfix = "";
if (this.givesProvidedByThis.length > 0) {
postfix =
"&beforeId=" +
this.givesProvidedByThis[this.givesProvidedByThis.length - 1].jwtId;
}
const providedByFullUrl = providedByUrl + postfix;
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(providedByFullUrl, { headers });
if (resp.status === 200) {
this.givesProvidedByThis = this.givesProvidedByThis.concat(
resp.data.data,
);
this.givesProvidedByHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives that were provided by this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives that were provided by this project.",
},
5000,
);
console.error(
"Something went wrong retrieving gives that were provided by this project:",
serverError.message,
);
}
}
async loadOffers() {
const offersUrl =
this.apiServer +
@@ -701,7 +810,7 @@ export default class ProjectViewView extends Vue {
}
const offersInUrl = offersUrl + postfix;
const headers = await getHeaders(this.activeDid);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(offersInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
@@ -749,7 +858,7 @@ export default class ProjectViewView extends Vue {
}
const fulfillsInUrl = fulfillsUrl + postfix;
const headers = await getHeaders(this.activeDid);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(fulfillsInUrl, { headers });
if (resp.status === 200) {
@@ -784,34 +893,23 @@ export default class ProjectViewView extends Vue {
}
}
async loadGivesProvidedBy() {
const providedByUrl =
async loadPlanFulfilledBy() {
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/givesProvidedBy?providerId=" +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
encodeURIComponent(this.projectId);
let postfix = "";
if (this.givesProvidedByThis.length > 0) {
postfix =
"&beforeId=" +
this.givesProvidedByThis[this.givesProvidedByThis.length - 1].jwtId;
}
const providedByFullUrl = providedByUrl + postfix;
const headers = await getHeaders(this.activeDid);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(providedByFullUrl, { headers });
const resp = await this.axios.get(fulfilledByUrl, { headers });
if (resp.status === 200) {
this.givesProvidedByThis = this.givesProvidedByThis.concat(
resp.data.data,
);
this.givesProvidedByHitLimit = resp.data.hitLimit;
this.fulfilledByThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives that were provided by this project.",
text: "Failed to retrieve plans fulfilled by this project.",
},
5000,
);
@@ -823,12 +921,12 @@ export default class ProjectViewView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives that were provided by this project.",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
5000,
);
console.error(
"Something went wrong retrieving gives that were provided by this project:",
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
@@ -860,12 +958,21 @@ export default class ProjectViewView extends Vue {
);
}
openGiftDialog(contact?: libsUtil.GiverReceiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(
openGiftDialogToProject(contact?: libsUtil.GiverReceiverInputInfo) {
(this.$refs.giveDialogToThis as GiftedDialog).open(
contact,
undefined,
undefined,
"Given by " + (contact?.name || "someone not named"),
(contact?.name || "Someone not named") + ` gave to this project`,
);
}
openGiftDialogFromProject() {
(this.$refs.giveDialogFromThis as GiftedDialog).open(
undefined,
{ did: this.activeDid, name: "You" },
undefined,
`This project gave to you`,
);
}
@@ -892,7 +999,7 @@ export default class ProjectViewView extends Vue {
checkIsFulfillable(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD,
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
claimType: "Offer",
issuer: offer.offeredByDid,
@@ -902,14 +1009,14 @@ export default class ProjectViewView extends Vue {
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD,
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
(this.$refs.giveDialogToThis as GiftedDialog).open(
giver,
undefined,
offer.handleId,
@@ -945,20 +1052,70 @@ export default class ProjectViewView extends Vue {
}
}
checkIsConfirmable(give: GiveSummaryRecord) {
/**
* @param confirmerIdList optional list of DIDs who confirmed; if missing, doesn't do a full server check
*/
checkIsConfirmable(give: GiveSummaryRecord, confirmerIdList?: string[]) {
const giveDetails: GenericCredWrapper<GiveVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD,
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim,
claimType: "GiveAction",
issuer: give.agentDid,
issuer: give.issuerDid,
};
return libsUtil.isGiveRecordTheUserCanConfirm(
this.isRegistered,
giveDetails,
this.activeDid,
confirmerIdList,
);
}
shallowNotifyWhyCannotConfirm(give: GiveSummaryRecord) {
const confirmerIds = this.recentlyCheckedAndUnconfirmableJwts.includes(
give.jwtId,
)
? [this.activeDid]
: [];
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
"GiveAction",
give,
this.activeDid,
confirmerIds,
);
}
async deepCheckConfirmable(give: GiveSummaryRecord) {
this.checkingConfirmationForJwtId = give.jwtId;
const confirmerInfo: libsUtil.ConfirmerData | undefined =
await libsUtil.retrieveConfirmerIdList(
this.apiServer,
give.jwtId,
give.issuerDid,
this.activeDid,
);
if (
this.checkIsConfirmable(give, confirmerInfo?.confirmerIdList as string[])
) {
this.confirmConfirmClaim(give);
} else {
this.recentlyCheckedAndUnconfirmableJwts = [
...this.recentlyCheckedAndUnconfirmableJwts,
give.jwtId,
];
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
"GiveAction",
give,
this.activeDid,
confirmerInfo?.confirmerIdList as string[],
);
}
this.checkingConfirmationForJwtId = "";
}
confirmConfirmClaim(give: GiveSummaryRecord) {
this.$notify(
{
@@ -1007,11 +1164,15 @@ export default class ProjectViewView extends Vue {
},
5000,
);
this.recentlyCheckedAndUnconfirmableJwts = [
...this.recentlyCheckedAndUnconfirmableJwts,
give.jwtId,
];
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
"There was a problem submitting the confirmation.";
this.$notify(
{
group: "alert",
@@ -1023,5 +1184,15 @@ export default class ProjectViewView extends Vue {
);
}
}
openHiddenDidDialog() {
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"creator",
this.issuerVisibleToDids,
this.allContacts,
this.activeDid,
this.allMyDids,
);
}
}
</script>

View File

@@ -4,7 +4,11 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">Your Ideas</h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Project Ideas
</h1>
<OnboardingDialog ref="onboardingDialog" />
<!-- Result Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300 mt-8">
@@ -61,7 +65,7 @@
<!-- New Project -->
<button
v-if="isRegistered && showProjects"
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
class="fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
<fa icon="plus" class="fa-fw"></fa>
@@ -207,7 +211,7 @@
Hit the big
<fa
icon="plus"
class="bg-blue-600 text-white px-1 py-1 rounded-full"
class="bg-green-600 text-white px-1.5 py-1 rounded-full"
/>
button. You'll never know until you try.
</div>
@@ -259,12 +263,15 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as libsUtil from "@/libs/util";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import EntityIcon from "@/components/EntityIcon.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import OnboardingDialog from "@/components/OnboardingDialog.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue";
import UserNameDialog from "@/components/UserNameDialog.vue";
import { Contact } from "@/db/tables/contacts";
import {
didInfo,
getHeaders,
@@ -272,15 +279,15 @@ import {
OfferSummaryRecord,
PlanData,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
import { Contact } from "@/db/tables/contacts";
import UserNameDialog from "@/components/UserNameDialog.vue";
import * as libsUtil from "@/libs/util";
import { OnboardPage } from "@/libs/util";
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
OnboardingDialog,
ProjectIcon,
TopMessage,
UserNameDialog,
@@ -305,8 +312,8 @@ export default class ProjectsView extends Vue {
offers: OfferSummaryRecord[] = [];
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
projects: PlanData[] = [];
showOffers = true;
showProjects = false;
showOffers = false;
showProjects = true;
libsUtil = libsUtil;
didInfo = didInfo;
@@ -321,15 +328,19 @@ export default class ProjectsView extends Vue {
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
this.allMyDids = await libsUtil.retrieveAccountDids();
if (allAccounts.length === 0) {
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Create,
);
}
if (this.allMyDids.length === 0) {
console.error("No accounts found.");
this.errNote("You need an identifier to load your projects.");
} else {
await this.loadOffers();
await this.loadProjects();
}
} catch (err) {
console.error("Error initializing:", err);
@@ -344,20 +355,20 @@ export default class ProjectsView extends Vue {
**/
async projectDataLoader(url: string) {
try {
const headers = await getHeaders(this.activeDid);
const headers = await getHeaders(this.activeDid, this.$notify);
this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan;
const { name, description, handleId, image, issuerDid, rowId } = plan;
this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
rowId,
});
}
} else {
@@ -384,7 +395,7 @@ export default class ProjectsView extends Vue {
async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
await this.loadProjects(`beforeId=${latestProject.rowid}`);
await this.loadProjects(`beforeId=${latestProject.rowId}`);
}
}
@@ -464,9 +475,9 @@ export default class ProjectsView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get offers from the server. Try again later.",
text: "Failed to get offers from the server.",
},
-1,
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -479,7 +490,7 @@ export default class ProjectsView extends Vue {
title: "Error",
text: "Got an error loading offers.",
},
-1,
5000,
);
} finally {
this.isLoading = false;

View File

@@ -67,6 +67,7 @@
<script lang="ts">
import axios from "axios";
import { DateTime } from "luxon";
import { Router } from "vue-router";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
@@ -200,6 +201,7 @@ export default class QuickActionBvcBeginView extends Vue {
},
3000,
);
(this.$router as Router).push({ path: "/quick-action-bvc" });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -22,7 +22,7 @@
<div>
<h2 class="text-2xl m-2">Confirm</h2>
<div v-if="loadingConfirms" class="flex justify-center">
<fa icon="spinner" class="animate-spin" />
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="claimsToConfirm.length === 0">
There are no claims yet today for you to confirm.
@@ -97,7 +97,7 @@
<h2 class="text-2xl m-2">Anything else?</h2>
<div class="m-2 flex">
<input type="checkbox" v-model="someoneGave" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Someone else gave</span>
<span class="pb-2 pl-2 pr-2">The group provided</span>
<span v-if="someoneGave">
<input
type="text"
@@ -106,7 +106,8 @@
class="border border-slate-400 h-6 px-2"
/>
<br />
(Everyone likes personalized messages! 😁)
(Everyone likes personalized messages! 😁 ... and for a pic:
<input type="checkbox" v-model="supplyGiftDetails" />)
</span>
<!-- This is to match input height to avoid shifting when hiding & showing. -->
<span v-else class="h-6">...</span>
@@ -144,7 +145,11 @@ import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
BVC_MEETUPS_PROJECT_CLAIM_ID,
@@ -179,6 +184,7 @@ export default class QuickActionBvcBeginView extends Vue {
description = "breakfast";
loadingConfirms = true;
someoneGave = false;
supplyGiftDetails = false;
async created() {
this.loadingConfirms = true;
@@ -205,6 +211,7 @@ export default class QuickActionBvcBeginView extends Vue {
suppressMilliseconds: true,
}) || "";
const accountsDB = await accountsDBPromise;
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
@@ -262,7 +269,9 @@ export default class QuickActionBvcBeginView extends Vue {
async record() {
try {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
if (this.claimsToConfirmSelected.length > 0) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
}
// in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled(
@@ -304,7 +313,7 @@ export default class QuickActionBvcBeginView extends Vue {
// now send the give for the description
let giveSucceeded = false;
if (this.someoneGave) {
if (this.someoneGave && !this.supplyGiftDetails) {
const giveResult = await createAndSubmitGive(
axios,
this.apiServer,
@@ -314,6 +323,10 @@ export default class QuickActionBvcBeginView extends Vue {
this.description,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
giveSucceeded = giveResult.type === "success";
@@ -332,29 +345,60 @@ export default class QuickActionBvcBeginView extends Vue {
);
}
}
if (confirmsSucceeded.length > 0 || giveSucceeded) {
const confirms =
confirmsSucceeded.length === 1 ? "confirmation" : "confirmations";
const actions =
confirmsSucceeded.length > 0 && giveSucceeded
? `Your ${confirms} and that give have been recorded.`
: giveSucceeded
? "That give has been recorded."
: "Your " +
confirms +
" " +
(confirmsSucceeded.length === 1 ? "has" : "have") +
" been recorded.";
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: actions,
if (this.someoneGave && this.supplyGiftDetails) {
// we'll give a success message for the confirmations and go to the gifted details page
if (confirmsSucceeded.length > 0) {
const actions =
confirmsSucceeded.length === 1
? `Your confirmation has been recorded.`
: `Your confirmations have been recorded.`;
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: actions,
},
3000,
);
}
(this.$router as Router).push({
name: "gifted-details",
query: {
description: this.description,
destinationPathAfter: "/",
providerProjectId: BVC_MEETUPS_PROJECT_CLAIM_ID,
recipientDid: this.activeDid,
},
3000,
);
});
} else {
// just go ahead and print a message for all the activity
if (confirmsSucceeded.length > 0 || giveSucceeded) {
const confirms =
confirmsSucceeded.length === 1 ? "confirmation" : "confirmations";
const actions =
confirmsSucceeded.length > 0 && giveSucceeded
? `Your ${confirms} and that give have been recorded.`
: giveSucceeded
? "That give has been recorded."
: "Your " +
confirms +
" " +
(confirmsSucceeded.length === 1 ? "has" : "have") +
" been recorded.";
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: actions,
},
3000,
);
(this.$router as Router).push({ path: "/" });
} else {
// errors should have already shown
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -0,0 +1,166 @@
<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"
/>
Offers to Your Projects
</h1>
</div>
<div v-if="newOffersToUserProjects.length === 0">
<p>Nobody has given any offers to your projects.</p>
<p class="mt-2">
Maybe there are already some projects you can help on the
<router-link to="/discover" class="text-blue-500">
Discover page <fa icon="search" />
</router-link>
</p>
<p class="mt-2">
You can announce more of your own on
<router-link to="/contacts" class="text-blue-500">
Your Ideas page <fa icon="hand" />
</router-link>
</p>
</div>
<InfiniteScroll @reached-bottom="loadMoreOffersToUserProjects">
<ul
data-testId="listRecentOffersToUserProjects"
class="border-t border-slate-300"
>
<li
v-for="offer in newOffersToUserProjects"
:key="offer.jwtId"
class="mt-4 relative group"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="offer.jwtId == lastAckedOfferToUserProjectsJwtId"
>
You've already seen all the following
</div>
<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>
</li>
</ul>
</InfiniteScroll>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
didInfo,
displayAmount,
getNewOffersToUserProjects,
OfferToPlanSummaryRecord,
} from "@/libs/endorserServer";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: { EntityIcon, GiftedDialog, InfiniteScroll, QuickNav },
})
export default class RecentOffersToUserView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
lastAckedOfferToUserProjectsJwtId = "";
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsAtEnd = 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.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
const offersToUserProjectsData = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
undefined,
undefined,
);
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsAtEnd = !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 loadMoreOffersToUserProjects() {
if (this.newOffersToUserProjectsAtEnd) {
return;
}
const offersToUserProjectsData = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
undefined,
this.newOffersToUserProjects[this.newOffersToUserProjects.length - 1]
.jwtId,
);
this.newOffersToUserProjects.push(...offersToUserProjectsData.data);
this.newOffersToUserProjectsAtEnd = !offersToUserProjectsData.hitLimit;
}
}
</script>

View File

@@ -0,0 +1,157 @@
<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"
/>
Offers to You
</h1>
</div>
<div v-if="newOffersToUser.length === 0">
<p>Nobody has given you an offer.</p>
<p class="mt-2">
You can start the cycle on the
<router-link to="/contacts" class="text-blue-500">
Contacts page <fa icon="users" />
</router-link>
with an "Offer" directly to someone. Hopefully you'll find a common
interest!
</p>
</div>
<InfiniteScroll @reached-bottom="loadMoreOffersToUser">
<ul
data-testId="listRecentOffersToUser"
class="border-t border-slate-300"
>
<li
v-for="offer in newOffersToUser"
:key="offer.jwtId"
class="mt-4 relative group"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="offer.jwtId == lastAckedOfferToUserJwtId"
>
You've already seen all the following
</div>
<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>
</li>
</ul>
</InfiniteScroll>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import {
didInfo,
displayAmount,
getNewOffersToUser,
OfferSummaryRecord,
} from "@/libs/endorserServer";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: { EntityIcon, GiftedDialog, InfiniteScroll, QuickNav },
})
export default class RecentOffersToUserView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
lastAckedOfferToUserJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserAtEnd = 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.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
undefined,
undefined,
);
this.newOffersToUser = offersToUserData.data;
this.newOffersToUserAtEnd = !offersToUserData.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 loadMoreOffersToUser() {
if (this.newOffersToUserAtEnd) {
return;
}
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
undefined,
this.newOffersToUser[this.newOffersToUser.length - 1].jwtId,
);
this.newOffersToUser.push(...offersToUserData.data);
this.newOffersToUserAtEnd = !offersToUserData.hitLimit;
}
}
</script>

View File

@@ -26,7 +26,7 @@
your device to run searches but it is not stored on our servers.
</div>
<div>
<div class="text-center">
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
Click to Choose a Location for Nearby Search
</button>
@@ -35,6 +35,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="storeSearchBox"
>
<fa icon="save" class="fa-fw" />
Store This Location for Nearby Search
</button>
<button
@@ -42,6 +43,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="forgetSearchBox"
>
<fa icon="trash-can" class="fa-fw" />
Delete Stored Location
</button>
<button
@@ -49,13 +51,15 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="resetLatLong"
>
Reset Marker
<fa icon="rotate" class="fa-fw" />
Reset To Original
</button>
<button
v-if="isNewMarkerSet"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="isNewMarkerSet = false"
>
<fa icon="eraser" class="fa-fw" />
Erase Marker
</button>
<div v-if="isNewMarkerSet">
@@ -64,7 +68,7 @@
</div>
</div>
<div class="mb-4 aspect-video">
<div class="aspect-video">
<l-map
ref="map"
:center="[localCenterLat, localCenterLong]"
@@ -125,7 +129,7 @@ const DEFAULT_ZOOM = 2;
LTileLayer,
},
})
export default class DiscoverView extends Vue {
export default class SearchAreaView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
isChoosingSearchBox = false;
@@ -162,8 +166,10 @@ export default class DiscoverView extends Vue {
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
const bounds = event.target.boxZoom?._map?.getBounds();
if (bounds) {
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
latDiff =
Math.abs(bounds.getNorthEast().lat - bounds.getSouthWest().lat) / 8;
longDiff =
Math.abs(bounds.getNorthEast().lng - bounds.getSouthWest().lng) / 8;
}
this.localLatDiff = latDiff;
this.localLongDiff = longDiff;
@@ -222,7 +228,7 @@ export default class DiscoverView extends Vue {
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
5000,
);
console.error(
"Telling user to retry the location search setting because:",
@@ -237,7 +243,7 @@ export default class DiscoverView extends Vue {
title: "No Location Selected",
text: "Select a location on the map.",
},
-1,
5000,
);
}
}
@@ -265,7 +271,7 @@ export default class DiscoverView extends Vue {
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
5000,
);
console.error(
"Telling user to retry the location search setting because:",

View File

@@ -94,19 +94,22 @@
</button>
</div>
</div>
<div v-else>You do not have an active identifier.</div>
<div v-else>You do not have an active identity.</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "@/libs/util";
@Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue {
@@ -124,20 +127,18 @@ export default class SeedBackupView extends Vue {
const settings = await retrieveSettingsForActiveAccount();
const activeDid = settings.activeDid || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
this.numAccounts = accounts.length;
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
this.numAccounts = await retrieveAccountCount();
this.activeAccount = await retrieveFullyDecryptedAccount(activeDid);
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Account",
title: "Error Loading Profile",
text: "Got an error loading your seed data.",
},
-1,
3000,
);
}
}

View File

@@ -41,7 +41,6 @@
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
@@ -49,8 +48,9 @@ import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { generateEndorserJwtForAccount } from "@/libs/endorserServer";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { generateEndorserJwtUrlForAccount } from "@/libs/endorserServer";
import { retrieveAccountMetadata } from "@/libs/util";
@Component({
components: { QuickNav, TopMessage },
@@ -65,18 +65,17 @@ export default class ShareMyContactInfoView extends Vue {
const isRegistered = !!settings.isRegistered;
const profileImageUrl = settings.profileImageUrl || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const account = await retrieveAccountMetadata(activeDid);
const numContacts = await db.contacts.count();
if (account) {
const message = await generateEndorserJwtForAccount(
const message = await generateEndorserJwtUrlForAccount(
account,
isRegistered,
givenName,
profileImageUrl,
true,
);
useClipboard()
.copy(message)

View File

@@ -120,7 +120,7 @@ export default class SharedPhotoView extends Vue {
title: "Error",
text: "Got an error loading this data.",
},
-1,
3000,
);
}
}

View File

@@ -92,8 +92,11 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, PASSKEYS_ENABLED } from "@/constants/app";
import { accountsDB, retrieveSettingsForActiveAccount } from "@/db/index";
import { registerSaveAndActivatePasskey } from "@/libs/util";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import {
registerSaveAndActivatePasskey,
retrieveAccountCount,
} from "@/libs/util";
@Component({
components: {},
@@ -108,8 +111,7 @@ export default class StartView extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
this.numAccounts = await retrieveAccountCount();
}
public onClickNewSeed() {

View File

@@ -91,7 +91,7 @@ export default class StatisticsView extends Vue {
title: "Mounting Error",
text: error.message,
},
-1,
5000,
);
}
}

View File

@@ -35,7 +35,7 @@
5000,
)
"
class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
>
Toast
</button>
@@ -49,10 +49,10 @@
title: 'Information Alert',
text: 'Just wanted you to know.',
},
-1,
5000,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Info
</button>
@@ -66,10 +66,10 @@
title: 'Success Alert',
text: 'Congratulations!',
},
-1,
5000,
)
"
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
>
Success
</button>
@@ -83,10 +83,10 @@
title: 'Warning Alert',
text: 'You might wanna look at this.',
},
-1,
5000,
)
"
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
>
Warning
</button>
@@ -100,10 +100,10 @@
title: 'Danger Alert',
text: 'Something terrible has happened!',
},
-1,
5000,
)
"
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
>
Danger
</button>
@@ -118,7 +118,7 @@
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif ON
</button>
@@ -133,7 +133,7 @@
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif MUTE
</button>
@@ -148,7 +148,7 @@
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif OFF
</button>
@@ -157,7 +157,7 @@
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" data-testid="fileInput" @change="uploadFile" />
<input type="file" data-testId="fileInput" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
@@ -165,7 +165,7 @@
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
data-testid="fileUploadButton"
data-testId="fileUploadButton"
>
Go to Shared Page
</router-link>
@@ -184,7 +184,7 @@
Register Passkey
<button
@click="register()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
@@ -194,13 +194,13 @@
Create JWT
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
@@ -210,19 +210,19 @@
Verify New JWT
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
@@ -230,11 +230,25 @@
<div v-else>Verify New JWT -- requires creation first</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Verify Hard-Coded JWT
</button>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Encryption & Decryption</h2>
See console for more output.
<div>
<button
@click="testEncryptionDecryption()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Run Test
</button>
Result: {{ encryptionTestResult }}
</div>
</div>
</section>
</template>
@@ -247,7 +261,8 @@ import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as cryptoLib from "@/libs/crypto";
import * as vcLib from "@/libs/crypto/vc";
import {
PeerSetup,
@@ -258,7 +273,7 @@ import {
import {
AccountKeyInfo,
blobToBase64,
getAccount,
retrieveAccountMetadata,
registerAndSavePasskey,
SHARED_PHOTO_BASE64_KEY,
} from "@/libs/util";
@@ -279,6 +294,9 @@ const TEST_PAYLOAD = {
export default class Help extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
// for encryption/decryption
encryptionTestResult?: boolean;
// for file import
fileName?: string;
@@ -289,16 +307,14 @@ export default class Help extends Vue {
peerSetup?: PeerSetup;
userName?: string;
cryptoLib = cryptoLib;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.userName = settings.firstName;
await accountsDB.open();
const account: { identity?: string } | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
const account = await retrieveAccountMetadata(this.activeDid);
if (this.activeDid) {
if (account) {
this.credIdHex = account.passkeyCredIdHex as string;
@@ -367,8 +383,12 @@ export default class Help extends Vue {
this.credIdHex = account.passkeyCredIdHex;
}
public async testEncryptionDecryption() {
this.encryptionTestResult = await cryptoLib.testEncryptionDecryption();
}
public async createJwtSimplewebauthn() {
const account: AccountKeyInfo | undefined = await getAccount(
const account: AccountKeyInfo | undefined = await retrieveAccountMetadata(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {
@@ -385,7 +405,7 @@ export default class Help extends Vue {
}
public async createJwtNavigator() {
const account: AccountKeyInfo | undefined = await getAccount(
const account: AccountKeyInfo | undefined = await retrieveAccountMetadata(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {

View File

@@ -0,0 +1,184 @@
<template>
<QuickNav selected="Discover" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 id="ViewHeading" 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>
Individual Profile
</h1>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 mt-16 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>
<div v-else-if="profile">
<!-- Profile Info -->
<div class="mt-8">
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
</div>
<p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }}
</p>
</div>
<!-- Map for first coordinates -->
<div v-if="profile?.locLat && profile?.locLon" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
<div class="h-96 mt-2 w-full">
<l-map
ref="profileMap"
:center="[profile.locLat, profile.locLon]"
:zoom="12"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker :lat-lng="[profile.locLat, profile.locLon]">
<l-popup>{{
didInfo(profile.issuerDid, activeDid, allMyDids, allContacts)
}}</l-popup>
</l-marker>
</l-map>
</div>
</div>
<!-- Map for second coordinates -->
<div v-if="profile?.locLat2 && profile?.locLon2" class="mt-4">
<h2 class="text-lg font-semibold">Second Location</h2>
<div class="h-96 mt-2 w-full">
<l-map
ref="profileMap"
:center="[profile.locLat2, profile.locLon2]"
:zoom="12"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker :lat-lng="[profile.locLat2, profile.locLon2]">
<l-popup>{{
didInfo(profile.issuerDid, activeDid, allMyDids, allContacts)
}}</l-popup>
</l-marker>
</l-map>
</div>
</div>
</div>
<div v-else class="text-center mt-8">
<p class="text-lg text-slate-500">Profile not found.</p>
</div>
</section>
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_PARTNER_API_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { didInfo, getHeaders } from "@/libs/endorserServer";
import { UserProfile } from "@/libs/partnerServer";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: {
LMap,
LMarker,
LPopup,
LTileLayer,
QuickNav,
TopMessage,
},
})
export default class UserProfileView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
isLoading = true;
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
// make this function available to the Vue template
didInfo = didInfo;
async mounted() {
const settings = await db.settings.toArray();
this.activeDid = settings[0]?.activeDid || "";
this.partnerApiServer =
settings[0]?.partnerApiServer || this.partnerApiServer;
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
await this.loadProfile();
}
async loadProfile() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
this.isLoading = false;
return;
}
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfile/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.profile = result.data;
} else {
throw new Error("Failed to load profile");
}
} catch (error) {
console.error("Error loading profile:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the profile.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
}
</script>

View File

@@ -5,6 +5,7 @@ importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
);
// similar method is in the src/db/index.ts file
function logConsoleAndDb(message, arg1, arg2) {
// in chrome://serviceworker-internals note that the arg1 and arg2 here will show as "[object Object]" in that page but will show as expandable objects in the console
console.log(`${new Date().toISOString()} ${message}`, arg1, arg2);
@@ -13,10 +14,18 @@ function logConsoleAndDb(message, arg1, arg2) {
if (appendDailyLog) {
let fullMessage = `${new Date().toISOString()} ${message}`;
if (arg1) {
fullMessage += `\n${JSON.stringify(arg1)}`;
if (typeof arg1 === "string") {
fullMessage += `\n${arg1}`;
} else {
fullMessage += `\n${JSON.stringify(arg1)}`;
}
}
if (arg2) {
fullMessage += `\n${JSON.stringify(arg2)}`;
if (typeof arg2 === "string") {
fullMessage += `\n${arg2}`;
} else {
fullMessage += `\n${JSON.stringify(arg2)}`;
}
}
// appendDailyLog is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// eslint-disable-next-line no-undef
@@ -63,16 +72,16 @@ self.addEventListener("push", function (event) {
// See https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/c1ed026662e754348a5f91542680bd4f57e5b81e/app.py#L217
const DAILY_UPDATE_TITLE = "DAILY_CHECK";
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// This is shared with the notification-test code and should be a constant. Look for the same name in HelpNotificationsView.vue
// Make sure it is something other than the DAILY_UPDATE_TITLE.
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// Make sure it is something different from the DAILY_UPDATE_TITLE.
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
let title;
let message = "Got some empty message.";
if (payload && payload.title == DIRECT_PUSH_TITLE) {
// skip any search logic and show the message directly
title = "Direct Notification";
title = "Direct Message";
message = payload.message || "No details were provided.";
} else {
// any other title will run through regular filtering logic
@@ -106,7 +115,7 @@ self.addEventListener("push", function (event) {
self.addEventListener("message", (event) => {
logConsoleAndDb("Service worker got a message...", event);
if (event.data && event.data.type === "SEND_LOCAL_DATA") {
self.secret = event.data.data;
self.secret = event.data.data; // used in safari-notifications.js to decrypt the account identity
event.ports[0].postMessage({ success: true });
}
logConsoleAndDb("Service worker posted a message.");
@@ -133,7 +142,8 @@ self.addEventListener("notificationclick", (event) => {
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
self.addEventListener("fetch", (event) => {
logConsoleAndDb("Service worker got fetch event.", event);
// Skipping this because we get so many of them, at startup and other times, all with an event of: {isTrusted:true}
//logConsoleAndDb("Service worker got fetch event.", event);
// Bypass any regular requests not related to Web Share Target
// and also requests that are not exactly to the timesafari.app

View File

@@ -515,6 +515,7 @@ async function getNotificationCount() {
const identity = activeAccount && activeAccount["identity"];
if (identity && "secret" in self) {
// get the "secret" pulled in additional-scripts.js to decrypt the "identity" inside the IndexedDB; see account.ts
const secret = self.secret;
const secretUint8Array = self.decodeBase64(secret);
const messageWithNonceAsUint8Array = self.decodeBase64(identity);

View File

@@ -1,12 +1,13 @@
import { test, expect } from '@playwright/test';
import { deleteContact, generateEthrUser, importUser } from './testUtils';
import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUtils';
test('Check activity feed', async ({ page }) => {
test('Check activity feed - check that server is running', async ({ page }) => {
// Load app homepage
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Check that initial 10 activities have been loaded
await page.locator('ul#listLatestActivity li:nth-child(10)');
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
// Scroll down a bit to trigger loading additional activities
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded();
@@ -17,7 +18,7 @@ test('Check discover results', async ({ page }) => {
await page.goto('./discover');
// Check that initial 10 projects have been loaded
await page.locator('ul#listDiscoverResults li.border-b:nth-child(10)');
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(10)')).toBeVisible();
// Scroll down a bit to trigger loading additional projects
await page.locator('ul#listDiscoverResults li.border-b:nth-child(20)').scrollIntoViewIfNeeded();
@@ -42,7 +43,7 @@ test('Check ability to share contact', async ({ page }) => {
await page.goto('./discover');
// Check that initial 10 projects have been loaded
await page.locator('ul#listDiscoverResults li.border-b:nth-child(10)');
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(10)')).toBeVisible();
// Scroll down a bit to trigger loading additional projects
await page.locator('ul#listDiscoverResults li.border-b:nth-child(20)').scrollIntoViewIfNeeded();
@@ -75,6 +76,7 @@ test('Check ID generation', async ({ page }) => {
test('Check setting name & sharing info', async ({ page }) => {
// Load homepage to trigger ID generation (?)
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Check 'someone must register you' notice
await expect(page.getByText('someone must register you.')).toBeVisible();
await page.getByRole('button', { name: /Show them/}).click();
@@ -82,8 +84,8 @@ test('Check setting name & sharing info', async ({ page }) => {
await expect(page.getByText('Set Your Name')).toBeVisible();
await page.getByRole('textbox').fill('Me Test User');
await page.locator('button:has-text("Save")').click();
await expect(page.getByText('share another way')).toBeVisible();
await page.getByRole('button', { name: /share another way/ }).click();
await expect(page.getByText('share some other way')).toBeVisible();
await page.getByRole('button', { name: /share some other way/ }).click();
await expect(page.getByRole('button', { name: 'copy to clipboard' })).toBeVisible();
await page.getByRole('button', { name: 'copy to clipboard' }).click();
await expect(page.getByText('contact info was copied')).toBeVisible();
@@ -112,12 +114,13 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
test('Check User 0 can register a random person', async ({ page }) => {
await importUser(page, '00');
const newDid = await generateEthrUser(page);
const newDid = await generateAndRegisterEthrUser(page);
expect(newDid).toContain('did:ethr:');
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').fill('Access!');
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// now ensure that alert goes away

View File

@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
test('Check User 0 can invite someone', async ({ page }) => {
await importUser(page, '00');
await page.goto('./invite-one');
await page.locator('button > svg.fa-plus').click();
const neighborNum = await generateRandomString(5);
await page.getByPlaceholder('Notes', { exact: true }).fill(`Neighbor ${neighborNum}`);
// get the expiration date input and set to 14 days from now
const expirationDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
await page.locator('input[type="date"]').fill(expirationDate.toISOString().split('T')[0]);
await page.locator('button:has-text("Save")').click();
await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden();
// check that the invite is in the list
const newInviteLine = page.locator(`td:has-text("Neighbor ${neighborNum}")`);
await expect(newInviteLine).toBeVisible();
// retrieve the link from the title
const inviteLink = await newInviteLine.getAttribute('data-testId');
expect(inviteLink).not.toBeNull();
// become the new user and accept the invite
const newDid = await generateNewEthrUser(page);
await switchToUser(page, newDid);
await page.goto(inviteLink as string);
await page.getByPlaceholder('Name', { exact: true }).fill(`My pal User #0`);
await page.locator('button:has-text("Save")').click();
await expect(page.locator('button:has-text("Save")')).toBeHidden();
await expect(page.locator(`li:has-text("My pal User #0")`)).toBeVisible();
});

View File

@@ -23,6 +23,6 @@ test('Check usage limits', async ({ page }) => {
await page.getByRole('button', { name: 'Set Your Name' }).click();
const name = 'User ' + did.slice(11, 14);
await page.getByPlaceholder('Name').fill(name);
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
});

View File

@@ -48,8 +48,9 @@ test('Create new project, then search for it', async ({ page }) => {
// Create new project
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').click();
// close onboarding, but not with a click to go to the main screen
await page.locator('div > svg.fa-xmark').click();
await page.locator('button > svg.fa-plus').click();
await page.getByPlaceholder('Idea Name').fill(finalTitle);
await page.getByPlaceholder('Description').fill(finalDescription);
await page.getByPlaceholder('Website').fill(standardWebsite);
@@ -63,7 +64,6 @@ test('Create new project, then search for it', async ({ page }) => {
// Search for newly-created project in /projects
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await expect(page.locator('ul#listProjects li').filter({ hasText: finalTitle })).toBeVisible();
// Search for newly-created project in /discover

View File

@@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
import { importUser, createUniqueStringsArray } from './testUtils';
test('Create 10 new projects', async ({ page }) => {
test.setTimeout(40000); // Set timeout longer since it often fails at 30 seconds
const projectCount = 10;
// Standard texts
@@ -42,8 +44,11 @@ test('Create 10 new projects', async ({ page }) => {
// Create new projects
for (let i = 0; i < projectCount; i++) {
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').click();
if (i === 0) {
// close onboarding, but not with a click to go to the main screen
await page.locator('div > svg.fa-xmark').click();
}
await page.locator('button > svg.fa-plus').click();
await page.getByPlaceholder('Idea Name').fill(finalTitles[i]); // Add random suffix
await page.getByPlaceholder('Description').fill(finalDescriptions[i]);
await page.getByPlaceholder('Website').fill(standardWebsite);

View File

@@ -19,11 +19,13 @@ test('Record something given', async ({ page }) => {
// Record something given
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// Refresh home view and check gift
await page.goto('./');
@@ -31,6 +33,8 @@ test('Record something given', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
const page1Promise = page.waitForEvent('popup');
// expand the Details section to see the extended details
await page.getByRole('heading', { name: 'Details', exact: true }).click();
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const page1 = await page1Promise;
});

View File

@@ -30,11 +30,15 @@ test('Record 9 new gifts', async ({ page }) => {
for (let i = 0; i < giftCount; i++) {
// Record something given
await page.goto('./');
if (i === 0) {
await page.getByTestId('closeOnboardingAndFinish').click();
}
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// Refresh home view and check gift
await page.goto('./');

View File

@@ -27,8 +27,12 @@ test('Record item given from image-share', async ({ page }) => {
await page.getByPlaceholder('What was received').fill(finalTitle);
await page.getByRole('spinbutton').fill('2');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// we end up on a page with the onboarding info
await page.getByTestId('closeOnboardingAndFinish').click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// Refresh home view and check gift
await page.goto('./');

View File

@@ -0,0 +1,50 @@
import { test, expect, Page } from '@playwright/test';
import { importUser } from './testUtils';
async function testProjectGive(page: Page, selector: string) {
// Generate a random string of a few characters
const randomString = Math.random().toString(36).substring(2, 6);
// Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix
const standardTitle = 'Gift ';
// Combine title prefix with the random string
const finalTitle = standardTitle + randomString;
// find a project and enter a give to it and see that it shows
await importUser(page, '00');
await page.goto('./discover');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.locator('ul#listDiscoverResults li:first-child a').click()
// wait for the project page to load
await page.waitForLoadState('networkidle');
// click the give button, inside the first div
await page.getByTestId(selector).locator('div:first-child div button').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// refresh the page
await page.reload();
// check that the give is in the list
await page
.getByTestId(selector)
.locator('div ul li:first-child')
.filter({ hasText: finalTitle })
.isVisible();
}
test('Record a give to a project', async ({ page }) => {
await testProjectGive(page, 'gives-to');
});
test('Record a give from a project', async ({ page }) => {
await testProjectGive(page, 'gives-from');
});

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