Compare commits

..

3 Commits

Author SHA1 Message Date
Matthew Aaron Raymer
9b141b6334 Stub adding js-yaml ... needs testing and more work 2023-12-09 18:24:44 +08:00
Matthew Aaron Raymer
0f46b8379c Added js-yml and types 2023-12-09 18:13:06 +08:00
Matthew Aaron Raymer
1bd22ad260 YML formatting options 2023-12-09 17:39:17 +08:00
113 changed files with 19047 additions and 22466 deletions

View File

@@ -1,3 +0,0 @@
# 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.

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

165
README.md
View File

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

3
babel.config.js Normal file
View File

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

View File

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

19476
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,122 @@
tasks :
tasks:
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d
- 40 notifications :
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
- extract private_key_hex in py-push-server webpush.py
- lock down regenerate_vapid endpoint (so only we admins can do it on demand)
- remove sleep in py-push-server app.py
- revisit "maybe" and "never" buttons on accont screen
- see if we can detect OS-level notifications if turned off
- write troubleshooting docs for notifications
- in py-push-server, when sending a push to a subscriber and we get on a 410 "error #106", delete the subscription record
- .3 fix the Project-location-selection map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
- .5 Add infinite scroll to gifts on the home page
- .5 If notifications are not enabled, add message to front page with link/button to enable
- add note after contact addition that they can see your info
- enhance help page instructions for debugging
- add way to test quickly a push notification
- help instructions for PWA install problems (secret failed, must reinstall)
- look at other examples for better UI friend.tech
- show VC details... somehow:
- 01 show my VCs - most interesting, or via search
- 01 allow download of each VC (& confirmations, to show that they actually own their data)
- 04 allow user to download VCs, mine + ones I can see about me from others
- add VC confirmation?
- Release Minimum Viable Product :
- generate new webpush.db entry, webpush.py private_key_hex & subscription_info & vapid_claims email
- .5 deploy endorser.ch server above Dec 1 (to get plan searches by names as well as descriptions)
- 08 thorough testing for errors & edge cases
- 01 ensure ability to recover server remotely, and add redundant access
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Add disclaimers.
- Switch default server to the public server.
- Deploy to a server.
- Ensure public server has limits that work for group adoption.
- Test PWA features on Android and iOS.
- Other features - donation vs give, show offers, show give & outstanding totals, show network view, restrict registration, connect to contacts
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- allow some gives even if they aren't registered
- .5 Add start date to project
- .3 check that Android shows "back" buttons on screens without bottom tray
- .1 Make give description text box into something that expands as they type?
- .5 customize favicon assignee-group:ui
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker
- .5 include the hash of the latest commit on help page next to version (maybe Trent's git-hash branch)
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
- switch some checks for activeDid to check for isRegistered
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
- .5 don't show "Offer" on project screen if they aren't registered
- 24 Move to Vite
- 32 accept images for projects
- 32 accept images for contacts
- linking between projects or plans :
- show total time given to & from a project
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 add "back" button to all screens that aren't part of the bottom tray
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
- 04 show gives spreading to other places
- badge for most gives/receives/confirms per day/week/month
- badge for amount given/offered to your project
- set a goal of given/offers
- automated tests, eg. cypress
- Notifications (wake on the phone, push notifications)
- Connect with phone contacts
- Multiple identities
- Support KERI AIDs
- Support Peer DIDs
- Support messaging through DIDComm
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
- Do we want split first name & last name?
- 40 notifications v+ :
- pull, w/ scheduled runs
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
- 16 From the home screen, make the quick action even easier.
log:
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 215 B

17
public/index.html Normal file
View File

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

View File

@@ -1,7 +1,7 @@
<template>
<router-view />
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<!-- https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert">
<div
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
@@ -129,10 +129,6 @@
</div>
</NotificationGroup>
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
@@ -146,97 +142,12 @@
move="transition duration-500"
move-delay="delay-300"
>
<!-- see NotificationIface in constants/app.ts -->
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<!--
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">
{{ notification.title }}
</span>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button
v-if="notification.onYes"
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes
{{ notification.yesText ? ", " + notification.yesText : "" }}
</button>
<button
v-if="notification.onNo"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No {{ notification.noText ? ", " + notification.noText : "" }}
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
{{ notification.onYes ? "Cancel" : "Close" }}
</button>
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -245,52 +156,33 @@
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 class="text-lg mb-4">
Would you like to <b>turn on</b> notifications for this app?
</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"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="
() => {
if (checkHour()) {
close(notification.id);
turnOnNotifications();
}
}
"
>
Turn on Daily Message
Turn on Notifications
</button>
<div class="grid grid-cols-2 gap-2">
<button
@click="maybeLater(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Maybe Later
</button>
<button
@click="never(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
>
Never
</button>
</div>
<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>
@@ -346,10 +238,6 @@
</p>
<button
@click="
close(notification.id);
turnOffNotifications();
"
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
@@ -372,11 +260,8 @@
<style></style>
<script lang="ts">
import axios from "axios";
import { Vue, Component } from "vue-facing-decorator";
import * as libsUtil from "@/libs/util";
import axios from "axios";
interface ServiceWorkerMessage {
type: string;
data: string;
@@ -400,37 +285,32 @@ interface VapidResponse {
};
}
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
notifyTime: { utcHour: number };
}
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { AppString } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { sendTestThroughPushServer } from "@/libs/util";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component
export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$notify!: (notification: Notification, timeout?: number) => void;
stopAsking = false;
b64 = "";
hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl = DEFAULT_PUSH_SERVER;
let pushUrl: string = AppString.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) => {
@@ -451,11 +331,7 @@ export default class App extends Vue {
-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(
{
@@ -468,11 +344,6 @@ export default class App extends Vue {
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
@@ -499,7 +370,6 @@ export default class App extends Vue {
}
private askPermission(): Promise<NotificationPermission> {
console.log("Requesting permission for notifications:", navigator);
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
return Promise.reject("Service worker not available.");
}
@@ -540,59 +410,14 @@ export default class App extends Vue {
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.",
);
alert("We need notification permission to provide certain features.");
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() {
async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
@@ -601,54 +426,20 @@ export default class App extends Vue {
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
return navigator.serviceWorker?.ready;
return navigator.serviceWorker.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
.then((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;
return this.sendSubscriptionToServer(subscription);
} 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 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,
);
.then(() => {
console.log("Subscription data sent to server.");
})
.catch((error) => {
console.error(
@@ -661,11 +452,8 @@ export default class App extends Vue {
});
})
.catch((error) => {
console.error(
"An error occurred setting notification permissions:",
error,
);
alert("Some error occurred setting notification permissions.");
console.error("An error occurred:", error);
alert("Some error occurred." + error);
});
}
@@ -712,7 +500,11 @@ export default class App extends Vue {
resolve();
})
.catch((error) => {
console.error("Push subscription failed:", error, options);
console.error(
"Subscription or server communication failed:",
error,
options,
);
// Inform the user about the issue
alert(
@@ -726,9 +518,9 @@ export default class App extends Vue {
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
subscription: PushSubscription,
): Promise<void> {
console.log("About to send subscription...", subscription);
console.log(subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
@@ -743,49 +535,12 @@ export default class App extends Vue {
});
}
async turnOffNotifications() {
let subscription;
const pushProviderSuccess = await navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then((subscript) => {
subscription = subscript;
if (subscription) {
return subscription.unsubscribe();
} else {
console.log("Subscription object is not available.");
return false;
never(ID: string) {
alert(ID);
}
})
.catch((error) => {
console.error("Push provider server communication failed:", error);
return false;
});
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
})
.then((response) => {
return response.ok;
})
.catch((error) => {
console.error("Push server communication failed:", error);
return false;
});
alert(
"Notifications are off. Push provider unsubscribe " +
(pushProviderSuccess ? "succeeded" : "failed") +
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
" push server unsubscribe " +
(pushServerSuccess ? "succeeded" : "failed") +
".",
);
maybeLater(ID: string) {
alert(ID);
}
}
</script>

View File

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

Before

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li
:class="{
@@ -12,7 +12,7 @@
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw" />
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
@@ -28,7 +28,7 @@
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw" />
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
@@ -44,7 +44,7 @@
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="hand" class="fa-fw" />
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
@@ -60,7 +60,7 @@
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw" />
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
@@ -76,7 +76,7 @@
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw" />
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,24 +12,15 @@ export type BoundingBox = {
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
id: number; // Only one entry using MASTER_SETTINGS_KEY
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
firstName?: string; // user's full name
hideRegisterPromptOnNewContact?: boolean;
firstName?: string; // User's first name
lastName?: string; // User's last name
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string; // Last notified claim ID
isRegistered?: boolean;
lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string;
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
webPushServer?: string; // Web Push server URL
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
@@ -38,18 +29,11 @@ export type Settings = {
}>;
showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
};
export function isAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}
/**
* Schema for the Settings table in the database.
*/
@@ -61,5 +45,3 @@ export const SettingsSchema = {
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

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

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import * as didJwt from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
export const ETHR_DID_PREFIX = "did:ethr:";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
) {
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, {
issuer: account.did,
signer: signer,
});
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt);
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,6 @@ import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
/**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/
export async function testServerRegisterUser() {
const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";

351
src/util.d.ts vendored
View File

@@ -1,5 +1,4 @@
// from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts
/* eslint-disable */
/**
* The `node:util` module supports the needs of Node.js internal APIs. Many of the
* utilities are useful for application and module developers as well. To access
@@ -106,7 +105,10 @@ declare module "util" {
| "date"
| "regexp"
| "module";
export type CustomInspectFunction = (depth: number, options: InspectOptionsStylized) => any; // TODO: , inspect: inspect
export type CustomInspectFunction = (
depth: number,
options: InspectOptionsStylized,
) => unknown; // TODO: , inspect: inspect
export interface InspectOptionsStylized extends InspectOptions {
stylize(text: string, styleType: Style): string;
}
@@ -155,7 +157,10 @@ declare module "util" {
* @since v0.5.3
* @param format A `printf`-like format string.
*/
export function format(format?: any, ...param: any[]): string;
export function format(
format?: string,
...param: (string | number | boolean)[]
): string;
/**
* This function is identical to {@link format}, except in that it takes
* an `inspectOptions` argument which specifies options that are passed along to {@link inspect}.
@@ -167,7 +172,11 @@ declare module "util" {
* ```
* @since v10.0.0
*/
export function formatWithOptions(inspectOptions: InspectOptions, format?: any, ...param: any[]): string;
export function formatWithOptions<T extends unknown[]>(
inspectOptions: InspectOptions,
format?: string,
...param: T
): string;
/**
* Returns the string name for a numeric error code that comes from a Node.js API.
* The mapping between error codes and error names is platform-dependent.
@@ -262,7 +271,10 @@ declare module "util" {
* @experimental
* @param resource Any non-null entity, reference to which is held weakly.
*/
export function aborted(signal: AbortSignal, resource: any): Promise<void>;
export function aborted(
signal: AbortSignal,
resource: unknown,
): Promise<void>;
/**
* The `util.inspect()` method returns a string representation of `object` that is
* intended for debugging. The output of `util.inspect` may change at any time
@@ -438,8 +450,13 @@ declare module "util" {
* @param object Any JavaScript primitive or `Object`.
* @return The representation of `object`.
*/
export function inspect(object: any, showHidden?: boolean, depth?: number | null, color?: boolean): string;
export function inspect(object: any, options?: InspectOptions): string;
export function inspect(
object: unknown,
showHidden?: boolean,
depth?: number | null,
color?: boolean,
): string;
export function inspect(object: unknown, options?: InspectOptions): string;
export namespace inspect {
let colors: NodeJS.Dict<[number, number]>;
let styles: {
@@ -596,7 +613,10 @@ declare module "util" {
* @since v0.3.0
* @legacy Use ES2015 class syntax and `extends` keyword instead.
*/
export function inherits(constructor: unknown, superConstructor: unknown): void;
export function inherits(
constructor: unknown,
superConstructor: unknown,
): void;
export type DebugLoggerFunction = (msg: string, ...param: unknown[]) => void;
export interface DebugLogger extends DebugLoggerFunction {
enabled: boolean;
@@ -658,7 +678,10 @@ declare module "util" {
* @param callback A callback invoked the first time the logging function is called with a function argument that is a more optimized logging function.
* @return The logging function
*/
export function debuglog(section: string, callback?: (fn: DebugLoggerFunction) => void): DebugLogger;
export function debuglog(
section: string,
callback?: (fn: DebugLoggerFunction) => void,
): DebugLogger;
export const debug: typeof debuglog;
/**
* Returns `true` if the given `object` is a `Boolean`. Otherwise, returns `false`.
@@ -748,7 +771,9 @@ declare module "util" {
* @since v0.11.5
* @deprecated Since v4.0.0 - Use `value === undefined || value === null` instead.
*/
export function isNullOrUndefined(object: unknown): object is null | undefined;
export function isNullOrUndefined(
object: unknown,
): object is null | undefined;
/**
* Returns `true` if the given `object` is a `Number`. Otherwise, returns `false`.
*
@@ -919,7 +944,11 @@ declare module "util" {
* @param code A deprecation code. See the `list of deprecated APIs` for a list of codes.
* @return The deprecated function wrapped to emit a warning.
*/
export function deprecate<T extends Function>(fn: T, msg: string, code?: string): T;
export function deprecate<T extends (...args: unknown[]) => unknown>(
fn: T,
msg: string,
code?: string,
): T;
/**
* Returns `true` if there is deep strict equality between `val1` and `val2`.
* Otherwise, returns `false`.
@@ -988,7 +1017,9 @@ declare module "util" {
* @param fn An `async` function
* @return a callback style function
*/
export function callbackify(fn: () => Promise<void>): (callback: (err: NodeJS.ErrnoException) => void) => void;
export function callbackify(
fn: () => Promise<void>,
): (callback: (err: NodeJS.ErrnoException) => void) => void;
export function callbackify<TResult>(
fn: () => Promise<TResult>,
): (callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void;
@@ -997,22 +1028,49 @@ declare module "util" {
): (arg1: T1, callback: (err: NodeJS.ErrnoException) => void) => void;
export function callbackify<T1, TResult>(
fn: (arg1: T1) => Promise<TResult>,
): (arg1: T1, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void;
): (
arg1: T1,
callback: (err: NodeJS.ErrnoException, result: TResult) => void,
) => void;
export function callbackify<T1, T2>(
fn: (arg1: T1, arg2: T2) => Promise<void>,
): (arg1: T1, arg2: T2, callback: (err: NodeJS.ErrnoException) => void) => void;
): (
arg1: T1,
arg2: T2,
callback: (err: NodeJS.ErrnoException) => void,
) => void;
export function callbackify<T1, T2, TResult>(
fn: (arg1: T1, arg2: T2) => Promise<TResult>,
): (arg1: T1, arg2: T2, callback: (err: NodeJS.ErrnoException | null, result: TResult) => void) => void;
): (
arg1: T1,
arg2: T2,
callback: (err: NodeJS.ErrnoException | null, result: TResult) => void,
) => void;
export function callbackify<T1, T2, T3>(
fn: (arg1: T1, arg2: T2, arg3: T3) => Promise<void>,
): (arg1: T1, arg2: T2, arg3: T3, callback: (err: NodeJS.ErrnoException) => void) => void;
): (
arg1: T1,
arg2: T2,
arg3: T3,
callback: (err: NodeJS.ErrnoException) => void,
) => void;
export function callbackify<T1, T2, T3, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3) => Promise<TResult>,
): (arg1: T1, arg2: T2, arg3: T3, callback: (err: NodeJS.ErrnoException | null, result: TResult) => void) => void;
): (
arg1: T1,
arg2: T2,
arg3: T3,
callback: (err: NodeJS.ErrnoException | null, result: TResult) => void,
) => void;
export function callbackify<T1, T2, T3, T4>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise<void>,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, callback: (err: NodeJS.ErrnoException) => void) => void;
): (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
callback: (err: NodeJS.ErrnoException) => void,
) => void;
export function callbackify<T1, T2, T3, T4, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise<TResult>,
): (
@@ -1024,7 +1082,14 @@ declare module "util" {
) => void;
export function callbackify<T1, T2, T3, T4, T5>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise<void>,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, callback: (err: NodeJS.ErrnoException) => void) => void;
): (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
arg5: T5,
callback: (err: NodeJS.ErrnoException) => void,
) => void;
export function callbackify<T1, T2, T3, T4, T5, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise<TResult>,
): (
@@ -1036,7 +1101,14 @@ declare module "util" {
callback: (err: NodeJS.ErrnoException | null, result: TResult) => void,
) => void;
export function callbackify<T1, T2, T3, T4, T5, T6>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Promise<void>,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
arg5: T5,
arg6: T6,
) => Promise<void>,
): (
arg1: T1,
arg2: T2,
@@ -1047,7 +1119,14 @@ declare module "util" {
callback: (err: NodeJS.ErrnoException) => void,
) => void;
export function callbackify<T1, T2, T3, T4, T5, T6, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Promise<TResult>,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
arg5: T5,
arg6: T6,
) => Promise<TResult>,
): (
arg1: T1,
arg2: T2,
@@ -1057,15 +1136,21 @@ declare module "util" {
arg6: T6,
callback: (err: NodeJS.ErrnoException | null, result: TResult) => void,
) => void;
export interface CustomPromisifyLegacy<TCustom extends Function> extends Function {
export interface CustomPromisifyLegacy<
TCustom extends (...args: unknown[]) => unknown,
> {
(...args: unknown[]): unknown;
__promisify__: TCustom;
}
export interface CustomPromisifySymbol<TCustom extends Function> extends Function {
export interface CustomPromisifySymbol<
TCustom extends (...args: unknown[]) => unknown,
> {
(...args: unknown[]): unknown;
[promisify.custom]: TCustom;
}
export type CustomPromisify<TCustom extends Function> =
| CustomPromisifySymbol<TCustom>
| CustomPromisifyLegacy<TCustom>;
export type CustomPromisify<TCustom extends (...args: unknown[]) => unknown> =
CustomPromisifySymbol<TCustom> | CustomPromisifyLegacy<TCustom>;
/**
* Takes a function following the common error-first callback style, i.e. taking
* an `(err, value) => ...` callback as the last argument, and returns a version
@@ -1135,40 +1220,88 @@ declare module "util" {
* ```
* @since v8.0.0
*/
export function promisify<TCustom extends Function>(fn: CustomPromisify<TCustom>): TCustom;
export function promisify<TCustom extends (...args: unknown[]) => unknown>(
fn: CustomPromisify<TCustom>,
): TCustom;
export function promisify<TResult>(
fn: (callback: (err: any, result: TResult) => void) => void,
fn: (callback: (err: unknown, result: TResult) => void) => void,
): () => Promise<TResult>;
export function promisify(fn: (callback: (err?: any) => void) => void): () => Promise<void>;
export function promisify(
fn: (callback: (err?: unknown) => void) => void,
): () => Promise<void>;
export function promisify<T1, TResult>(
fn: (arg1: T1, callback: (err: any, result: TResult) => void) => void,
fn: (arg1: T1, callback: (err: unknown, result: TResult) => void) => void,
): (arg1: T1) => Promise<TResult>;
export function promisify<T1>(fn: (arg1: T1, callback: (err?: any) => void) => void): (arg1: T1) => Promise<void>;
export function promisify<T1>(
fn: (arg1: T1, callback: (err?: unknown) => void) => void,
): (arg1: T1) => Promise<void>;
export function promisify<T1, T2, TResult>(
fn: (arg1: T1, arg2: T2, callback: (err: any, result: TResult) => void) => void,
fn: (
arg1: T1,
arg2: T2,
callback: (err: unknown, result: TResult) => void,
) => void,
): (arg1: T1, arg2: T2) => Promise<TResult>;
export function promisify<T1, T2>(
fn: (arg1: T1, arg2: T2, callback: (err?: any) => void) => void,
fn: (arg1: T1, arg2: T2, callback: (err?: unknown) => void) => void,
): (arg1: T1, arg2: T2) => Promise<void>;
export function promisify<T1, T2, T3, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, callback: (err: any, result: TResult) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
callback: (err: unknown, result: TResult) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3) => Promise<TResult>;
export function promisify<T1, T2, T3>(
fn: (arg1: T1, arg2: T2, arg3: T3, callback: (err?: any) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
callback: (err?: unknown) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3) => Promise<void>;
export function promisify<T1, T2, T3, T4, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, callback: (err: any, result: TResult) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
callback: (err: unknown, result: TResult) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise<TResult>;
export function promisify<T1, T2, T3, T4>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, callback: (err?: any) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
callback: (err?: unknown) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise<void>;
export function promisify<T1, T2, T3, T4, T5, TResult>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, callback: (err: any, result: TResult) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
arg5: T5,
callback: (err: unknown, result: TResult) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise<TResult>;
export function promisify<T1, T2, T3, T4, T5>(
fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, callback: (err?: any) => void) => void,
fn: (
arg1: T1,
arg2: T2,
arg3: T3,
arg4: T4,
arg5: T5,
callback: (err?: unknown) => void,
) => void,
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise<void>;
export function promisify(fn: Function): Function;
export function promisify<T extends (...args: unknown[]) => void>(
fn: T,
): (...funcArgs: Parameters<T>) => Promise<void>;
export namespace promisify {
/**
* That can be used to declare custom promisified variants of functions.
@@ -1273,27 +1406,32 @@ declare module "util" {
*/
encodeInto(src: string, dest: Uint8Array): EncodeIntoResult;
}
import { TextDecoder as _TextDecoder, TextEncoder as _TextEncoder } from "util";
import {
TextDecoder as _TextDecoder,
TextEncoder as _TextEncoder,
} from "util";
global {
/**
* `TextDecoder` class is a global reference for `require('util').TextDecoder`
* https://nodejs.org/api/globals.html#textdecoder
* @since v11.0.0
*/
var TextDecoder: typeof globalThis extends {
onmessage: any;
let TextDecoder: typeof globalThis extends {
onmessage: unknown;
TextDecoder: infer TextDecoder;
} ? TextDecoder
}
? TextDecoder
: typeof _TextDecoder;
/**
* `TextEncoder` class is a global reference for `require('util').TextEncoder`
* https://nodejs.org/api/globals.html#textencoder
* @since v11.0.0
*/
var TextEncoder: typeof globalThis extends {
onmessage: any;
let TextEncoder: typeof globalThis extends {
onmessage: unknown;
TextEncoder: infer TextEncoder;
} ? TextEncoder
}
? TextEncoder
: typeof _TextEncoder;
}
@@ -1326,7 +1464,9 @@ declare module "util" {
* @param config Used to provide arguments for parsing and to configure the parser. `config` supports the following properties:
* @return The parsed command line arguments:
*/
export function parseArgs<T extends ParseArgsConfig>(config?: T): ParsedResults<T>;
export function parseArgs<T extends ParseArgsConfig>(
config?: T,
): ParsedResults<T>;
interface ParseArgsOptionConfig {
/**
* Type of argument.
@@ -1389,31 +1529,46 @@ declare module "util" {
This is technically incorrect but is a much nicer UX for the common case.
The IfDefaultsTrue version is for things which default to true; the IfDefaultsFalse version is for things which default to false.
*/
type IfDefaultsTrue<T, IfTrue, IfFalse> = T extends true ? IfTrue
: T extends false ? IfFalse
type IfDefaultsTrue<T, IfTrue, IfFalse> = T extends true
? IfTrue
: T extends false
? IfFalse
: IfTrue;
// we put the `extends false` condition first here because `undefined` compares like `any` when `strictNullChecks: false`
type IfDefaultsFalse<T, IfTrue, IfFalse> = T extends false ? IfFalse
: T extends true ? IfTrue
type IfDefaultsFalse<T, IfTrue, IfFalse> = T extends false
? IfFalse
: T extends true
? IfTrue
: IfFalse;
type ExtractOptionValue<T extends ParseArgsConfig, O extends ParseArgsOptionConfig> = IfDefaultsTrue<
type ExtractOptionValue<
T extends ParseArgsConfig,
O extends ParseArgsOptionConfig,
> = IfDefaultsTrue<
T["strict"],
O["type"] extends "string" ? string : O["type"] extends "boolean" ? boolean : string | boolean,
O["type"] extends "string"
? string
: O["type"] extends "boolean"
? boolean
: string | boolean,
string | boolean
>;
type ParsedValues<T extends ParseArgsConfig> =
& IfDefaultsTrue<T["strict"], unknown, { [longOption: string]: undefined | string | boolean }>
& (T["options"] extends ParseArgsOptionsConfig ? {
type ParsedValues<T extends ParseArgsConfig> = IfDefaultsTrue<
T["strict"],
unknown,
{ [longOption: string]: undefined | string | boolean }
> &
(T["options"] extends ParseArgsOptionsConfig
? {
-readonly [LongOption in keyof T["options"]]: IfDefaultsFalse<
T["options"][LongOption]["multiple"],
undefined | Array<ExtractOptionValue<T, T["options"][LongOption]>>,
undefined | ExtractOptionValue<T, T["options"][LongOption]>
>;
}
: {});
: object);
type ParsedPositionals<T extends ParseArgsConfig> = IfDefaultsTrue<
T["strict"],
@@ -1424,7 +1579,8 @@ declare module "util" {
type PreciseTokenForOptions<
K extends string,
O extends ParseArgsOptionConfig,
> = O["type"] extends "string" ? {
> = O["type"] extends "string"
? {
kind: "option";
index: number;
name: K;
@@ -1432,7 +1588,8 @@ declare module "util" {
value: string;
inlineValue: boolean;
}
: O["type"] extends "boolean" ? {
: O["type"] extends "boolean"
? {
kind: "option";
index: number;
name: K;
@@ -1446,20 +1603,35 @@ declare module "util" {
T extends ParseArgsConfig,
K extends keyof T["options"] = keyof T["options"],
> = K extends unknown
? T["options"] extends ParseArgsOptionsConfig ? PreciseTokenForOptions<K & string, T["options"][K]>
? T["options"] extends ParseArgsOptionsConfig
? PreciseTokenForOptions<K & string, T["options"][K]>
: OptionToken
: never;
type ParsedOptionToken<T extends ParseArgsConfig> = IfDefaultsTrue<T["strict"], TokenForOptions<T>, OptionToken>;
type ParsedOptionToken<T extends ParseArgsConfig> = IfDefaultsTrue<
T["strict"],
TokenForOptions<T>,
OptionToken
>;
type ParsedPositionalToken<T extends ParseArgsConfig> = IfDefaultsTrue<
T["strict"],
IfDefaultsFalse<T["allowPositionals"], { kind: "positional"; index: number; value: string }, never>,
IfDefaultsTrue<T["allowPositionals"], { kind: "positional"; index: number; value: string }, never>
IfDefaultsFalse<
T["allowPositionals"],
{ kind: "positional"; index: number; value: string },
never
>,
IfDefaultsTrue<
T["allowPositionals"],
{ kind: "positional"; index: number; value: string },
never
>
>;
type ParsedTokens<T extends ParseArgsConfig> = Array<
ParsedOptionToken<T> | ParsedPositionalToken<T> | { kind: "option-terminator"; index: number }
| ParsedOptionToken<T>
| ParsedPositionalToken<T>
| { kind: "option-terminator"; index: number }
>;
type PreciseParsedResults<T extends ParseArgsConfig> = IfDefaultsFalse<
@@ -1476,7 +1648,14 @@ declare module "util" {
>;
type OptionToken =
| { kind: "option"; index: number; name: string; rawName: string; value: string; inlineValue: boolean }
| {
kind: "option";
index: number;
name: string;
rawName: string;
value: string;
inlineValue: boolean;
}
| {
kind: "option";
index: number;
@@ -1493,9 +1672,14 @@ declare module "util" {
// If ParseArgsConfig extends T, then the user passed config constructed elsewhere.
// So we can't rely on the `"not definitely present" implies "definitely not present"` assumption mentioned above.
type ParsedResults<T extends ParseArgsConfig> = ParseArgsConfig extends T ? {
type ParsedResults<T extends ParseArgsConfig> = ParseArgsConfig extends T
? {
values: {
[longOption: string]: undefined | string | boolean | Array<string | boolean>;
[longOption: string]:
| undefined
| string
| boolean
| Array<string | boolean>;
};
positionals: string[];
tokens?: Token[];
@@ -1755,7 +1939,7 @@ declare module "util/types" {
* ```
* @since v10.0.0
*/
function isBooleanObject(object: unknown): object is Boolean;
function isBooleanObject(object: unknown): object is boolean;
/**
* Returns `true` if the value is any boxed primitive object, e.g. created
* by `new Boolean()`, `new String()` or `Object(Symbol())`.
@@ -1771,7 +1955,9 @@ declare module "util/types" {
* ```
* @since v10.11.0
*/
function isBoxedPrimitive(object: unknown): object is String | Number | BigInt | Boolean | Symbol;
function isBoxedPrimitive(
object: unknown,
): object is string | number | bigint | boolean | symbol;
/**
* Returns `true` if the value is a built-in [`DataView`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView) instance.
*
@@ -1924,8 +2110,11 @@ declare module "util/types" {
* @since v10.0.0
*/
function isMap<T>(
object: T | {},
): object is T extends ReadonlyMap<any, any> ? (unknown extends T ? never : ReadonlyMap<any, any>)
object: T | object,
): object is T extends ReadonlyMap<unknown, unknown>
? unknown extends T
? never
: ReadonlyMap<unknown, unknown>
: Map<unknown, unknown>;
/**
* Returns `true` if the value is an iterator returned for a built-in [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) instance.
@@ -1963,7 +2152,7 @@ declare module "util/types" {
* Subclasses of the native error types are also native errors:
*
* ```js
* class MyError extends Error {}
* class MyError extends Error object
* console.log(util.types.isNativeError(new MyError())); // true
* ```
*
@@ -1972,7 +2161,7 @@ declare module "util/types" {
*
* ```js
* const vm = require('node:vm');
* const context = vm.createContext({});
* const context = vm.createContext(object);
* const myError = vm.runInContext('new Error()', context);
* console.log(util.types.isNativeError(myError)); // true
* console.log(myError instanceof Error); // false
@@ -2000,7 +2189,7 @@ declare module "util/types" {
* ```
* @since v10.0.0
*/
function isNumberObject(object: unknown): object is Number;
function isNumberObject(object: unknown): object is number;
/**
* Returns `true` if the value is a built-in [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).
*
@@ -2041,8 +2230,12 @@ declare module "util/types" {
* @since v10.0.0
*/
function isSet<T>(
object: T | {},
): object is T extends ReadonlySet<any> ? (unknown extends T ? never : ReadonlySet<any>) : Set<unknown>;
object: T | object,
): object is T extends ReadonlySet<unknown>
? unknown extends T
? never
: ReadonlySet<unknown>
: Set<unknown>;
/**
* Returns `true` if the value is an iterator returned for a built-in [`Set`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) instance.
*
@@ -2078,7 +2271,7 @@ declare module "util/types" {
* ```
* @since v10.0.0
*/
function isStringObject(object: unknown): object is String;
function isStringObject(object: unknown): object is string;
/**
* Returns `true` if the value is a symbol object, created
* by calling `Object()` on a `Symbol` primitive.
@@ -2090,7 +2283,7 @@ declare module "util/types" {
* ```
* @since v10.0.0
*/
function isSymbolObject(object: unknown): object is Symbol;
function isSymbolObject(object: unknown): object is symbol;
/**
* Returns `true` if the value is a built-in [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) instance.
*

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +0,0 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
Raw Claim
</h1>
</div>
<div class="flex">
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
</div>
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="submitClaim()"
>
Sign &amp; Send
</button>
</section>
</template>
<script lang="ts">
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import * as serverUtil from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
@Component({
components: { GiftedDialog, QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
apiServer = "";
claimStr = "";
async mounted() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.claimStr = this.$route.query.claim;
try {
this.veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(this.veriClaim, null, 2);
} catch (e) {
// ignore a parse
}
}
async submitClaim() {
const fullClaim = JSON.parse(this.claimStr);
const result = await serverUtil.createAndSubmitClaim(
fullClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Claim submitted.",
},
5000,
);
} else {
console.error("Got error submitting the claim:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the claim. See logs for more info.",
},
-1,
);
}
}
}
</script>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div class="mb-8">
<h1
@@ -16,7 +16,7 @@
</h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Transferred with {{ contact?.name }}
Given with {{ contact?.name }}
</h1>
</div>
@@ -25,13 +25,6 @@
<span class="justify-around">(Only 50 most recent)</span>
<span />
</div>
<div class="flex justify-around">
<span />
<span class="justify-around">
(This does not include claims by them if they're not visible to you.)
</span>
<span />
</div>
<!-- Results List -->
<table
@@ -55,9 +48,9 @@
{{ new Date(record.issuedAt).toLocaleString() }}
</td>
<td class="p-1">
<span v-if="record.agentDid == contact?.did">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -71,7 +64,7 @@
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid == contact?.did">
<span v-if="record.agentDid == contact.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
</span>
<span v-else>
@@ -79,9 +72,9 @@
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid != contact?.did">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -105,42 +98,71 @@
</template>
<script lang="ts">
import { AxiosError, AxiosRequestHeaders } from "axios";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import {
AgreeVerifiableCredential,
createEndorserJwtVcFromClaim,
displayAmount,
getHeaders,
GiveSummaryRecord,
GiveServerRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import * as didJwt from "did-jwt";
import { AxiosError } from "axios";
import QuickNav from "@/components/QuickNav.vue";
import { IIdentifier } from "@veramo/core";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
export default class ContactsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
apiServer = "";
contact: Contact | null = null;
giveRecords: Array<GiveSummaryRecord> = [];
giveRecords: Array<GiveServerRecord> = [];
numAccounts = 0;
displayAmount = displayAmount;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identity available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
try {
await db.open();
@@ -148,15 +170,14 @@ export default class ContactAmountssView extends Vue {
this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings or gives.", err);
this.$notify(
{
group: "alert",
@@ -164,7 +185,7 @@ export default class ContactAmountssView extends Vue {
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings or contacts or gives.",
"There was an error retrieving the latest sweet, sweet action.",
},
-1,
);
@@ -173,14 +194,15 @@ export default class ContactAmountssView extends Vue {
async loadGives(activeDid: string, contact: Contact) {
try {
let result: Array<GiveSummaryRecord> = [];
const identity = await this.getIdentity(this.activeDid);
let result: Array<GiveServerRecord> = [];
const url =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(this.activeDid) +
encodeURIComponent(identity.did) +
"&recipientDid=" +
encodeURIComponent(contact.did);
const headers = await getHeaders(activeDid);
const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
result = resp.data.data;
@@ -206,8 +228,8 @@ export default class ContactAmountssView extends Vue {
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(contact.did) +
"&recipientDid=" +
encodeURIComponent(this.activeDid);
const headers2 = await getHeaders(activeDid);
encodeURIComponent(identity.did);
const headers2 = await this.getHeaders(identity);
const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) {
result = R.concat(result, resp2.data.data);
@@ -228,7 +250,7 @@ export default class ContactAmountssView extends Vue {
);
}
const sortedResult: Array<GiveSummaryRecord> = R.sort(
const sortedResult: Array<GiveServerRecord> = R.sort(
(a, b) =>
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
result,
@@ -247,7 +269,7 @@ export default class ContactAmountssView extends Vue {
}
}
async confirm(record: GiveSummaryRecord) {
async confirm(record: GiveServerRecord) {
// Make claim
// I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -262,21 +284,42 @@ export default class ContactAmountssView extends Vue {
object: origClaim,
};
const vcJwt: string = await createEndorserJwtVcFromClaim(
this.activeDid,
vcClaim,
);
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
const identity = await this.getIdentity(this.activeDid);
if (identity.keys[0].privateKeyHex !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success) {
record.amountConfirmed =
(origClaim.object?.amountOfThisGood as number) || 1;
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
}
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
@@ -302,6 +345,7 @@ export default class ContactAmountssView extends Vue {
);
}
}
}
cannotConfirmMessage() {
this.$notify(

View File

@@ -1,7 +1,7 @@
<template>
<QuickNav selected="Home"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -20,19 +20,19 @@
<ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center">
<span class="grow">
<img
src="../assets/blank-square.svg"
width="32"
<span class="grow italic text-slate-500"
><EntityIcon
:entityId="null"
:iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
Unnamed/Unknown
></EntityIcon>
Anonymous
</span>
<span class="text-right">
<button
type="button"
@click="openDialog()"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
@@ -45,19 +45,19 @@
class="border-b border-slate-300 py-3"
>
<h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold">
<EntityIcon
:contact="contact"
<span class="grow font-semibold"
><EntityIcon
:entityId="contact.did"
:iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
></EntityIcon>
{{ contact.name || "(no name)" }}
</span>
<span class="text-right">
<button
type="button"
@click="openDialog(contact)"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
>
<fa icon="gift" class="fa-fw"></fa>
</button>
@@ -66,37 +66,74 @@
</li>
</ul>
<GiftedDialog ref="customDialog" :projectId="projectId" />
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverInputInfo } from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
import { IIdentifier } from "@veramo/core";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class ContactGiftingView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
apiServer = "";
accounts: typeof AccountsSchema;
projectId = localStorage.getItem("projectId") || "";
numAccounts = 0;
async beforeCreate() {
accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identity available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
@@ -105,19 +142,9 @@ export default class ContactGiftingView extends Vue {
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// .orderBy("name") wouldn't retrieve any entries with a blank name
// .toCollection.sortBy("name") didn't sort in an order I understood
const baseContacts = await db.contacts.toArray();
this.allContacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
localStorage.removeItem("projectId");
this.allContacts = await db.contacts.toArray();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",
@@ -125,23 +152,15 @@ export default class ContactGiftingView extends Vue {
title: "Error",
text:
err.message ||
"There was an error retrieving your settings or contacts.",
"There was an error retrieving the latest sweet, sweet action.",
},
-1,
);
}
}
openDialog(giver?: GiverReceiverInputInfo) {
const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
);
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
}
</script>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,12 +1,11 @@
<template>
<QuickNav selected="Discover"></QuickNav>
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Discover Projects
Discover
</h1>
<!-- Quick Search -->
@@ -37,7 +36,7 @@
isRemoteActive = false;
searchLocal();
"
v-bind:class="computedLocalTabStyleClassNames()"
v-bind:class="computedLocalTabClassNames()"
>
Nearby
<span
@@ -51,13 +50,13 @@
<li>
<a
href="#"
v-bind:class="computedRemoteTabClassNames()"
@click="
projects = [];
isRemoteActive = true;
isLocalActive = false;
searchAll();
"
v-bind:class="computedRemoteTabStyleClassNames()"
>
Anywhere
<span
@@ -74,7 +73,7 @@
<div v-if="isLocalActive">
<div>
<button
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })"
>
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
@@ -100,15 +99,14 @@
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
class="block py-4 flex gap-4"
>
<div>
<ProjectIcon
<div class="w-12">
<EntityIcon
:entityId="project.handleId"
:iconSize="48"
:imageUrl="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
class="block border border-slate-300 rounded-md"
></EntityIcon>
</div>
<div class="grow">
@@ -130,33 +128,38 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
import { accessToken } from "@/libs/crypto";
import { didInfo, ProjectData } from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: {
InfiniteScroll,
ProjectIcon,
QuickNav,
TopMessage,
InfiniteScroll,
EntityIcon,
},
})
export default class DiscoverView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$notify!: (notification: Notification, timeout?: number) => void;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
searchTerms = "";
projects: PlanData[] = [];
projects: ProjectData[] = [];
isLoading = false;
isLocalActive = true;
isRemoteActive = false;
@@ -170,8 +173,8 @@ export default class DiscoverView extends Vue {
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.searchBox = settings?.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray();
@@ -202,6 +205,30 @@ export default class DiscoverView extends Vue {
}
}
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
public async searchAll(beforeId?: string) {
this.resetCounts();
@@ -222,13 +249,13 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plans?" + queryParams,
{
method: "GET",
headers: await getHeaders(this.activeDid),
headers: await this.buildHeaders(),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
console.log("Problem with full search:", details);
this.$notify(
{
group: "alert",
@@ -244,18 +271,11 @@ export default class DiscoverView extends Vue {
const results = await response.json();
const plans: PlanData[] = results.data;
const plans: ProjectData[] = 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,
});
const { name, description, handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
}
this.remoteCount = this.projects.length;
} else {
@@ -263,7 +283,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
console.log("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -312,13 +332,13 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{
method: "GET",
headers: await getHeaders(this.activeDid),
headers: await this.buildHeaders(),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with nearby search:", details);
console.log("Problem with nearby search:", details);
this.$notify(
{
group: "alert",
@@ -335,16 +355,10 @@ export default class DiscoverView extends Vue {
if (results.data) {
if (beforeId) {
const plans: PlanData[] = results.data;
const plans: ProjectData[] = results.data;
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
issuerDid,
rowid,
});
const { name, description, handleId = plan.handleId, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
}
} else {
this.projects = results.data;
@@ -355,7 +369,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
console.log("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -397,39 +411,35 @@ export default class DiscoverView extends Vue {
this.$router.push(route);
}
public computedLocalTabStyleClassNames() {
public computedLocalTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isLocalActive,
"text-black": this.isLocalActive,
"border-black": this.isLocalActive,
"text-blue-600": this.isLocalActive,
"border-blue-600": this.isLocalActive,
"font-semibold": this.isLocalActive,
"text-blue-600": !this.isLocalActive,
"border-transparent": !this.isLocalActive,
"hover:border-slate-400": !this.isLocalActive,
"hover:text-slate-600": !this.isLocalActive,
"hover:border-slate-300": !this.isLocalActive,
};
}
public computedRemoteTabStyleClassNames() {
public computedRemoteTabClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isRemoteActive,
"text-black": this.isRemoteActive,
"border-black": this.isRemoteActive,
"text-blue-600": this.isRemoteActive,
"border-blue-600": this.isRemoteActive,
"font-semibold": this.isRemoteActive,
"text-blue-600": !this.isRemoteActive,
"border-transparent": !this.isRemoteActive,
"hover:border-slate-400": !this.isRemoteActive,
"hover:text-slate-600": !this.isRemoteActive,
"hover:border-slate-300": !this.isRemoteActive,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -18,64 +18,33 @@
<!-- Identity List -->
<!-- Current Identity - Display First! -->
<div
v-if="activeDid && !activeDidInIdentities"
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4"
>
<fa icon="circle-check" class="fa-fw text-red-600 text-xl mr-3"></fa>
<div class="text-sm text-slate-500">
<div class="overflow-hidden truncate">
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4">
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0">
{{ givenName }}
</h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ activeDid }}</code>
</div>
<b
>There is a data corruption error: this identity is selected but it is
not in storage. You cannot send any more claims with this identity
until you import the seed again. This may require reinstalling the
app; if you know how, you can also clear out the TimeSafariAccounts
IndexedDB. Be sure to back up all your Settings & Contacts first.</b
>
</div>
</span>
</div>
<!-- Other Identity/ies -->
<ul class="mb-4">
<li v-for="ident in otherIdentities" :key="ident.did">
<div class="flex items-center justify-between mb-2">
<div
class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
<li
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
v-for="ident in otherIdentities"
:key="ident.did"
@click="switchAccount(ident.did)"
>
<fa
v-if="ident.did === activeDid"
icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3"
/>
<fa
v-else
icon="circle"
class="fa-fw text-slate-400 text-xl mr-3"
/>
<span class="flex-grow overflow-hidden">
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa>
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0"></h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code>
</div>
</span>
</div>
<div>
<fa
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteAccount(ident.id)"
/>
</div>
</div>
</li>
</ul>
@@ -84,13 +53,13 @@
<router-link
id="start-link"
:to="{ name: 'start' }"
class="block text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
Add Another Identity&hellip;
</router-link>
<a
href="#"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-8"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
@click="switchAccount('0')"
>
No Identity
@@ -99,21 +68,41 @@
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$notify!: (notification: Notification, timeout?: number) => void;
Constants = AppString;
public accounts: typeof AccountsSchema;
public activeDid = "";
public activeDidInIdentities = false;
public apiServer = "";
public apiServerInput = "";
public otherIdentities: Array<{ id: string; did: string }> = [];
public givenName = "";
public otherIdentities: Array<{ did: string }> = [];
public showContactGives = false;
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse((account?.identity as string) || "null");
return identity;
}
async created() {
try {
@@ -122,14 +111,24 @@ export default class IdentitySwitcherView extends Vue {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
this.showContactGives = !!settings?.showContactGivesInline;
const identity = await this.getIdentity(this.activeDid);
if (identity) {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
}
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const acct = accounts[n];
this.otherIdentities.push({ id: acct.id as string, did: acct.did });
if (acct.did && this.activeDid === acct.did) {
this.activeDidInIdentities = true;
const did = JSON.parse(accounts[n].identity)["did"];
if (did && this.activeDid !== did) {
this.otherIdentities.push({ did: did });
}
}
} catch (err) {
@@ -155,38 +154,17 @@ export default class IdentitySwitcherView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
this.$router.push({ name: "account" });
}
async deleteAccount(id: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
this.activeDid = did || "";
this.otherIdentities = [];
await accountsDB.open();
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
},
-1,
);
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"];
if (did && this.activeDid !== did) {
this.otherIdentities.push({ did: did });
}
notifyCannotDelete() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity.",
},
3000,
);
}
this.$router.push({ name: "account" });
}
}
</script>

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -17,7 +17,7 @@
<div>
<p class="text-center text-xl mb-4 font-light">
Will increment the maximum known derivation path from the existing seed.
Will increment the maximum derivation path from the existing seed.
</p>
<p v-if="didArrays.length > 1">
@@ -49,22 +49,20 @@
</ul>
</div>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="incrementDerivation()"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
Increment and Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-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"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>
@@ -74,8 +72,7 @@ import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
nextDerivationPath,
} from "@/libs/crypto";
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@@ -124,7 +121,17 @@ export default class ImportAccountView extends Vue {
}
});
// increment the last number in that max derivation path
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
let lastStr = accountWithMaxDeriv.derivationPath.split("/").slice(-1)[0];
if (lastStr.endsWith("'")) {
lastStr = lastStr.slice(0, -1);
}
const lastNum = parseInt(lastStr, 10);
const newLastNum = lastNum + 1;
const newDerivPath = accountWithMaxDeriv.derivationPath
.split("/")
.slice(0, -1)
.concat([newLastNum.toString() + "'"])
.join("/");
const mne: string = accountWithMaxDeriv.mnemonic;

View File

@@ -1,5 +1,5 @@
<template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
@@ -22,10 +22,9 @@
/>
<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"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="onClickSaveChanges()"
>
Save Changes
@@ -33,13 +32,12 @@
<!-- 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"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</section>
</template>
@@ -68,7 +66,9 @@ export default class NewEditAccountView extends Vue {
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
this.$router.back();
localStorage.setItem("firstName", this.givenName as string);
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.push({ name: "account" });
}
onClickCancel() {

View File

@@ -0,0 +1,67 @@
<template>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<router-link
:to="{ name: 'project' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa>
</router-link>
Make Commitment
</h1>
</div>
<!-- Project Details -->
<select class="block w-full rounded border border-slate-400 mb-4 px-3 py-2">
<option disabled>Choose a commitment type</option>
<option selected>Time</option>
<option>Cryptocurrency</option>
<option>Money</option>
</select>
<!-- Time amount -->
<div class="mb-4 flex items-stretch">
<input
type="number"
placeholder="0.0"
class="block w-full rounded-l border border-slate-400 px-3 py-2"
/>
<span
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>hours</span
>
</div>
<!-- Crypto amount -->
<!-- Money amount -->
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Commit"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Maybe Later
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
@Component({
components: {},
})
export default class NewEditCommitmentView extends Vue {}
</script>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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