forked from trent_larson/crowd-funder-for-time-pwa
Compare commits
204 Commits
qr-reader
...
home-infin
| Author | SHA1 | Date | |
|---|---|---|---|
| 540cc21839 | |||
| c182068901 | |||
| aaa1f31945 | |||
| 17c632eb16 | |||
| 41c4cbe61a | |||
| c8402797ad | |||
| 4a09b9b9b1 | |||
| 5db3423301 | |||
| 2b00b243e8 | |||
| f2e5d8168d | |||
| 1d262b8da9 | |||
| 8ed74b71f2 | |||
| 8fb21c3d89 | |||
| 8dbfcd38d3 | |||
| 04df0d4eff | |||
|
|
ab523639a5 | ||
|
|
0484dfb253 | ||
|
|
c2839e8a99 | ||
|
|
e533cd3d34 | ||
|
|
18e00b95c7 | ||
|
|
e97cd1b1fa | ||
| ccca93b9f1 | |||
| 1be6c04699 | |||
| 2c33febb0e | |||
| e6f73dc81c | |||
| 0d55a722c5 | |||
| 97ef78f5dd | |||
| 672abac9a9 | |||
| 0607fad3e5 | |||
| 6aa89a1d1d | |||
| 2556d5feb9 | |||
| 3c1654764c | |||
| 4c1e229d62 | |||
| 17444d75de | |||
| f2fb432d2e | |||
| e45689daed | |||
| 041308ebc9 | |||
| 9c36bb509a | |||
| 2c300614ef | |||
| 8849e8806a | |||
| f75094283a | |||
| 0fabccd410 | |||
|
|
8ddf7d9532 | ||
|
|
4078853558 | ||
|
|
f4df5ffa9a | ||
| fa856f7594 | |||
|
|
a60beb483c | ||
| a0db6433a6 | |||
| 59d0772881 | |||
| b18e554886 | |||
| 098ef3c644 | |||
| 6045975b79 | |||
| a6bb036ceb | |||
| 1e2ad85547 | |||
|
|
3e2723b744 | ||
| 4daffe8f40 | |||
| efb1922826 | |||
| c6e10bfdad | |||
| bb122be319 | |||
| 3f436476a2 | |||
| a77d20b572 | |||
| 393d1583ae | |||
| 69a25ddd6c | |||
| a12d7fcc1b | |||
| 69c60e5426 | |||
| 4806acc30e | |||
| 1127d7079b | |||
| 0bbadfec6d | |||
| 276d8b2f19 | |||
| a7fbbbd4cd | |||
| a8d362c14d | |||
| ce5933f645 | |||
| 5cbf917ada | |||
| 7335412145 | |||
| feea1a1d3b | |||
| 7f4d31a79c | |||
| 4041a7d08e | |||
|
|
9846cf3e4c | ||
| 681d949098 | |||
| 3bf8fd0c22 | |||
| fa41fb3415 | |||
| 6dbfc5f77d | |||
| 1b9ae96006 | |||
|
|
4dd5664462 | ||
|
|
7d6a45061d | ||
|
|
3b32c2b156 | ||
|
|
1ee6203f4c | ||
|
|
d93299c352 | ||
|
|
9aea7a576d | ||
| 714bb169fa | |||
| 606d9ec734 | |||
| 7a3bd069b8 | |||
| b1ac9e71cb | |||
| c1176fa24d | |||
| 1cf6660e6c | |||
| 6957678474 | |||
| 889b6d5737 | |||
| 1be10b1511 | |||
| 85405317ee | |||
| 072497a553 | |||
| 8a33ccfdcf | |||
| 7311d36726 | |||
| 7e819ea4de | |||
| 5670f23bf3 | |||
| 08d9ca3a25 | |||
| 607666a2f9 | |||
| 0a618cc4ff | |||
| e387794db3 | |||
| ab1a725c1b | |||
| 46d76013e8 | |||
| faf8f4f6a9 | |||
| 154fcd98a5 | |||
| c391385500 | |||
| b64f35869e | |||
| 45fbf7ade5 | |||
| 92fcffdfc5 | |||
| 5f5562f5e3 | |||
| 74ed025377 | |||
| f36ecfd8db | |||
| ee6a344daf | |||
| 65a5edf26b | |||
| fc70a11bd8 | |||
| 73f890beac | |||
| 67dce9e678 | |||
| 2b66ddfb83 | |||
| 56fc2893a2 | |||
|
|
552ad5a267 | ||
|
|
910f57ec7d | ||
|
|
e813315dad | ||
| aea9626c06 | |||
|
|
7f0f1b7fc8 | ||
|
|
cfc4d0a947 | ||
|
|
8684488def | ||
|
|
a820a7b131 | ||
| 30d45c0acf | |||
| 221bb2a27c | |||
| 2961e29831 | |||
| 5ae5e110c2 | |||
| 20c2954be1 | |||
| a848e1fa81 | |||
| 85bd807bcc | |||
| eeece8a1b4 | |||
| bbfc1e1007 | |||
| 433d0c023e | |||
| ac6376243b | |||
| a12f033b72 | |||
| 42cd7d00de | |||
| c388cc8cfe | |||
| 6d4d4e40c3 | |||
| 3b39faf173 | |||
| f43ecc98aa | |||
| 5b7ccf9ef0 | |||
| 9bacd4da87 | |||
|
|
ee28b18b14 | ||
| 7450d8d1c3 | |||
| 7490cfc557 | |||
| 95287e4dd0 | |||
| 679d1a70e8 | |||
| 047fb263dd | |||
| b76cf28bc2 | |||
| 58c091cdaa | |||
| 0df5a975f3 | |||
| 94051e6ba9 | |||
| 8e60f53f0b | |||
| afc48a5434 | |||
| 6eb3381a98 | |||
| 2bec218cc5 | |||
| 327c655fb3 | |||
| 866aad069f | |||
| 7f6c938029 | |||
| 6d2df4a50c | |||
| 7305606546 | |||
| 2a9ff8aa77 | |||
| 829994491c | |||
| ce06e8f0fa | |||
| 1ee751eea8 | |||
|
|
2d38183dce | ||
|
|
082a6eae1f | ||
|
|
d07fb47721 | ||
|
|
ccb6160bca | ||
| 116b239616 | |||
|
|
2eaa4203aa | ||
|
|
f27a18c712 | ||
| f47346cc35 | |||
|
|
2c4a920c3c | ||
| 0e02268950 | |||
| 94d9c425ad | |||
|
|
ed91cadd9d | ||
|
|
a6de282aec | ||
| 2db662c125 | |||
| b7892f4dfa | |||
|
|
3bbb138299 | ||
|
|
5b5c631001 | ||
|
|
e60b56a0b0 | ||
|
|
d3e025c293 | ||
|
|
6f4027f614 | ||
| 249811efe3 | |||
| bd2455458f | |||
| a053c48819 | |||
| 9486142b2a | |||
|
|
2fba7f2a55 | ||
|
|
31d13b9143 | ||
|
|
852bd93f3f | ||
| b707bfce40 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules
|
||||
signature.bin
|
||||
*.pem
|
||||
verified.txt
|
||||
myenv
|
||||
|
||||
*~
|
||||
# local env files
|
||||
|
||||
41
CHANGELOG.md
Normal file
41
CHANGELOG.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
## [0.1.3] - 2023.11.08 - 910f57ec7d2e50803ae3d04f4b927e0f5219fbde
|
||||
### Added
|
||||
- Contact name editing
|
||||
### Changed
|
||||
- Don't show actions on front page if not registered.
|
||||
### Removed
|
||||
- Home page Notiwind test buttons
|
||||
|
||||
|
||||
## [0.1.2] - 2023.11.01 - 7f6c93802911a030a89fe3706e18b5c17151e5bb
|
||||
### Added
|
||||
- Basics: create ID, record a give, declare a project, search, and get notifications.
|
||||
211
README.md
211
README.md
@@ -1,6 +1,9 @@
|
||||
# kickstart-for-time-pwa
|
||||
# TimeSafari.app - Crowd-Funder for Time - PWA
|
||||
|
||||
## Project setup
|
||||
|
||||
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
@@ -10,43 +13,44 @@ npm install
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Test key contents
|
||||
### Compiles and minifies for production
|
||||
|
||||
See [this page](openssl_signing_console.rst)
|
||||
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/`
|
||||
|
||||
...to make sure the service worker scripts are in proper form
|
||||
|
||||
* Update the CHANGELOG.md & the version in package.json, run `npm install`, and commit. Tag wth the new version: `git tag 0.1.0`
|
||||
|
||||
* If production, change src/constants/app.ts DEFAULT_*_SERVER to be PROD.
|
||||
|
||||
* `npm run build`
|
||||
|
||||
* Revert src/constants/app.ts & change version to "-beta"
|
||||
|
||||
* `cp sw_scripts/[ns]* dist/`
|
||||
|
||||
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
|
||||
|
||||
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntu@endorser.ch:time-safari`
|
||||
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
### Register new user on test server
|
||||
|
||||
New users require registration. This can be done with a claim payload like this
|
||||
by an existing user:
|
||||
|
||||
```
|
||||
const vcClaim = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "RegisterAction",
|
||||
agent: { identifier: identity0.did },
|
||||
object: SERVICE_ID,
|
||||
participant: { identifier: newIdentity.did },
|
||||
};
|
||||
```
|
||||
|
||||
On the test server, User #0 has rights to register others, so you can start
|
||||
playing one of two ways:
|
||||
|
||||
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing this seed phrase:
|
||||
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
|
||||
- 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:
|
||||
@@ -57,14 +61,39 @@ playing one of two ways:
|
||||
|
||||
### Create multiple identifiers
|
||||
|
||||
Go to /start and create or import a new one. Then switch identifiers on the bottom of the Your Identity page.
|
||||
Under the "Your Identity" screen, click "Advanced", click "Switch Identity / No Identity", then "Add Another Identity...".
|
||||
|
||||
### Create keys with alternate tools
|
||||
|
||||
See [this page](openssl_signing_console.rst)
|
||||
[This page](openssl_signing_console.rst) is a tool to create a JWT from a locally-generated keypair.
|
||||
|
||||
### Web-push
|
||||
|
||||
For your own web-push tests, change the push server URL in Advanced settings on the account page, and install Time Safari & push server on the same domain.
|
||||
|
||||
### Icons
|
||||
|
||||
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
||||
|
||||
### Manual walk-through
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Customize Vue configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
|
||||
|
||||
## Scenarios
|
||||
@@ -72,28 +101,22 @@ See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
- 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:
|
||||
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
|
||||
`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 data & restart
|
||||
### Clear/Reset data & restart
|
||||
|
||||
Clear cache for localhost, then go to http://localhost:8080/start
|
||||
(because it'll generate a new one automatically if you start on the `/account` page).
|
||||
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data".)
|
||||
* Unregister service worker (in Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers`).
|
||||
* Clear notification permission (in Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications").
|
||||
* Clear Cache Storage (in Chrome, in dev tools under Application; in Firefox, in dev tools under Storage).
|
||||
|
||||
|
||||
|
||||
## Dependencies
|
||||
|
||||
See https://tea.xyz
|
||||
|
||||
| Project | Version |
|
||||
| ---------- | --------- |
|
||||
| nodejs.org | ^16.0.0 |
|
||||
| npmjs.com | ^8.0.0 |
|
||||
|
||||
## Other
|
||||
|
||||
@@ -102,110 +125,10 @@ See https://tea.xyz
|
||||
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
|
||||
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
|
||||
|
||||
```
|
||||
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
|
||||
* [Customize Vue configuration](https://cli.vuejs.org/config/).
|
||||
|
||||
// Import an existing ID
|
||||
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
|
||||
|
||||
// just to get rid of variability that might cause an error
|
||||
mnemonic = mnemonic.trim().toLowerCase()
|
||||
|
||||
/**
|
||||
// an approach I pieced together
|
||||
// requires: yarn add elliptic
|
||||
// ... plus:
|
||||
// const EC = require('elliptic').ec
|
||||
// const secp256k1 = new EC('secp256k1')
|
||||
//
|
||||
const keyHex: string = bip39.mnemonicToEntropy(mnemonic)
|
||||
// returns a KeyPair from the elliptic.ec library
|
||||
const keyPair = secp256k1.keyFromPrivate(keyHex, 'hex')
|
||||
// this code is from did-provider-eth createIdentifier
|
||||
const privateHex = keyPair.getPrivate('hex')
|
||||
const publicHex = keyPair.getPublic('hex')
|
||||
const address = didJwt.toEthereumAddress(publicHex)
|
||||
**/
|
||||
|
||||
/**
|
||||
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
|
||||
// ... which almost works but the didJwt.toEthereumAddress is wrong
|
||||
// requires: yarn add bip32
|
||||
// ... plus: import * as bip32 from 'bip32'
|
||||
//
|
||||
const seed: Buffer = await bip39.mnemonicToSeed(mnemonic)
|
||||
const root = bip32.fromSeed(seed)
|
||||
const node = root.derivePath(UPORT_ROOT_DERIVATION_PATH)
|
||||
const privateHex = node.privateKey.toString("hex")
|
||||
const publicHex = node.publicKey.toString("hex")
|
||||
const address = didJwt.toEthereumAddress('0x' + publicHex)
|
||||
**/
|
||||
|
||||
/**
|
||||
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
|
||||
// requires: yarn add @ethersproject/hdnode
|
||||
// ... plus: import { HDNode } from '@ethersproject/hdnode'
|
||||
**/
|
||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic)
|
||||
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH)
|
||||
const privateHex = rootNode.privateKey.substring(2) // original starts with '0x'
|
||||
const publicHex = rootNode.publicKey.substring(2) // original starts with '0x'
|
||||
let address = rootNode.address
|
||||
|
||||
const prevIds = previousIdentifiers || [];
|
||||
|
||||
if (toLowercase) {
|
||||
const foundEqual = R.find(
|
||||
(id) => utility.rawAddressOfDid(id.did) === address,
|
||||
prevIds
|
||||
)
|
||||
if (foundEqual) {
|
||||
// They're trying to create a lowercase version of one that exists in normal case.
|
||||
// (We really should notify the user.)
|
||||
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a normal-case version of the DID since a regular version exists."}))
|
||||
} else {
|
||||
address = address.toLowerCase()
|
||||
}
|
||||
} else {
|
||||
// They're not trying to convert to lowercase.
|
||||
const foundLower = R.find((id) =>
|
||||
utility.rawAddressOfDid(id.did) === address.toLowerCase(),
|
||||
prevIds
|
||||
)
|
||||
if (foundLower) {
|
||||
// They're trying to create a normal case version of one that exists in lowercase.
|
||||
// (We really should notify the user.)
|
||||
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a lowercase version of the DID since a lowercase version exists."}))
|
||||
address = address.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... derived keys and address..."}))
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, UPORT_ROOT_DERIVATION_PATH)
|
||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... created new ID..."}))
|
||||
|
||||
// awaiting because otherwise the UI may not see that a mnemonic was created
|
||||
const savedId = await storeIdentifier(newId, mnemonic, mnemonicPassword)
|
||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... stored new ID..."}))
|
||||
return savedId
|
||||
}
|
||||
|
||||
// Create a totally new ID
|
||||
export const createAndStoreIdentifier = async (mnemonicPassword) => {
|
||||
|
||||
// This doesn't give us the entropy/seed.
|
||||
//const id = await agent.didManagerCreate()
|
||||
|
||||
const entropy = crypto.randomBytes(32)
|
||||
const mnemonic = bip39.entropyToMnemonic(entropy)
|
||||
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... generated mnemonic..."}))
|
||||
|
||||
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
|
||||
}
|
||||
```
|
||||
|
||||
## Kudos
|
||||
### Kudos
|
||||
|
||||
Gifts make the world go 'round!
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
Prerequisites:
|
||||
JWT Creation & Verification
|
||||
|
||||
jq
|
||||
To run this in a script, see ./openssl_signing_console.sh
|
||||
|
||||
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
||||
Prerequisites: openssl, jq
|
||||
|
||||
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using
|
||||
a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
||||
|
||||
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
|
||||
|
||||
@@ -15,20 +18,22 @@ openssl ec -in private.pem -pubout -out public.pem
|
||||
|
||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
||||
|
||||
Next, create a payload object as a JSON object containing the claims you want to include in the JWT. For example schema.org :
|
||||
Next, create a payload object as a JSON object containing the claims you want to include in the JWT.
|
||||
For example schema.org :
|
||||
|
||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
||||
|
||||
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
|
||||
|
||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
|
||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
|
||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
|
||||
Concatenate the encoded header, payload, and a secret to create the signing input:
|
||||
|
||||
signing_input="$header_b64.$payload_b64"
|
||||
|
||||
Create the signature by signing the signing input with a ES256K algorithm and your secret. You can use the openssl command line utility to do this:
|
||||
Create the signature by signing the signing input with a ES256K algorithm and your secret.
|
||||
You can use the openssl command line utility to do this:
|
||||
|
||||
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
|
||||
|
||||
@@ -43,7 +48,7 @@ Authorization: Bearer $jwt
|
||||
|
||||
To verify the JWT, you can use the openssl utility with the public key:
|
||||
|
||||
openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||
|
||||
This will verify the signature and output Verified OK if the signature is valid. If the signature is not valid, it will output an error.
|
||||
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature")
|
||||
|
||||
This will verify the signature and output "Verified OK" if the signature is valid.
|
||||
If the signature is not valid, it will give an error response and output "Verification failure".
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Generate a JWT, with signature verified using OpenSSL
|
||||
#
|
||||
# Prerequisites: openssl, jq
|
||||
#
|
||||
# Usage: source ./openssl_signing_console.sh
|
||||
#
|
||||
# For a more complete explanation, see ./openssl_signing_console.rst
|
||||
|
||||
|
||||
# Generate a key and extract the public part
|
||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
||||
openssl ec -in private.pem -pubout -out public.pem
|
||||
|
||||
# Use test data
|
||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
||||
|
||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
||||
|
||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
|
||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
|
||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
|
||||
signing_input="$header_b64.$payload_b64"
|
||||
|
||||
echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem -out signature.bin
|
||||
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem | openssl base64 -e)
|
||||
|
||||
# Read binary signature from file and encode it to Base64 URL-Safe format
|
||||
signature_b64=$(base64 -w 0 < signature.bin | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Read binary signature and encode it to Base64 URL-Safe format
|
||||
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
|
||||
# Construct the JWT
|
||||
jwt="$signing_input.$signature_b64"
|
||||
|
||||
openssl dgst -sha256 -verify public.pem -signature signature.bin -out verified.txt <(echo -n "$signing_input")
|
||||
|
||||
|
||||
echo Resulting JWT: $jwt
|
||||
|
||||
24624
package-lock.json
generated
24624
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kickstart-for-time-pwa",
|
||||
"version": "0.1.0",
|
||||
"name": "TimeSafari",
|
||||
"version": "0.1.6-beta",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
@@ -9,58 +9,61 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@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",
|
||||
"@tweenjs/tween.js": "^21.0.0",
|
||||
"@veramo/core": "^5.2.0",
|
||||
"@veramo/credential-w3c": "^5.2.0",
|
||||
"@veramo/data-store": "^5.2.0",
|
||||
"@veramo/did-manager": "^5.1.2",
|
||||
"@veramo/did-provider-ethr": "^5.1.2",
|
||||
"@veramo/did-resolver": "^5.2.0",
|
||||
"@veramo/key-manager": "^5.1.2",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"@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",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.5.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"core-js": "^3.31.1",
|
||||
"core-js": "^3.32.1",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-export-import": "^4.0.7",
|
||||
"did-jwt": "^7.2.4",
|
||||
"ethereum-cryptography": "^2.0.0",
|
||||
"did-jwt": "^7.2.7",
|
||||
"ethereum-cryptography": "^2.1.2",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"ethr-did-resolver": "^8.0.0",
|
||||
"ethr-did-resolver": "^8.1.2",
|
||||
"git-describe": "^4.1.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"localstorage-slim": "^2.4.0",
|
||||
"luxon": "^3.3.0",
|
||||
"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.1.0",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"ramda": "^0.29.0",
|
||||
"readable-stream": "^4.4.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"three": "^0.154.0",
|
||||
"three": "^0.156.1",
|
||||
"util": "^0.12.5",
|
||||
"vue": "^3.3.4",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "^2.1.20",
|
||||
"vue-router": "^4.2.3",
|
||||
"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/leaflet": "^1.9.4",
|
||||
"@types/ramda": "^0.29.3",
|
||||
"@types/three": "^0.152.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"@types/three": "^0.155.1",
|
||||
"@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",
|
||||
@@ -70,15 +73,15 @@
|
||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0-alpha.1",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"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.24",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "~5.1.6"
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.1.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "~5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,105 @@
|
||||
|
||||
tasks:
|
||||
- fix "any" warnings
|
||||
- fix missing updateAllFeed in ContactGiftingView page
|
||||
- check that Anonymous users jdenticon are nulls (not "Anonymous")
|
||||
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
|
||||
- .2 bug - on contacts view, click on "to" & "from" and nothing happens
|
||||
- 40 notifications :
|
||||
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
|
||||
|
||||
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
|
||||
- 08 notifications :
|
||||
- get it to work on Android
|
||||
- get it to work on iOS
|
||||
- lock down regenerate_vapid endpoint (so only we admins can do it on demand)
|
||||
- make the app behave correctly when App Notifications are turned off
|
||||
- remove sleep in py-push-server app.py?
|
||||
- write troubleshooting docs for notifications
|
||||
- make the "App Notifications" toggle on when they turn notifications on
|
||||
- make the "App Notifications" toggle off when they turn notifications off
|
||||
- in py-push-server, when sending a push to a subscriber and we get on a 410 "error #106", delete the subscription record
|
||||
- https://gitea.anomalistdesign.com/trent_larson/py-push-server/pulls/3/files
|
||||
- remove "notification push server" advanced setting since it only makes sense on the current domain
|
||||
- prompt user to install on their home screen
|
||||
|
||||
- 01 fix the Discovery map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
|
||||
- back-and-forth on discovery & project pages led to "You need an identity to load your projects." error on product page when I had an identity
|
||||
- fix the projects on /discover to show the issuer (currently all "Someone Anonymous")
|
||||
|
||||
- 08 Scan QR code to import into contacts assignee:matthew
|
||||
- SEE: https://github.com/gruhn/vue-qrcode-reader
|
||||
- .3 bug - make or edit a project, choose "Include location", and see the map display shows on top of the bottom icons assignee-group:ui
|
||||
|
||||
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
|
||||
- .5 Add infinite scroll to gifts on the home page
|
||||
|
||||
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) assignee-group:ui
|
||||
- SEE: https://github.com/emmanuelsw/notiwind assignee:jose assignee-group:ui
|
||||
- .5 If notifications are not enabled, add message to front page with link/button to enable
|
||||
|
||||
- Home Feed & Quick Give screen :
|
||||
- 01 save the feed-viewed status in settings storage ("afterQuery")
|
||||
- 01 quick action - send action, maybe choose via canvas tool
|
||||
- SEE: https://github.com/konvajs/vue-konva
|
||||
- 01 server - show all claim details when issued by the issuer
|
||||
- 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
|
||||
|
||||
- 24 Move to Vite assignee:matthew
|
||||
|
||||
- .2 Edit Plan does not have icons across the bottom assignee-group:ui
|
||||
- .5 include the hash of the latest commit, and maybe a version
|
||||
- .5 add link to further project / people when a project pays ahead
|
||||
- .5 add project ID to the URL, to make a project publicly-accessible
|
||||
- .5 remove edit from project page for projects owned by others
|
||||
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
|
||||
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
|
||||
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
|
||||
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
|
||||
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
|
||||
- Discuss whether the remaining tasks are worthwhile before MVP release.
|
||||
|
||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
||||
- .5 change the derivation path, and regenerate test IDs
|
||||
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
|
||||
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
|
||||
- .5 customize favicon assignee-group:ui
|
||||
- .5 Do we want to combine first name & last name?
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
|
||||
- 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 assignee-group:ui
|
||||
|
||||
- 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
|
||||
- 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)
|
||||
- show VC details... somehow:
|
||||
- 01 show my VCs - most interesting, or via search
|
||||
- 01 allow download of each VC (& confirmations, to show that they actually own their data)
|
||||
- 04 allow user to download VCs, mine + ones I can see about me from others
|
||||
- add VC confirmation?
|
||||
|
||||
- Release Minimum Viable Product :
|
||||
- .5 deploy endorser.ch server above Dec 1 (to get plan searches by names as well as descriptions)
|
||||
- 08 thorough testing for errors & edge cases
|
||||
- 01 ensure ability to recover server remotely, and add redundant access
|
||||
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
|
||||
- Add disclaimers.
|
||||
- Switch default server to the public server.
|
||||
- Deploy to a server.
|
||||
- Ensure public server has limits that work for group adoption.
|
||||
- Test PWA features on Android and iOS.
|
||||
- Other features - donation vs give, show offers, show give & outstanding totals, show network view, restrict registration, connect to contacts
|
||||
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
|
||||
|
||||
- remove 'rowid' references (that are sqlite-specific)
|
||||
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
|
||||
- 01 make the prod build copy the sw_scripts
|
||||
- 01 send visibility signal as a VC and store it
|
||||
- .5 Add start date to project
|
||||
- .3 check that Android shows "back" buttons on screens without bottom tray
|
||||
- .1 Make give description text box into something that expands as they type?
|
||||
- .5 customize favicon assignee-group:ui
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?)
|
||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
|
||||
- .5 Display a more appealing confirmation on the map when erasing the marker
|
||||
- .5 include the hash of the latest commit on help page next to version (maybe Trent's git-hash branch)
|
||||
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
|
||||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
|
||||
- switch some checks for activeDid to check for isRegistered
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
|
||||
|
||||
- 04 fix lack of initial notification in Firefox (on MacOS, maybe others)
|
||||
|
||||
- contacts v+ :
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
- 01 parse input more robustly (with CSV lib and not commas)
|
||||
- stats v1 :
|
||||
- 01 show numeric stats
|
||||
- 04 show different graphic for projects vs people (gnome?) on world
|
||||
- 01 link to world for specific stats
|
||||
- .5 don't load another instance of a bush if it already exists
|
||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
|
||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||
|
||||
- .5 show seed phrase in a QR code for transfer to another device
|
||||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
|
||||
- .5 don't show "Offer" on project screen if they aren't registered
|
||||
|
||||
- 24 Move to Vite
|
||||
- 32 accept images for projects
|
||||
- 32 accept images for contacts
|
||||
|
||||
- linking between projects or plans :
|
||||
- show total time given to & from a project
|
||||
- terminology:
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
||||
|
||||
- .5 add "back" button to all screens that aren't part of the bottom tray
|
||||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
|
||||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
|
||||
|
||||
- Stats :
|
||||
- 01 point out user's location on the world
|
||||
- 01 present a credential selected from the stats
|
||||
@@ -90,23 +111,24 @@ tasks:
|
||||
- automated tests, eg. cypress
|
||||
|
||||
- Notifications (wake on the phone, push notifications)
|
||||
- pull instead of push, maybe via scheduled runs
|
||||
- have a notification pop-up on Mac screen
|
||||
|
||||
- Connect with phone contacts
|
||||
|
||||
- Multiple identities
|
||||
|
||||
- Peer DID
|
||||
|
||||
- DIDComm
|
||||
|
||||
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
|
||||
- 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.
|
||||
|
||||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections
|
||||
|
||||
log:
|
||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.2 KiB |
432
src/App.vue
432
src/App.vue
@@ -157,27 +157,93 @@
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">
|
||||
Would you like to turn on notifications for this app?
|
||||
Would you like to <b>turn on</b> notifications for this app?
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
close(notification.id);
|
||||
turnOnNotifications();
|
||||
"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
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 px-2 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-mute'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">Mute app notifications:</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 1 Hour
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 8 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 24 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Until I turn it back on
|
||||
</button>
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-off'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">
|
||||
Would you like to <b>turn off</b> notifications for this app?
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Leave it On
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,4 +255,340 @@
|
||||
|
||||
<style></style>
|
||||
|
||||
<script lang="ts"></script>
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import axios from "axios";
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface ServiceWorkerResponse {
|
||||
// Define the properties and their types
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Example interface for error
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
// Other properties as needed
|
||||
}
|
||||
|
||||
interface VapidResponse {
|
||||
data: {
|
||||
vapidKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class App extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
b64 = "";
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
let pushUrl: string = AppString.DEFAULT_PUSH_SERVER;
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
}
|
||||
|
||||
await axios
|
||||
.get(pushUrl + "/web-push/vapid")
|
||||
.then((response: VapidResponse) => {
|
||||
this.b64 = response.data?.vapidKey || "";
|
||||
console.log("Got vapid key:", this.b64);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
console.log("New service worker is now controlling the page");
|
||||
});
|
||||
});
|
||||
if (!this.b64) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Could not set notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.location.host.startsWith("localhost")) {
|
||||
console.log(
|
||||
"Ignoring this error getting VAPID for local development:",
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
console.error("Got an error initializing notifications:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Got an error setting notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessageToServiceWorker(
|
||||
message: ServiceWorkerMessage,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const messageChannel = new MessageChannel();
|
||||
|
||||
messageChannel.port1.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.error) {
|
||||
reject(event.data.error as ErrorResponse);
|
||||
} else {
|
||||
resolve(event.data as ServiceWorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.controller.postMessage(message, [
|
||||
messageChannel.port2,
|
||||
]);
|
||||
} else {
|
||||
reject("Service worker controller not available");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private askPermission(): Promise<NotificationPermission> {
|
||||
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
|
||||
return Promise.reject("Service worker not available.");
|
||||
}
|
||||
|
||||
const secret = localStorage.getItem("secret");
|
||||
if (!secret) {
|
||||
return Promise.reject("No secret found.");
|
||||
}
|
||||
|
||||
return this.sendSecretToServiceWorker(secret)
|
||||
.then(() => this.checkNotificationSupport())
|
||||
.then(() => this.requestNotificationPermission())
|
||||
.catch((error) => Promise.reject(error));
|
||||
}
|
||||
|
||||
private sendSecretToServiceWorker(secret: string): Promise<void> {
|
||||
const message: ServiceWorkerMessage = {
|
||||
type: "SEND_LOCAL_DATA",
|
||||
data: secret,
|
||||
};
|
||||
|
||||
return this.sendMessageToServiceWorker(message).then((response) => {
|
||||
console.log("Response from service worker:", response);
|
||||
});
|
||||
}
|
||||
|
||||
private checkNotificationSupport(): Promise<void> {
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support notifications.");
|
||||
return Promise.reject("This browser does not support notifications.");
|
||||
}
|
||||
if (Notification.permission === "granted") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
return Notification.requestPermission().then((permission) => {
|
||||
if (permission !== "granted") {
|
||||
alert(
|
||||
"Allow this app permission to make notifications for personal reminders." +
|
||||
" You can adjust them at any time in your settings.",
|
||||
);
|
||||
throw new Error("We weren't granted permission.");
|
||||
}
|
||||
return permission;
|
||||
});
|
||||
}
|
||||
|
||||
async turnOnNotifications() {
|
||||
return this.askPermission()
|
||||
.then((permission) => {
|
||||
console.log("Permission granted:", permission);
|
||||
|
||||
// Call the function and handle promises
|
||||
this.subscribeToPush()
|
||||
.then(() => {
|
||||
console.log("Subscribed successfully.");
|
||||
return navigator.serviceWorker.ready;
|
||||
})
|
||||
.then((registration) => {
|
||||
return registration.pushManager.getSubscription();
|
||||
})
|
||||
.then((subscription) => {
|
||||
if (subscription) {
|
||||
return this.sendSubscriptionToServer(subscription);
|
||||
} else {
|
||||
throw new Error("Subscription object is not available.");
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Subscription data sent to server.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Subscription or server communication failed:",
|
||||
error,
|
||||
);
|
||||
alert(
|
||||
"Subscription or server communication failed. Try again in a while.",
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"An error occurred setting notification permissions:",
|
||||
error,
|
||||
);
|
||||
alert(
|
||||
"Some error occurred setting notification permissions. See logs.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
private subscribeToPush(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!("serviceWorker" in navigator && "PushManager" in window)) {
|
||||
const errorMsg = "Push messaging is not supported";
|
||||
console.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
const errorMsg = "Notification permission not granted";
|
||||
console.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
|
||||
const options: PushSubscriptionOptions = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey,
|
||||
};
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
return registration.pushManager.subscribe(options);
|
||||
})
|
||||
.then((subscription) => {
|
||||
console.log("Push subscription successful:", subscription);
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Subscription or server communication failed:",
|
||||
error,
|
||||
options,
|
||||
);
|
||||
|
||||
// Inform the user about the issue
|
||||
alert(
|
||||
"We encountered an issue setting up push notifications. " +
|
||||
"If you wish to revoke notification permissions, please do so in your browser settings.",
|
||||
);
|
||||
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendSubscriptionToServer(
|
||||
subscription: PushSubscription,
|
||||
): Promise<void> {
|
||||
console.log("About to send subscription", subscription);
|
||||
return fetch("/web-push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send subscription to server");
|
||||
}
|
||||
console.log("Subscription sent to server successfully.");
|
||||
});
|
||||
}
|
||||
|
||||
async turnOffNotifications() {
|
||||
let subscription;
|
||||
const pushProviderSuccess = await navigator.serviceWorker.ready
|
||||
.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;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("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.log("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") +
|
||||
".",
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 85 KiB |
@@ -5,13 +5,26 @@
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { toSvg } from "jdenticon";
|
||||
|
||||
const BLANK_CONFIG = {
|
||||
lightness: {
|
||||
color: [1.0, 1.0],
|
||||
grayscale: [1.0, 1.0],
|
||||
},
|
||||
saturation: {
|
||||
color: 0.0,
|
||||
grayscale: 0.0,
|
||||
},
|
||||
backColor: "#0000",
|
||||
};
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = 0;
|
||||
|
||||
generateIdenticon() {
|
||||
const svgString = toSvg(this.entityId, this.iconSize);
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ message }} {{ giver?.name || "somebody not specified" }}
|
||||
{{ message }} {{ giver?.name || "somebody not named" }}
|
||||
</h1>
|
||||
<input
|
||||
type="text"
|
||||
@@ -10,21 +10,24 @@
|
||||
placeholder="What was received"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row mb-6">
|
||||
<div class="flex flex-row">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
|
||||
>Hours</span
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ UNIT_SHORT[unitCode] }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="decrement()"
|
||||
v-if="amountInput !== '0'"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="hours"
|
||||
v-model="amountInput"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@@ -33,7 +36,13 @@
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
|
||||
<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
|
||||
</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
@@ -51,56 +60,281 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Emit } from "vue-facing-decorator";
|
||||
import { GiverInputInfo, GiverOutputInfo } from "@/libs/endorserServer";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { createAndSubmitGive, GiverInputInfo } from "@/libs/endorserServer";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
@Prop message = "";
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
giver?: GiverInputInfo;
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
@Prop showGivenToUser = false;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
giver?: GiverInputInfo; // undefined means no identified giver agent
|
||||
description = "";
|
||||
hours = "0";
|
||||
givenToUser = false;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
UNIT_SHORT: Record<string, string> = {
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
"HUR": "Hours",
|
||||
"USD": "US $",
|
||||
};
|
||||
/* eslint-enable prettier/prettier */
|
||||
|
||||
/* 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 || "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.message ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-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;
|
||||
}
|
||||
|
||||
close() {
|
||||
// close the dialog but don't change values (since it might be submitting info)
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
confirm(): GiverOutputInfo {
|
||||
const result = {
|
||||
action: "confirm",
|
||||
giver: this.giver,
|
||||
hours: parseFloat(this.hours),
|
||||
description: this.description,
|
||||
};
|
||||
cancel() {
|
||||
this.close();
|
||||
this.eraseValues();
|
||||
}
|
||||
|
||||
eraseValues() {
|
||||
this.description = "";
|
||||
this.giver = undefined;
|
||||
this.hours = "0";
|
||||
|
||||
return result;
|
||||
this.givenToUser = this.showGivenToUser;
|
||||
this.amountInput = "0";
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
cancel(): GiverOutputInfo {
|
||||
const result = { action: "cancel" };
|
||||
async confirm() {
|
||||
this.close();
|
||||
return result;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the give...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordGive(
|
||||
this.giver?.did as string | undefined,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
).then(() => {
|
||||
this.eraseValues();
|
||||
});
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records for DID ${activeDid} but no identity was found",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
*/
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
amountInput?: number,
|
||||
unitCode?: string,
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
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,
|
||||
identity,
|
||||
giverDid,
|
||||
this.givenToUser ? this.activeDid : undefined,
|
||||
description,
|
||||
amountInput,
|
||||
unitCode,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("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 gift was recorded.",
|
||||
},
|
||||
7000,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give recordation caught:", error);
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
317
src/components/OfferDialog.vue
Normal file
317
src/components/OfferDialog.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Description, prerequisites, terms, etc."
|
||||
v-model="description"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
Hours
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="hours"
|
||||
/>
|
||||
<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 flex-row mb-6">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Expiration
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
|
||||
v-model="expirationDateInput"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { createAndSubmitOffer } from "@/libs/endorserServer";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class OfferDialog extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
description = "";
|
||||
expirationDateInput = "";
|
||||
hours = "0";
|
||||
visible = false;
|
||||
|
||||
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 || "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.message ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close();
|
||||
this.description = "";
|
||||
this.hours = "0";
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
this.close();
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the offer...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
this.recordOffer(
|
||||
this.description,
|
||||
parseFloat(this.hours),
|
||||
this.expirationDateInput,
|
||||
).then(() => {
|
||||
this.description = "";
|
||||
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
|
||||
*/
|
||||
public async recordOffer(
|
||||
description?: string,
|
||||
hours?: number,
|
||||
expirationDateInput?: string,
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record an offer.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
description,
|
||||
hours,
|
||||
expirationDateInput,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isOfferCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
console.log("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the offer.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That offer was recorded.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with offer recordation caught:", error);
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the offer.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isOfferCreationError(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
|
||||
getOfferCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -1,12 +1,31 @@
|
||||
/**
|
||||
* Generic strings that could be used throughout the app.
|
||||
*
|
||||
* See also ../libs/veramo/setup.ts
|
||||
*/
|
||||
export enum AppString {
|
||||
APP_NAME = "Kick-Start with Time",
|
||||
APP_NAME = "Time Safari",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
|
||||
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
|
||||
|
||||
PROD_PUSH_SERVER = "https://timesafari.app",
|
||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||
|
||||
DEFAULT_PUSH_SERVER = TEST1_PUSH_SERVER,
|
||||
}
|
||||
|
||||
/**
|
||||
* The possible values for "group" and "type" are in App.vue.
|
||||
* From the notiwind package
|
||||
*/
|
||||
export interface NotificationIface {
|
||||
group: string; // "alert" | "modal"
|
||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -9,61 +9,44 @@ import {
|
||||
} from "./tables/settings";
|
||||
import { AppString } from "@/constants/app";
|
||||
|
||||
// a separate DB because the seed is super-sensitive data
|
||||
type SensitiveTables = {
|
||||
accounts: Table<Account>;
|
||||
};
|
||||
|
||||
// Define types for tables that hold sensitive and non-sensitive data
|
||||
type SensitiveTables = { accounts: Table<Account> };
|
||||
type NonsensitiveTables = {
|
||||
contacts: Table<Contact>;
|
||||
settings: Table<Settings>;
|
||||
};
|
||||
|
||||
/**
|
||||
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
|
||||
* https://typescript-eslint.io/rules/no-unnecessary-type-constraint/
|
||||
*
|
||||
* and change *any* to *unknown*
|
||||
*
|
||||
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
|
||||
*/
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = Object.assign({}, AccountsSchema);
|
||||
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
|
||||
// Initialize Dexie databases for sensitive and non-sensitive data
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = { ...AccountsSchema };
|
||||
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
|
||||
const NonsensitiveSchemas = { ...ContactsSchema, ...SettingsSchema };
|
||||
|
||||
/**
|
||||
* Needed to enable a special webpack setting to allow *await* below:
|
||||
* https://stackoverflow.com/questions/72474803/error-the-top-level-await-experiment-is-not-enabled-set-experiments-toplevelaw
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create password and place password in localStorage.
|
||||
*
|
||||
* It's good practice to keep the data encrypted at rest, so we'll do that even
|
||||
* if the secret is stored right next to the app.
|
||||
*/
|
||||
// Manage the encryption key. If not present in localStorage, create and store it.
|
||||
const secret =
|
||||
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
|
||||
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
||||
|
||||
if (localStorage.getItem("secret") == null) {
|
||||
localStorage.setItem("secret", secret);
|
||||
}
|
||||
|
||||
// Apply encryption to the sensitive database using the secret key
|
||||
encrypted(accountsDB, { secretKey: secret });
|
||||
accountsDB.version(1).stores(SensitiveSchemas);
|
||||
|
||||
// Define the schema for our databases
|
||||
accountsDB.version(1).stores(SensitiveSchemas);
|
||||
db.version(1).stores(NonsensitiveSchemas);
|
||||
|
||||
// initialize, a la https://dexie.org/docs/Tutorial/Design#the-populate-event
|
||||
db.on("populate", function () {
|
||||
// ensure there's an initial entry for settings
|
||||
// Event handler to initialize the non-sensitive database with default settings
|
||||
db.on("populate", () => {
|
||||
db.settings.add({
|
||||
id: MASTER_SETTINGS_KEY,
|
||||
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,
|
||||
|
||||
// remember that things you add from now on aren't automatically in the DB for old users
|
||||
webPushServer: AppString.DEFAULT_PUSH_SERVER,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
/**
|
||||
* Represents an account stored in the database.
|
||||
*/
|
||||
export type Account = {
|
||||
id?: number; // auto-generated by Dexie
|
||||
/**
|
||||
* Auto-generated ID by Dexie.
|
||||
*/
|
||||
id?: number;
|
||||
|
||||
/**
|
||||
* The date the account was created.
|
||||
*/
|
||||
dateCreated: string;
|
||||
|
||||
/**
|
||||
* The derivation path for the account.
|
||||
*/
|
||||
derivationPath: string;
|
||||
|
||||
/**
|
||||
* Decentralized Identifier (DID) for the account.
|
||||
*/
|
||||
did: string;
|
||||
// stringified JSON containing underlying key material of type IIdentifier
|
||||
// https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format.
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* The mnemonic passphrase for the account.
|
||||
*/
|
||||
mnemonic: string;
|
||||
};
|
||||
|
||||
// mark encrypted field by starting with a $ character
|
||||
// see https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon
|
||||
/**
|
||||
* Schema for the accounts table in the database.
|
||||
* Fields starting with a $ character are encrypted.
|
||||
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
|
||||
*/
|
||||
export const AccountsSchema = {
|
||||
accounts:
|
||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
||||
|
||||
@@ -7,5 +7,5 @@ export interface Contact {
|
||||
}
|
||||
|
||||
export const ContactsSchema = {
|
||||
contacts: "++did, name, publicKeyBase64, registered, seesMe",
|
||||
contacts: "&did, name, publicKeyBase64, registered, seesMe",
|
||||
};
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
/**
|
||||
* BoundingBox type describes the geographical bounding box coordinates.
|
||||
*/
|
||||
export type BoundingBox = {
|
||||
eastLong: number;
|
||||
maxLat: number;
|
||||
minLat: number;
|
||||
westLong: number;
|
||||
eastLong: number; // Eastern longitude
|
||||
maxLat: number; // Maximum (Northernmost) latitude
|
||||
minLat: number; // Minimum (Southernmost) latitude
|
||||
westLong: number; // Western longitude
|
||||
};
|
||||
|
||||
// a singleton
|
||||
/**
|
||||
* Settings type encompasses user-specific configuration details.
|
||||
*/
|
||||
export type Settings = {
|
||||
id: number; // there's only one entry: MASTER_SETTINGS_KEY
|
||||
id: number; // Only one entry using MASTER_SETTINGS_KEY
|
||||
activeDid?: string; // Active Decentralized ID
|
||||
apiServer?: string; // API server URL
|
||||
firstName?: string; // User's first name
|
||||
lastName?: string; // deprecated - put all names in firstName
|
||||
lastViewedClaimId?: string; // Last viewed claim ID
|
||||
lastNotifiedClaimId?: string; // Last notified claim ID
|
||||
isRegistered?: boolean;
|
||||
webPushServer?: string; // Web Push server URL
|
||||
|
||||
activeDid?: string;
|
||||
apiServer?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
lastViewedClaimId?: string;
|
||||
// Array of named search boxes defined by bounding boxes
|
||||
searchBoxes?: Array<{
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
showContactGivesInline?: boolean;
|
||||
|
||||
showContactGivesInline?: boolean; // Display contact inline or not
|
||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
||||
reminderOn?: boolean; // Toggle to enable or disable reminders
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema for the Settings table in the database.
|
||||
*/
|
||||
export const SettingsSchema = {
|
||||
settings: "id",
|
||||
};
|
||||
|
||||
/**
|
||||
* Constants.
|
||||
*/
|
||||
export const MASTER_SETTINGS_KEY = 1;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||
import { getRandomBytesSync } from "ethereum-cryptography/random";
|
||||
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
||||
@@ -7,7 +6,10 @@ import { HDNode } from "@ethersproject/hdnode";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
|
||||
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
|
||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -150,3 +152,24 @@ export function fromJose(signature: string): {
|
||||
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) } }
|
||||
|
||||
Note that similar code is also contained in time-safari
|
||||
*/
|
||||
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
|
||||
let jwtText = jwtUrlText;
|
||||
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
|
||||
if (endorserContextLoc > -1) {
|
||||
jwtText = jwtText.substring(
|
||||
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
|
||||
);
|
||||
}
|
||||
|
||||
// JWT format: { header, payload, signature, data }
|
||||
const jwt = didJwt.decodeJWT(jwtText);
|
||||
|
||||
return jwt.payload;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,14 @@ import { Axios, AxiosResponse } from "axios";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
||||
// the object in RegisterAction claims
|
||||
export const SERVICE_ID = "endorser.ch";
|
||||
// the prefix for the contact URL
|
||||
export const CONTACT_URL_PREFIX = "https://endorser.ch";
|
||||
// the suffix for the contact URL
|
||||
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
|
||||
// the prefix for handle IDs, the permanent ID for claims on Endorser
|
||||
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
|
||||
|
||||
export interface AgreeVerifiableCredential {
|
||||
"@context": string;
|
||||
@@ -33,14 +40,24 @@ export interface ClaimResult {
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
export interface GenericClaim {
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
issuedAt: string;
|
||||
// "any" because arbitrary objects can be subject of agreement
|
||||
}
|
||||
|
||||
export interface GenericServerRecord extends GenericVerifiableCredential {
|
||||
handleId?: string;
|
||||
id?: string;
|
||||
issuedAt?: string;
|
||||
issuer?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
claim: Record<any, any>;
|
||||
}
|
||||
export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "",
|
||||
claim: {},
|
||||
};
|
||||
|
||||
export interface GiveServerRecord {
|
||||
agentDid: string;
|
||||
@@ -54,17 +71,46 @@ export interface GiveServerRecord {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface OfferServerRecord {
|
||||
amount: number;
|
||||
amountGiven: number;
|
||||
offeredByDid: string;
|
||||
recipientDid: string;
|
||||
requirementsMet: boolean;
|
||||
unit: string;
|
||||
validThrough: string;
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id4
|
||||
export interface GiveVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": string;
|
||||
"@type": "GiveAction";
|
||||
agent?: { identifier: string };
|
||||
description?: string;
|
||||
fulfills?: { "@type": string; identifier: string };
|
||||
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
|
||||
identifier?: string;
|
||||
object?: { amountOfThisGood: number; unitCode: string };
|
||||
recipient?: { identifier: string };
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id8
|
||||
export interface OfferVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": "Offer";
|
||||
description?: string;
|
||||
includesObject?: { amountOfThisGood: number; unitCode: string };
|
||||
itemOffered?: {
|
||||
description?: string;
|
||||
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
||||
};
|
||||
offeredBy?: { identifier: string };
|
||||
validThrough?: string;
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id7
|
||||
export interface PlanVerifiableCredential {
|
||||
"@context": "https://schema.org";
|
||||
"@type": "PlanAction";
|
||||
@@ -109,30 +155,126 @@ export function isHiddenDid(did: string) {
|
||||
return did === HIDDEN_DID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true for any nested string where func(input) === true
|
||||
*
|
||||
* Similar logic is found in endorser-mobile.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function testRecursivelyOnString(func: (arg0: any) => boolean, input: any) {
|
||||
if (Object.prototype.toString.call(input) === "[object String]") {
|
||||
return func(input);
|
||||
} else if (input instanceof Object) {
|
||||
if (!Array.isArray(input)) {
|
||||
// it's an object
|
||||
for (const key in input) {
|
||||
if (testRecursivelyOnString(func, input[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// it's an array
|
||||
for (const value of input) {
|
||||
if (testRecursivelyOnString(func, value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function containsHiddenDid(obj: any) {
|
||||
return testRecursivelyOnString(isHiddenDid, obj);
|
||||
}
|
||||
|
||||
export function stripEndorserPrefix(claimId: string) {
|
||||
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
|
||||
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
|
||||
} else {
|
||||
return claimId;
|
||||
}
|
||||
}
|
||||
|
||||
// similar logic is found in endorser-mobile
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function removeSchemaContext(obj: any) {
|
||||
return obj["@context"] === SCHEMA_ORG_CONTEXT
|
||||
? R.omit(["@context"], obj)
|
||||
: obj;
|
||||
}
|
||||
|
||||
// similar logic is found in endorser-mobile
|
||||
export function addLastClaimOrHandleAsIdIfMissing(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
obj: any,
|
||||
lastClaimId?: string,
|
||||
handleId?: string,
|
||||
) {
|
||||
if (!obj.identifier && lastClaimId) {
|
||||
const result = R.clone(obj);
|
||||
result.lastClaimId = lastClaimId;
|
||||
return result;
|
||||
} else if (!obj.identifier && handleId) {
|
||||
const result = R.clone(obj);
|
||||
result.identifier = handleId;
|
||||
return result;
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// return clone of object without any nested *VisibleToDids keys
|
||||
// similar logic is found in endorser-mobile
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function removeVisibleToDids(input: any): any {
|
||||
if (input instanceof Object) {
|
||||
if (!Array.isArray(input)) {
|
||||
// it's an object
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: Record<string, any> = {};
|
||||
for (const key in input) {
|
||||
if (!key.endsWith("VisibleToDids")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result[key] = removeVisibleToDids(R.clone(input[key]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// it's an array
|
||||
return R.map(removeVisibleToDids, input);
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
|
||||
|
||||
Similar logic is found in endorser-mobile.
|
||||
**/
|
||||
export function didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
allMyDids: string[],
|
||||
contacts: Contact[],
|
||||
): string {
|
||||
const myId: string | undefined = R.find(R.equals(did), allMyDids);
|
||||
if (myId) {
|
||||
return "You" + (myId !== activeDid ? " (Alt ID)" : "");
|
||||
} else {
|
||||
const contact: Contact | undefined = R.find((c) => c.did === did, contacts);
|
||||
if (contact) {
|
||||
return contact.name || "Someone Unnamed in Contacts";
|
||||
} else if (!did) {
|
||||
return "Unspecified Person";
|
||||
} else if (isHiddenDid(did)) {
|
||||
return "Someone Not In Network";
|
||||
} else {
|
||||
return "Someone Not In Contacts";
|
||||
}
|
||||
}
|
||||
if (!did) return "Someone Anonymous";
|
||||
|
||||
const myId = R.find(R.equals(did), allMyDids);
|
||||
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
|
||||
|
||||
const contact = R.find((c) => c.did === did, contacts);
|
||||
return contact
|
||||
? contact.name || "Contact With No Name"
|
||||
: isHiddenDid(did)
|
||||
? "Someone Not In Network"
|
||||
: "Someone Not In Contacts";
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
@@ -149,7 +291,7 @@ export interface ErrorResult {
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export type CreateAndSubmitGiveResult = SuccessResult | ErrorResult;
|
||||
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||
|
||||
/**
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
@@ -168,33 +310,85 @@ export async function createAndSubmitGive(
|
||||
toDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
unitCode?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitGiveResult> {
|
||||
try {
|
||||
// Make a claim
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
recipient: toDid ? { identifier: toDid } : undefined,
|
||||
agent: fromDid ? { identifier: fromDid } : undefined,
|
||||
description: description || undefined,
|
||||
object: hours
|
||||
? { amountOfThisGood: hours, unitCode: unitCode || "HUR" }
|
||||
: undefined,
|
||||
fulfills: fulfillsProjectHandleId
|
||||
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
|
||||
: undefined,
|
||||
};
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as GenericServerRecord,
|
||||
identity,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param description may be null; should have this or hours
|
||||
* @param hours may be null; should have this or description
|
||||
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
|
||||
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
|
||||
*/
|
||||
export async function createAndSubmitOffer(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
identity: IIdentifier,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
expirationDate?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim: OfferVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Offer",
|
||||
offeredBy: { identifier: identity.did },
|
||||
validThrough: expirationDate || undefined,
|
||||
};
|
||||
if (hours) {
|
||||
vcClaim.includesObject = {
|
||||
amountOfThisGood: hours,
|
||||
unitCode: "HUR",
|
||||
};
|
||||
if (toDid) {
|
||||
vcClaim.recipient = { identifier: toDid };
|
||||
}
|
||||
if (fromDid) {
|
||||
vcClaim.agent = { identifier: fromDid };
|
||||
}
|
||||
if (description) {
|
||||
vcClaim.description = description;
|
||||
}
|
||||
if (hours) {
|
||||
vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" };
|
||||
}
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.fulfills = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
};
|
||||
}
|
||||
// Make a payload for the claim
|
||||
}
|
||||
if (description) {
|
||||
vcClaim.itemOffered = { description };
|
||||
}
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||
vcClaim.itemOffered.isPartOf = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
};
|
||||
}
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as GenericServerRecord,
|
||||
identity,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAndSubmitClaim(
|
||||
vcClaim: GenericVerifiableCredential,
|
||||
identity: IIdentifier,
|
||||
apiServer: string,
|
||||
axios: Axios,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
try {
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
@@ -205,14 +399,7 @@ export async function createAndSubmitGive(
|
||||
|
||||
// Create a signature using private key of identity
|
||||
const firstKey = identity.keys[0];
|
||||
if (!firstKey || !firstKey.privateKeyHex) {
|
||||
throw {
|
||||
error: "No private key",
|
||||
message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`,
|
||||
};
|
||||
}
|
||||
|
||||
const privateKeyHex = firstKey.privateKeyHex;
|
||||
const privateKeyHex = firstKey?.privateKeyHex;
|
||||
|
||||
if (!privateKeyHex) {
|
||||
throw {
|
||||
@@ -222,48 +409,31 @@ export async function createAndSubmitGive(
|
||||
}
|
||||
|
||||
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,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = apiServer + "/api/v2/claim";
|
||||
const url = `${apiServer}/api/v2/claim`;
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
const response = await axios.post(url, payload, { headers });
|
||||
return {
|
||||
type: "success",
|
||||
response,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
let errorMessage: string;
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof Error) {
|
||||
// If it's a JavaScript Error object
|
||||
errorMessage = error.message;
|
||||
} else if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error
|
||||
) {
|
||||
// If it's an object that has a 'message' property
|
||||
errorMessage = (error as { message: string }).message;
|
||||
} else {
|
||||
// Unknown error shape, default message
|
||||
errorMessage = "Unknown error";
|
||||
}
|
||||
return { type: "success", response };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error creating claim:", error);
|
||||
const errorMessage: string =
|
||||
error.response?.data?.error?.message || error.message || "Unknown error";
|
||||
|
||||
return {
|
||||
type: "error",
|
||||
|
||||
5
src/libs/util.ts
Normal file
5
src/libs/util.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// many of these are also found in endorser-mobile utility.ts
|
||||
|
||||
export const isGlobalUri = (uri: string) => {
|
||||
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
||||
};
|
||||
@@ -1,151 +1,7 @@
|
||||
// Created from the setup in https://veramo.io/docs/guides/react_native
|
||||
// see also ../constants/app.ts and
|
||||
|
||||
// Core interfaces
|
||||
/* import {
|
||||
createAgent,
|
||||
IDIDManager,
|
||||
IResolver,
|
||||
IDataStore,
|
||||
IKeyManager,
|
||||
} from "@veramo/core";
|
||||
*/
|
||||
// Core identity manager plugin
|
||||
//import { DIDManager } from "@veramo/did-manager";
|
||||
|
||||
// Ethr did identity provider
|
||||
//import { EthrDIDProvider } from "@veramo/did-provider-ethr";
|
||||
|
||||
// Core key manager plugin
|
||||
//import { KeyManager } from "@veramo/key-manager";
|
||||
|
||||
// Custom key management system for RN
|
||||
//import { KeyManagementSystem } from '@veramo/kms-local-react-native'
|
||||
|
||||
// Custom resolver
|
||||
// Custom resolvers
|
||||
//import { DIDResolverPlugin } from "@veramo/did-resolver";
|
||||
/* import { Resolver } from "did-resolver";
|
||||
import { getResolver as ethrDidResolver } from "ethr-did-resolver";
|
||||
import { getResolver as webDidResolver } from "web-did-resolver";
|
||||
*/
|
||||
// for VCs and VPs https://veramo.io/docs/api/credential-w3c
|
||||
//import { CredentialIssuer } from '@veramo/credential-w3c'
|
||||
|
||||
// Storage plugin using TypeOrm
|
||||
/* import {
|
||||
Entities,
|
||||
KeyStore,
|
||||
DIDStore,
|
||||
IDataStoreORM,
|
||||
} from "@veramo/data-store";
|
||||
*/
|
||||
// TypeORM is installed with @veramo/typeorm
|
||||
//import { createConnection } from 'typeorm'
|
||||
|
||||
//import * as R from "ramda";
|
||||
|
||||
/*
|
||||
import { Contact } from '../entity/contact'
|
||||
import { Settings } from '../entity/settings'
|
||||
import { PrivateData } from '../entity/privateData'
|
||||
|
||||
import { Initial1616938713828 } from '../migration/1616938713828-initial'
|
||||
import { SettingsContacts1616967972293 } from '../migration/1616967972293-settings-contacts'
|
||||
import { EncryptedSeed1637856484788 } from '../migration/1637856484788-EncryptedSeed'
|
||||
import { HomeScreenConfig1639947962124 } from '../migration/1639947962124-HomeScreenConfig'
|
||||
import { HandlePublicKeys1652142819353 } from '../migration/1652142819353-HandlePublicKeys'
|
||||
import { LastClaimsSeen1656811846836 } from '../migration/1656811846836-LastClaimsSeen'
|
||||
import { ContactRegistered1662256903367 }from '../migration/1662256903367-ContactRegistered'
|
||||
import { PrivateData1663080623479 } from '../migration/1663080623479-PrivateData'
|
||||
|
||||
const ALL_ENTITIES = Entities.concat([Contact, Settings, PrivateData])
|
||||
|
||||
// Create react native DB connection configured by ormconfig.js
|
||||
|
||||
export const dbConnection = createConnection({
|
||||
database: 'endorser-mobile.sqlite',
|
||||
entities: ALL_ENTITIES,
|
||||
location: 'default',
|
||||
logging: ['error', 'info', 'warn'],
|
||||
migrations: [ Initial1616938713828, SettingsContacts1616967972293, EncryptedSeed1637856484788, HomeScreenConfig1639947962124, HandlePublicKeys1652142819353, LastClaimsSeen1656811846836, ContactRegistered1662256903367, PrivateData1663080623479 ],
|
||||
migrationsRun: true,
|
||||
type: 'react-native',
|
||||
})
|
||||
*/
|
||||
function didProviderName(netName: string) {
|
||||
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
|
||||
}
|
||||
|
||||
//const NETWORK_NAMES = ["mainnet", "rinkeby"];
|
||||
|
||||
const DEFAULT_DID_PROVIDER_NETWORK_NAME = "mainnet";
|
||||
|
||||
export const DEFAULT_DID_PROVIDER_NAME = didProviderName(
|
||||
DEFAULT_DID_PROVIDER_NETWORK_NAME,
|
||||
);
|
||||
|
||||
export const HANDY_APP = false;
|
||||
|
||||
// this is used as the object in RegisterAction claims
|
||||
export const SERVICE_ID = "endorser.ch";
|
||||
|
||||
//const INFURA_PROJECT_ID = "INFURA_PROJECT_ID";
|
||||
/*
|
||||
const providers = {}
|
||||
NETWORK_NAMES.forEach((networkName) => {
|
||||
providers[didProviderName(networkName)] = new EthrDIDProvider({
|
||||
defaultKms: 'local',
|
||||
network: networkName,
|
||||
rpcUrl: 'https://' + networkName + '.infura.io/v3/' + INFURA_PROJECT_ID,
|
||||
gas: 1000001,
|
||||
ttl: 60 * 60 * 24 * 30 * 12 + 1,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const didManager = new DIDManager({
|
||||
store: new DIDStore(dbConnection),
|
||||
defaultProvider: DEFAULT_DID_PROVIDER_NAME,
|
||||
providers: providers,
|
||||
})
|
||||
*/
|
||||
|
||||
/* const basicDidResolvers = NETWORK_NAMES.map((networkName) => [
|
||||
networkName,
|
||||
new Resolver({
|
||||
ethr: ethrDidResolver({
|
||||
networks: [
|
||||
{
|
||||
name: networkName,
|
||||
rpcUrl:
|
||||
"https://" + networkName + ".infura.io/v3/" + INFURA_PROJECT_ID,
|
||||
},
|
||||
],
|
||||
}).ethr,
|
||||
web: webDidResolver().web,
|
||||
}),
|
||||
]);
|
||||
|
||||
const basicResolverMap = R.fromPairs(basicDidResolvers)
|
||||
|
||||
export const DEFAULT_BASIC_RESOLVER = basicResolverMap[DEFAULT_DID_PROVIDER_NETWORK_NAME]
|
||||
|
||||
const agentDidResolvers = NETWORK_NAMES.map((networkName) => {
|
||||
return new DIDResolverPlugin({
|
||||
resolver: basicResolverMap[networkName],
|
||||
})
|
||||
})
|
||||
|
||||
let allPlugins = [
|
||||
new CredentialIssuer(),
|
||||
new KeyManager({
|
||||
store: new KeyStore(dbConnection),
|
||||
kms: {
|
||||
local: new KeyManagementSystem(),
|
||||
},
|
||||
}),
|
||||
didManager,
|
||||
].concat(agentDidResolvers)
|
||||
*/
|
||||
|
||||
//export const agent = createAgent<IDIDManager & IKeyManager & IDataStore & IDataStoreORM & IResolver>({ plugins: allPlugins })
|
||||
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -13,6 +13,8 @@ import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
@@ -33,16 +36,19 @@ import {
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
@@ -59,6 +65,8 @@ import {
|
||||
library.add(
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
@@ -72,6 +80,7 @@ library.add(
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
@@ -79,17 +88,20 @@ library.add(
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
register("/additional-scripts.js", {
|
||||
ready() {
|
||||
console.log(
|
||||
"App is being served from cache by a service worker.\n" +
|
||||
|
||||
@@ -33,14 +33,18 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "home",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/account",
|
||||
name: "account",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/claim/:id?",
|
||||
name: "claim",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/confirm-contact",
|
||||
@@ -58,6 +62,14 @@ const routes: Array<RouteRecordRaw> = [
|
||||
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-gives",
|
||||
name: "contact-gives",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-qr",
|
||||
name: "contact-qr",
|
||||
@@ -71,15 +83,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "contacts",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/scan-contact",
|
||||
name: "scan-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/discover",
|
||||
@@ -93,6 +96,22 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help-notifications",
|
||||
name: "help-notifications",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/identity-switcher",
|
||||
name: "identity-switcher",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/import-account",
|
||||
name: "import-account",
|
||||
@@ -142,15 +161,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/identity-switcher",
|
||||
name: "identity-switcher",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/project",
|
||||
path: "/project/:id?",
|
||||
name: "project",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
||||
@@ -162,6 +173,22 @@ const routes: Array<RouteRecordRaw> = [
|
||||
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/scan-contact",
|
||||
name: "scan-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/search-area",
|
||||
name: "search-area",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seed-backup",
|
||||
name: "seed-backup",
|
||||
@@ -185,12 +212,10 @@ const routes: Array<RouteRecordRaw> = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-gives",
|
||||
name: "contact-gives",
|
||||
path: "/test",
|
||||
name: "test",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "statistics" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db } from "../db";
|
||||
import { SERVICE_ID } from "../libs/veramo/setup";
|
||||
import { SERVICE_ID } from "../libs/endorserServer";
|
||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
|
||||
2184
src/util.d.ts
vendored
Normal file
2184
src/util.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
472
src/views/ClaimView.vue
Normal file
472
src/views/ClaimView.vue
Normal file
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<!-- 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">
|
||||
<!-- 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>
|
||||
Verifiable Claim Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<div>
|
||||
<div class="block pb-4 flex gap-4 overflow-hidden">
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-xl">{{ veriClaim.id }}</h2>
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
{{ veriClaim.claimType }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="message" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.claim?.description }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.issuer }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
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>
|
||||
<li
|
||||
v-for="confirmerId in confirmerIdList"
|
||||
:key="confirmerId"
|
||||
class="list-disc"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ confirmerId }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
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. -->
|
||||
|
||||
<div v-if="confsVisibleToIdList.length > 0">
|
||||
The following people can connect you with people who have issued or
|
||||
confirmed this claim.
|
||||
<ul>
|
||||
<li
|
||||
v-for="confsVisibleTo in confsVisibleToIdList"
|
||||
:key="confsVisibleTo"
|
||||
class="list-disc"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ confsVisibleTo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="confirmerIdList.includes(activeDid)">
|
||||
You have confirmed this claim.
|
||||
</div>
|
||||
<div v-else-if="containsHiddenDid(veriClaim.claim)">
|
||||
You cannot confirm this claim because it contains data that is hidden
|
||||
from you.
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4"
|
||||
@click="confirmClaim(veriClaim.id)"
|
||||
>
|
||||
Confirm Claim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-bold text-2xl mt-8">Claim</h2>
|
||||
<pre>{{ util.inspect(veriClaim, false, null) }}</pre>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div v-if="fullClaimMessage">
|
||||
{{ fullClaimMessage }}
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
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>{{ util.inspect(fullClaim, false, null) }}</pre>
|
||||
</div>
|
||||
|
||||
<a :href="apiServer + '/api/claim/' + veriClaim.id" target="_blank">
|
||||
<button class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4">
|
||||
View on the Public Server
|
||||
</button>
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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 GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
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 QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
||||
})
|
||||
export default class ClaimView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
confirmerIdList = []; // list of DIDs that have confirmed this claim excluding the issuer
|
||||
confsVisibleErrorMessage = "";
|
||||
confsVisibleToIdList = []; // list of DIDs that can see any confirmer
|
||||
fullClaim = null;
|
||||
fullClaimMessage = "";
|
||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
|
||||
util = util;
|
||||
containsHiddenDid = serverUtil.containsHiddenDid;
|
||||
|
||||
async created() {
|
||||
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 = 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);
|
||||
this.loadClaim(claimId, identity);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "No claim ID was provided.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
totalConfirmers() {
|
||||
return (
|
||||
this.numConfsNotVisible +
|
||||
this.confirmerIdList.length +
|
||||
this.confsVisibleToIdList.length
|
||||
);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
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;
|
||||
} else {
|
||||
// actually, axios typically throws an error so we never get here
|
||||
console.log("Error getting claim:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem getting that claim. See logs for more info.",
|
||||
},
|
||||
-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,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmUrl =
|
||||
this.apiServer +
|
||||
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
||||
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
||||
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 resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
||||
const resultList3 = R.reject(
|
||||
(did: string) => did === this.veriClaim.issuer,
|
||||
resultList2,
|
||||
);
|
||||
this.confirmerIdList = resultList3;
|
||||
this.numConfsNotVisible = resultList1.length - resultList2.length;
|
||||
if (resultList3.length === resultList2.length) {
|
||||
// the issuer was not in the "visible" list so they must be hidden
|
||||
// so subtract them from the non-visible confirmers count
|
||||
this.numConfsNotVisible = this.numConfsNotVisible - 1;
|
||||
}
|
||||
this.confsVisibleToIdList =
|
||||
response.data.result.resultVisibleToDids || [];
|
||||
} else {
|
||||
this.confsVisibleErrorMessage =
|
||||
"Had problems retrieving confirmations.";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Error retrieving confirmations:", serverError);
|
||||
this.confsVisibleErrorMessage =
|
||||
"Had problems retrieving confirmations. See logs for more info.";
|
||||
}
|
||||
}
|
||||
|
||||
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 this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fullClaim = resp.data;
|
||||
} else {
|
||||
// actually, axios typically throws an error so we never get here
|
||||
console.log("Error getting full claim:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem getting that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error retrieving full claim:", error);
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.response?.status === 403) {
|
||||
this.fullClaimMessage =
|
||||
"You are not authorized to view the full contents of this claim." +
|
||||
" To see all the details, ask the issuer to allow you to see their claims." +
|
||||
" If you cannot see the issuer's DID, ask someone in the Confirmations section above." +
|
||||
" If there are no connections, you will have to ask people in your" +
|
||||
" network for their help, some other way; send them to this page and" +
|
||||
" see if they can make a connection for you.";
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async confirmClaim() {
|
||||
if (confirm("Do you personally confirm that this is true?")) {
|
||||
// similar logic is found in endorser-mobile
|
||||
const goodClaim = serverUtil.removeSchemaContext(
|
||||
serverUtil.removeVisibleToDids(
|
||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||
this.veriClaim.claim,
|
||||
this.veriClaim.id,
|
||||
this.veriClaim.handleId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const confirmationClaim: serverUtil.GenericVerifiableCredential & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
object: any;
|
||||
} = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AgreeAction",
|
||||
object: goodClaim,
|
||||
};
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
confirmationClaim,
|
||||
await this.getIdentity(this.activeDid),
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Confirmation submitted.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
console.log("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,8 +2,11 @@
|
||||
<QuickNav selected="Contacts"></QuickNav>
|
||||
<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">
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
id="ViewBreadcrumb"
|
||||
class="text-lg text-center font-light relative px-7"
|
||||
>
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
@@ -11,11 +14,12 @@
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</h1>
|
||||
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||
Given with {{ contact?.name }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Given with {{ contact?.name }}
|
||||
</h1>
|
||||
<div class="flex justify-around">
|
||||
<span />
|
||||
<span class="justify-around">(Only 50 most recent)</span>
|
||||
@@ -120,7 +124,7 @@ interface Notification {
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class ContactsView extends Vue {
|
||||
export default class ContactAmountssView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
@@ -358,7 +362,10 @@ export default class ContactsView extends Vue {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
|
||||
/*
|
||||
Tooltip, generated on "title" attributes on "fa" icons
|
||||
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
||||
*/
|
||||
/* Tooltip container */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
|
||||
@@ -16,19 +16,15 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search -->
|
||||
|
||||
<!-- Initial Loading Animation -->
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="border-t border-slate-300">
|
||||
<li class="border-b border-slate-300 py-3">
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow italic text-slate-500"
|
||||
><EntityIcon
|
||||
entityId="Anonymous"
|
||||
:entityId="null"
|
||||
:iconSize="32"
|
||||
class="opacity-50 inline-block align-middle border border-dashed border-slate-400 bg-slate-200 rounded-md mr-1"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
Anonymous
|
||||
</span>
|
||||
@@ -72,10 +68,9 @@
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
@dialog-result="handleDialogResult"
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
showGivenToUser="true"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -83,16 +78,10 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { Account, AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
CreateAndSubmitGiveResult,
|
||||
ErrorResult,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
} from "@/libs/endorserServer";
|
||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
@@ -108,7 +97,7 @@ interface Notification {
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
export default class ContactGiftingView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
@@ -124,10 +113,10 @@ export default class HomeView extends Vue {
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
@@ -150,7 +139,7 @@ export default class HomeView extends Vue {
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
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();
|
||||
@@ -173,123 +162,5 @@ export default class HomeView extends Vue {
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was "cancel" so do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
) {
|
||||
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 && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
);
|
||||
|
||||
if (this.isGiveCreationError(result)) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give caught:", error);
|
||||
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the Give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
isGiveCreationError(result: CreateAndSubmitGiveResult) {
|
||||
return result.type == "error";
|
||||
}
|
||||
|
||||
getGiveCreationErrorMessage(result: CreateAndSubmitGiveResult) {
|
||||
return (result as ErrorResult).error?.userMessage;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,27 +2,67 @@
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Contact Info
|
||||
</h1>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Play with display options: https://qr-code-styling.com/
|
||||
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
|
||||
-->
|
||||
<QRCodeVue3
|
||||
:value="this.qrValue"
|
||||
:cornersSquareOptions="{ type: 'extra-rounded' }"
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||
Your Contact Info
|
||||
</h1>
|
||||
<p v-if="!givenName" class="text-center mt-2">
|
||||
<span class="text-red">Beware!</span>
|
||||
You aren't sharing your name, so hurry and
|
||||
<router-link
|
||||
:to="{ name: 'new-edit-account' }"
|
||||
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
|
||||
>
|
||||
go here to set it for them.
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
-->
|
||||
<QRCodeVue3
|
||||
:value="this.qrValue"
|
||||
:cornersSquareOptions="{ type: 'extra-rounded' }"
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center" v-else>
|
||||
You have no identitifiers yet, so
|
||||
<router-link :to="{ name: 'start' }" class="text-blue-500">
|
||||
create your identifier.
|
||||
</router-link>
|
||||
<br />
|
||||
We recommend you do that first; otherwise, these contacts won't see your
|
||||
activity.
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
|
||||
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { QrcodeStream } from "vue-qrcode-reader";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import * as R from "ramda";
|
||||
@@ -30,6 +70,10 @@ 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,
|
||||
ENDORSER_JWT_URL_LOCATION,
|
||||
} from "@/libs/endorserServer";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
@@ -43,6 +87,7 @@ interface Notification {
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QrcodeStream,
|
||||
QRCodeVue3,
|
||||
QuickNav,
|
||||
},
|
||||
@@ -52,6 +97,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
qrValue = "";
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
@@ -65,7 +111,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
"Attempted to show contact info with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
@@ -76,21 +122,12 @@ export default class ContactQRScanShow extends Vue {
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.givenName = settings?.firstName || "";
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
if (!account) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "",
|
||||
text: "You have no identity yet.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
if (account) {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const publicKeyHex = identity.keys[0].publicKeyHex;
|
||||
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
||||
@@ -98,7 +135,9 @@ export default class ContactQRScanShow extends Vue {
|
||||
iat: Date.now(),
|
||||
iss: this.activeDid,
|
||||
own: {
|
||||
name: (settings?.firstName || "") + " " + (settings?.lastName || ""),
|
||||
name:
|
||||
(settings?.firstName || "") +
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
||||
publicEncKey,
|
||||
},
|
||||
};
|
||||
@@ -112,9 +151,64 @@ export default class ContactQRScanShow extends Vue {
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
const viewPrefix = "https://endorser.ch/contact?jwt=";
|
||||
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
|
||||
this.qrValue = viewPrefix + vcJwt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param content is the result of a QR scan, an array with one item with a rawValue property
|
||||
*/
|
||||
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScanDetect(content: any) {
|
||||
if (content[0]?.rawValue) {
|
||||
//console.log("onDetect", content[0].rawValue);
|
||||
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
|
||||
this.$router.push({ name: "contacts" });
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Invalid Contact QR Code",
|
||||
text: "No QR code detected with contact information.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScanError(error: any) {
|
||||
console.log("Scan was invalid:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Invalid Scan",
|
||||
text: "The scan was invalid.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
onCopyToClipboard() {
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.then(() => {
|
||||
console.log("Contact URL:", this.qrValue);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "Contact URL was copied to clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,10 +19,16 @@
|
||||
</div>
|
||||
|
||||
<!-- New Contact -->
|
||||
<div class="mb-4 flex">
|
||||
<div class="mb-4 flex items-stretch">
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="flex items-center bg-slate-500 text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
>
|
||||
<fa icon="qrcode" class="fa-fw text-2xl" />
|
||||
</router-link>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="DID, Name, Public Key"
|
||||
placeholder="DID, Name, Public Key (base 16 or 64)"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
v-model="contactInput"
|
||||
/>
|
||||
@@ -62,8 +68,8 @@
|
||||
showGiveTotals
|
||||
? "Total"
|
||||
: showGiveConfirmed
|
||||
? "Confirmed"
|
||||
: "Unconfirmed"
|
||||
? "Confirmed"
|
||||
: "Unconfirmed"
|
||||
}}
|
||||
</button>
|
||||
<br />
|
||||
@@ -88,6 +94,16 @@
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||
></EntityIcon>
|
||||
{{ contact.name || "(no name)" }}
|
||||
<button
|
||||
class="text-sm uppercase bg-slate-500 text-white px-1 rounded-md"
|
||||
@click="
|
||||
contactEdit = contact;
|
||||
contactNewName = contact.name;
|
||||
"
|
||||
title="Edit"
|
||||
>
|
||||
<fa icon="pen" class="fa-fw" />
|
||||
</button>
|
||||
</h2>
|
||||
<div class="text-sm truncate">{{ contact.did }}</div>
|
||||
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
|
||||
@@ -95,52 +111,54 @@
|
||||
</div>
|
||||
|
||||
<div id="ContactActions" class="flex gap-1.5 mt-2">
|
||||
<button
|
||||
v-if="contact.seesMe"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, false)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="register(contact)"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
>
|
||||
<fa
|
||||
v-if="contact.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
title="Registered"
|
||||
/>
|
||||
<fa
|
||||
<div v-if="activeDid">
|
||||
<button
|
||||
v-if="contact.seesMe"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, false, true)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="fa-fw"
|
||||
title="Registration Unknown"
|
||||
/>
|
||||
</button>
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, true, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
v-if="activeDid"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
@click="register(contact)"
|
||||
class="text-sm uppercase bg-slate-500 text-white ml-6 px-2 py-1.5 rounded-md"
|
||||
v-if="activeDid"
|
||||
>
|
||||
<fa
|
||||
v-if="contact.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
title="Registered"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="fa-fw"
|
||||
title="Registration Unknown"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="deleteContact(contact)"
|
||||
class="text-sm uppercase bg-red-600 text-white px-2 py-1.5 rounded-md"
|
||||
class="text-sm uppercase bg-red-600 text-white ml-24 px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
>
|
||||
<fa icon="trash-can" class="fa-fw" />
|
||||
@@ -153,7 +171,7 @@
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-l-md"
|
||||
@click="onClickAddGive(activeDid, contact.did)"
|
||||
title="givenByMeDescriptions[contact.did]"
|
||||
:title="givenByMeDescriptions[contact.did] || ''"
|
||||
>
|
||||
To:
|
||||
{{
|
||||
@@ -172,7 +190,7 @@
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-r-md -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickAddGive(contact.did, activeDid)"
|
||||
title="givenToMeDescriptions[contact.did]"
|
||||
:title="givenToMeDescriptions[contact.did] || ''"
|
||||
>
|
||||
From:
|
||||
{{
|
||||
@@ -203,7 +221,32 @@
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>This identity has no contacts.</p>
|
||||
<p v-else>There are no contacts.</p>
|
||||
|
||||
<div v-if="contactEdit !== null" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Name"
|
||||
v-model="contactNewName"
|
||||
/>
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickSaveName(contactEdit, contactNewName)"
|
||||
>
|
||||
<fa icon="save" />
|
||||
</button>
|
||||
<span class="inline-block w-2" />
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickCancelName()"
|
||||
>
|
||||
<fa icon="ban" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -211,12 +254,19 @@
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
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 { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import {
|
||||
accessToken,
|
||||
getContactPayloadFromJwtUrl,
|
||||
SimpleSigner,
|
||||
} from "@/libs/crypto";
|
||||
import {
|
||||
CONTACT_URL_PREFIX,
|
||||
GiveServerRecord,
|
||||
GiveVerifiableCredential,
|
||||
RegisterVerifiableCredential,
|
||||
@@ -225,27 +275,24 @@ import {
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ContactsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
contactEndorserUrl = localStorage.getItem("contactEndorserUrl") || "";
|
||||
contactInput = "";
|
||||
contactEdit: Contact | null = null;
|
||||
contactNewName = "";
|
||||
// { "did:...": concatenated-descriptions } entry for each contact
|
||||
givenByMeDescriptions: Record<string, string> = {};
|
||||
// { "did:...": amount } entry for each contact
|
||||
@@ -260,15 +307,17 @@ export default class ContactsView extends Vue {
|
||||
givenToMeUnconfirmed: Record<string, number> = {};
|
||||
hourDescriptionInput = "";
|
||||
hourInput = "0";
|
||||
isRegistered = false;
|
||||
showGiveNumbers = false;
|
||||
showGiveTotals = true;
|
||||
showGiveConfirmed = true;
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
this.showGiveNumbers = !!settings?.showContactGivesInline;
|
||||
if (this.showGiveNumbers) {
|
||||
@@ -279,12 +328,18 @@ export default class ContactsView extends Vue {
|
||||
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
|
||||
allContacts,
|
||||
);
|
||||
|
||||
if (this.contactEndorserUrl) {
|
||||
await this.newContactFromScan(this.contactEndorserUrl);
|
||||
localStorage.removeItem("contactEndorserUrl");
|
||||
this.contactEndorserUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
@@ -312,6 +367,10 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
async loadGives() {
|
||||
if (!this.activeDid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResponse = (
|
||||
resp: { status: number; data: { data: GiveServerRecord[] } },
|
||||
descriptions: Record<string, string>,
|
||||
@@ -346,11 +405,11 @@ export default class ContactsView extends Vue {
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
title: "Retrieval Error",
|
||||
text:
|
||||
"Got an error retrieving your " +
|
||||
(useRecipient ? "given" : "received") +
|
||||
" time from the server.",
|
||||
" data from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -401,12 +460,13 @@ export default class ContactsView extends Vue {
|
||||
this.givenToMeConfirmed = givenToMeConfirmed;
|
||||
this.givenToMeUnconfirmed = givenToMeUnconfirmed;
|
||||
} catch (error) {
|
||||
console.log("Error loading gives", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: error as string,
|
||||
title: "Load Error",
|
||||
text: "Got an error loading your gives.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -414,6 +474,24 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
async onClickNewContact(): Promise<void> {
|
||||
if (!this.contactInput) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "No Contact",
|
||||
text: "There was no contact info to add.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) {
|
||||
await this.newContactFromScan(this.contactInput);
|
||||
return;
|
||||
}
|
||||
|
||||
let did = this.contactInput;
|
||||
let name, publicKeyBase64;
|
||||
const commaPos1 = this.contactInput.indexOf(",");
|
||||
@@ -432,22 +510,111 @@ export default class ContactsView extends Vue {
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact = { did, name, publicKeyBase64 };
|
||||
await db.contacts.add(newContact);
|
||||
const allContacts = this.contacts.concat([newContact]);
|
||||
this.contacts = R.sort(
|
||||
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
|
||||
allContacts,
|
||||
);
|
||||
await this.addContact(newContact);
|
||||
}
|
||||
|
||||
async newContactFromScan(url: string): Promise<void> {
|
||||
const payload = getContactPayloadFromJwtUrl(url);
|
||||
if (!payload) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "No Contact Info",
|
||||
text: "The contact info could not be parsed.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
return this.addContact({
|
||||
did: payload.iss,
|
||||
name: payload.own.name,
|
||||
publicKeyBase64: payload.own.publicEncKey,
|
||||
} as Contact);
|
||||
}
|
||||
}
|
||||
|
||||
async addContact(newContact: Contact) {
|
||||
if (!newContact.did) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Incomplete Contact",
|
||||
text: "Cannot add a contact without a DID.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
newContact.seesMe = true; // since we will immediately set that on the server
|
||||
return db.contacts
|
||||
.add(newContact)
|
||||
.then(() => {
|
||||
const allContacts = this.contacts.concat([newContact]);
|
||||
this.contacts = R.sort(
|
||||
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
|
||||
allContacts,
|
||||
);
|
||||
let addedMessage;
|
||||
if (this.activeDid) {
|
||||
this.setVisibility(newContact, true, false);
|
||||
addedMessage =
|
||||
newContact.name +
|
||||
" was added, and your activity is visible to them.";
|
||||
} else {
|
||||
addedMessage = newContact.name + " was added.";
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Contact Added",
|
||||
text: addedMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
if (this.isRegistered) {
|
||||
// putting this last so that it shows on the top
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "New User?",
|
||||
text:
|
||||
"If " +
|
||||
newContact.name +
|
||||
" is a new user, be sure to register them.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error when adding contact to storage:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Contact Not Added",
|
||||
text: "An error prevented this import.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteContact(contact: Contact) {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to delete " +
|
||||
"You should first make sure that your activity is no longer visible to them." +
|
||||
" Note that this only deletes them from your contacts on this device." +
|
||||
" \n\nAre you sure you want to remove " +
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
" with DID " +
|
||||
contact.did +
|
||||
" ?",
|
||||
" from your contact list?",
|
||||
)
|
||||
) {
|
||||
await db.open();
|
||||
@@ -467,6 +634,16 @@ export default class ContactsView extends Vue {
|
||||
"?",
|
||||
)
|
||||
) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "",
|
||||
title: "Registration submitted...",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
const vcClaim: RegisterVerifiableCredential = {
|
||||
@@ -533,6 +710,7 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when registering:", error);
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
@@ -549,7 +727,7 @@ export default class ContactsView extends Vue {
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
@@ -559,7 +737,11 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async setVisibility(contact: Contact, visibility: boolean) {
|
||||
async setVisibility(
|
||||
contact: Contact,
|
||||
visibility: boolean,
|
||||
showSuccessAlert: boolean,
|
||||
) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/report/" +
|
||||
@@ -571,39 +753,49 @@ export default class ContactsView extends Vue {
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.status === 200) {
|
||||
contact.seesMe = visibility;
|
||||
db.contacts.update(contact.did, { seesMe: visibility });
|
||||
} else {
|
||||
console.error("Bad response setting visibility: ", resp.data);
|
||||
if (resp.data.error?.message) {
|
||||
if (showSuccessAlert) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: resp.data.error?.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Bad server response of " + resp.status,
|
||||
type: "success",
|
||||
title: "Visibility Set",
|
||||
text:
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
contact.seesMe = visibility;
|
||||
db.contacts.update(contact.did, { seesMe: visibility });
|
||||
} else {
|
||||
console.error(
|
||||
"Got some bad server response when setting visibility: ",
|
||||
resp.status,
|
||||
resp,
|
||||
);
|
||||
const message =
|
||||
resp.data.error?.message || "Got some error setting visibility.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Visibility",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Got some error when setting visibility:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: err as string,
|
||||
title: "Error Setting Visibility",
|
||||
text: "Check connectivity and try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -628,46 +820,37 @@ export default class ContactsView extends Vue {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Refreshed",
|
||||
type: "info",
|
||||
title: "Visibility Refreshed",
|
||||
text:
|
||||
this.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
if (resp.data.error?.message) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: resp.data.error?.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Bad server response of " + resp.status,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
console.log("Got bad server response when checking visibility: ", resp);
|
||||
const message = resp.data.error?.message || "Got bad server response.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Checking Visibility",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Caught error from request to check visibility:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: err as string,
|
||||
title: "Error Checking Visibility",
|
||||
text: "Check connectivity and try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -782,6 +965,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// similar function is in endorserServer.ts
|
||||
private async createAndSubmitGive(
|
||||
identity: IIdentifier,
|
||||
fromDid: string,
|
||||
@@ -851,6 +1035,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error in createAndSubmitGive: ", error);
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
@@ -867,7 +1052,7 @@ export default class ContactsView extends Vue {
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
title: "Error Sending Give",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
@@ -876,6 +1061,18 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
private async onClickCancelName() {
|
||||
this.contactEdit = null;
|
||||
this.contactNewName = "";
|
||||
}
|
||||
|
||||
private async onClickSaveName(contact: Contact, newName: string) {
|
||||
contact.name = newName;
|
||||
return db.contacts
|
||||
.update(contact.did, { name: newName })
|
||||
.then(() => (this.contactEdit = null));
|
||||
}
|
||||
|
||||
public toggleShowGiveTotals() {
|
||||
if (this.showGiveTotals) {
|
||||
this.showGiveTotals = false;
|
||||
@@ -900,14 +1097,36 @@ export default class ContactsView extends Vue {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
|
||||
.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;
|
||||
}
|
||||
|
||||
/*
|
||||
Tooltip, generated on "title" attributes on "fa" icons
|
||||
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
||||
*/
|
||||
/* Tooltip container */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
|
||||
}
|
||||
|
||||
/* Tooltip text */
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchAll()">
|
||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchSelected()">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerms"
|
||||
@@ -17,7 +17,7 @@
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
@click="searchAll()"
|
||||
@click="searchSelected()"
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
@@ -41,8 +41,10 @@
|
||||
Nearby
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>{{ localCount }}</span
|
||||
v-if="isLocalActive"
|
||||
>
|
||||
{{ localCount > -1 ? localCount : "?" }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -56,57 +58,27 @@
|
||||
searchAll();
|
||||
"
|
||||
>
|
||||
Remote
|
||||
Anywhere
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>{{ remoteCount }}</span
|
||||
v-if="isRemoteActive"
|
||||
>
|
||||
{{ remoteCount > -1 ? remoteCount : "?" }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocalActive">
|
||||
<div v-if="!isChoosingSearchBox">
|
||||
<div>
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="isChoosingSearchBox = true"
|
||||
@click="$router.push({ name: 'search-area' })"
|
||||
>
|
||||
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
||||
Choose Location Below for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="storeSearchBox"
|
||||
>
|
||||
Store This Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="forgetSearchBox"
|
||||
>
|
||||
Delete Stored Location
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="resetLatLong"
|
||||
>
|
||||
Reset Marker
|
||||
</button>
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="cancelSearchBoxSelect"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
@@ -118,7 +90,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData" v-if="!isChoosingSearchBox">
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
@@ -150,50 +122,11 @@
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
|
||||
<div
|
||||
v-if="isLocalActive && isChoosingSearchBox"
|
||||
style="height: 600px; width: 800px"
|
||||
>
|
||||
<l-map
|
||||
ref="map"
|
||||
:center="[localCenterLat, localCenterLong]"
|
||||
v-model:zoom="localZoom"
|
||||
@click="setMapPoint"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="isNewMarkerSet"
|
||||
:lat-lng="[localCenterLat, localCenterLong]"
|
||||
@click="isNewMarkerSet = false"
|
||||
/>
|
||||
<l-rectangle
|
||||
v-if="isNewMarkerSet"
|
||||
:bounds="[
|
||||
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
||||
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
||||
]"
|
||||
:weight="1"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
@@ -204,10 +137,6 @@ import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
|
||||
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
||||
const WORLD_ZOOM = 2;
|
||||
const DEFAULT_ZOOM = 2;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
@@ -217,13 +146,9 @@ interface Notification {
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LRectangle,
|
||||
QuickNav,
|
||||
InfiniteScroll,
|
||||
EntityIcon,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class DiscoverView extends Vue {
|
||||
@@ -235,19 +160,12 @@ export default class DiscoverView extends Vue {
|
||||
apiServer = "";
|
||||
searchTerms = "";
|
||||
projects: ProjectData[] = [];
|
||||
isChoosingSearchBox = false;
|
||||
isLoading = false;
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
isNewMarkerSet = false;
|
||||
localCenterLat = 0;
|
||||
localCenterLong = 0;
|
||||
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localCount = 0;
|
||||
localZoom = DEFAULT_ZOOM;
|
||||
remoteCount = 0;
|
||||
localCount = -1;
|
||||
remoteCount = -1;
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
isLoading = false;
|
||||
|
||||
// make this function available to the Vue template
|
||||
didInfo = didInfo;
|
||||
@@ -258,7 +176,6 @@ export default class DiscoverView extends Vue {
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||
this.resetLatLong();
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
@@ -266,7 +183,26 @@ export default class DiscoverView extends Vue {
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
this.searchLocal();
|
||||
if (this.searchBox) {
|
||||
await this.searchLocal();
|
||||
} else {
|
||||
this.isLocalActive = false;
|
||||
this.isRemoteActive = true;
|
||||
await this.searchAll();
|
||||
}
|
||||
}
|
||||
|
||||
public resetCounts() {
|
||||
this.localCount = -1;
|
||||
this.remoteCount = -1;
|
||||
}
|
||||
|
||||
public async searchSelected() {
|
||||
if (this.isLocalActive) {
|
||||
await this.searchLocal();
|
||||
} else {
|
||||
await this.searchAll();
|
||||
}
|
||||
}
|
||||
|
||||
public async buildHeaders(): Promise<HeadersInit> {
|
||||
@@ -294,6 +230,13 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
|
||||
public async searchAll(beforeId?: string) {
|
||||
this.resetCounts();
|
||||
|
||||
if (!beforeId) {
|
||||
// this was an initial search so clear any previous results
|
||||
this.projects = [];
|
||||
}
|
||||
|
||||
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
if (beforeId) {
|
||||
@@ -356,13 +299,21 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
|
||||
public async searchLocal(beforeId?: string) {
|
||||
this.resetCounts();
|
||||
|
||||
if (!this.searchBox) {
|
||||
this.projects = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!beforeId) {
|
||||
// this was an initial search so clear any previous results
|
||||
this.projects = [];
|
||||
}
|
||||
|
||||
const claimContents =
|
||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
let queryParams = [
|
||||
claimContents,
|
||||
"minLocLat=" + this.searchBox.bbox.minLat,
|
||||
@@ -407,9 +358,7 @@ export default class DiscoverView extends Vue {
|
||||
const plans: ProjectData[] = results.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId = plan.handleId, rowid } = plan;
|
||||
if (beforeId !== plan["rowid"]) {
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
} else {
|
||||
this.projects = results.data;
|
||||
@@ -457,133 +406,11 @@ export default class DiscoverView extends Vue {
|
||||
onClickLoadProject(id: string) {
|
||||
localStorage.setItem("projectId", id);
|
||||
const route = {
|
||||
name: "project",
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
setMapPoint(event: LeafletMouseEvent) {
|
||||
if (this.isNewMarkerSet) {
|
||||
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
||||
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
||||
} else {
|
||||
// marker is not set
|
||||
this.localCenterLat = event.latlng.lat;
|
||||
this.localCenterLong = event.latlng.lng;
|
||||
|
||||
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
// Guess at a size for the bounding box.
|
||||
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
|
||||
const bounds = event.target.boxZoom?._map?.getBounds();
|
||||
if (bounds) {
|
||||
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
|
||||
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
|
||||
}
|
||||
this.localLatDiff = latDiff;
|
||||
this.localLongDiff = longDiff;
|
||||
this.isNewMarkerSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
public resetLatLong() {
|
||||
if (this.searchBox?.bbox) {
|
||||
const bbox = this.searchBox.bbox;
|
||||
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
||||
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
||||
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
this.isNewMarkerSet = true;
|
||||
} else {
|
||||
this.isNewMarkerSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async storeSearchBox() {
|
||||
if (this.localCenterLong || this.localCenterLat) {
|
||||
try {
|
||||
const newSearchBox = {
|
||||
name: "Local",
|
||||
bbox: {
|
||||
eastLong: this.localCenterLong + this.localLongDiff,
|
||||
maxLat: this.localCenterLat + this.localLatDiff,
|
||||
minLat: this.localCenterLat - this.localLatDiff,
|
||||
westLong: this.localCenterLong - this.localLongDiff,
|
||||
},
|
||||
};
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [newSearchBox],
|
||||
});
|
||||
this.searchBox = newSearchBox;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.searchLocal();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "No Location Selected",
|
||||
text: "Select a location on the map.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forgetSearchBox() {
|
||||
try {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [],
|
||||
});
|
||||
this.searchBox = null;
|
||||
this.localCenterLat = 0;
|
||||
this.localCenterLong = 0;
|
||||
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localZoom = DEFAULT_ZOOM;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.isNewMarkerSet = false;
|
||||
this.searchLocal();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelSearchBoxSelect() {
|
||||
this.isChoosingSearchBox = false;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
}
|
||||
|
||||
public computedLocalTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
|
||||
87
src/views/HelpNotificationsView.vue
Normal file
87
src/views/HelpNotificationsView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Notification Help
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>Here are things to try to get notifications working.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">Test</h2>
|
||||
<p>Somehow call the service-worker self.showNotification</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">Check OS-level permissions</h2>
|
||||
<div>
|
||||
Walk-throughs & screenshots, maybe for all combinations of OS &
|
||||
browsers.
|
||||
<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>See the browser section.</div>
|
||||
|
||||
<h3 class="text-lg font-semibold">Desktop - Mac</h3>
|
||||
<div>Requires Mac OS 13.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">Check browser-level permissions</h2>
|
||||
<p>Walk-throughs & screenshots for browser settings</p>
|
||||
<div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Mobile Phone - Apple iOS</h3>
|
||||
<div>Make sure your OS (above) supports it.</div>
|
||||
|
||||
<h3 class="text-lg font-semibold">Mobile Phone - Android</h3>
|
||||
<div>Chrome requires version 50.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">Explain full reset to start again</h2>
|
||||
<p>
|
||||
Walk-throughs for clearing everything & subscribing anew to get a
|
||||
message
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">Auto-detection</h2>
|
||||
<p>Show results of auto-detection whether they're turned on</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class HelpNotificationsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Help
|
||||
</h1>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Help
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
@@ -15,7 +29,7 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the philosophy here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow a gifting society.
|
||||
We are building networks of people who want to grow a giving society.
|
||||
First of all, you can record ways you've seen people give, and that
|
||||
leaves a permanent record -- one that came from you, and the recipient
|
||||
can prove it was for them. This is personally gratifying, but it extends
|
||||
@@ -36,19 +50,37 @@
|
||||
the control; this app gives you the control.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I take my first action?</h2>
|
||||
<h2 class="text-xl font-semibold">How do I get started?</h2>
|
||||
<p>
|
||||
You need someone to register you -- usually the person who told you
|
||||
about this app, on the Contacts
|
||||
<fa icon="circle-user" class="fa-fw" /> page. After they register you,
|
||||
and after you have contacts, you can select any contact on the home page
|
||||
and record your appreciation for... whatever. That is a claim recorded
|
||||
on a custom ledger. The day after being registered, you'll be able to
|
||||
<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
|
||||
appreciation for... whatever. The main goal is to record what people
|
||||
have given you, to grow giving economies. Each claim is recorded on a
|
||||
custom ledger. The day after being registered, you'll be able to able to
|
||||
register others; later, you can create projects, too.
|
||||
</p>
|
||||
<p>
|
||||
Note that there are limits to how many each person can register, so you
|
||||
may have to wait.
|
||||
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>
|
||||
<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, 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>
|
||||
@@ -112,25 +144,20 @@
|
||||
How do I restore my other (non-identifier-secret) data?
|
||||
</h2>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>Make sure you have your backup file (above), then contact us.</li>
|
||||
<li>
|
||||
Make sure you have your backup file (above), then contact us with
|
||||
your interest. This is functionality that has to be written, and
|
||||
your interest will help us prioritize it, but there are also manual
|
||||
ways to restore your data.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I add someone to my contacts?
|
||||
</h2>
|
||||
<p>
|
||||
Tell them to copy their ID, which typically starts with "did:ethr:...",
|
||||
and send it to you. Go to the Contacts
|
||||
<fa icon="circle-user" class="fa-fw" /> page and enter that into the top
|
||||
form. You may add a name by adding a comma followed by their name; you
|
||||
may also add their public key by adding another comma followed by the
|
||||
key.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
||||
<p>
|
||||
Go
|
||||
Before doing this, note that it is an advanced feature that affects
|
||||
functionality (eg. the words "Alt ID" next to results, backup features)
|
||||
so beware. You can
|
||||
<router-link to="start" class="text-blue-500">
|
||||
create another identity here.
|
||||
</router-link>
|
||||
@@ -148,10 +175,25 @@
|
||||
<fa icon="eye-slash" class="fa-fw" />.
|
||||
</p>
|
||||
<p>
|
||||
Sometimes the reason you don't see something is because the search time
|
||||
is 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.
|
||||
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">
|
||||
How do I access even more functionality?
|
||||
</h2>
|
||||
<p>
|
||||
There is an "Advanced" section at the bottom of the Account
|
||||
<fa icon="circle-user" /> page.
|
||||
</p>
|
||||
<p>
|
||||
There is a even more functionality in a mobile app (and more
|
||||
documentation) at
|
||||
<a href="https://endorser.ch" class="text-blue-500">
|
||||
EndorserSearch.com
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
|
||||
@@ -162,17 +204,24 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
||||
<p>
|
||||
{{ package.version }}
|
||||
This is part of the
|
||||
<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>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
For any other questions, including remove your data:
|
||||
For any other questions, including removing your data:
|
||||
</h2>
|
||||
<p>
|
||||
Contact us through
|
||||
<a href="https://communitycred.org">CommunityCred.org</a>.
|
||||
Contact us at
|
||||
<a mailto="info@TimeSafari.app">info@TimeSafari.app</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -183,8 +232,31 @@ import * as Package from "../../package.json";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
package = Package;
|
||||
commitHash = process.env.VUE_APP_GIT_HASH;
|
||||
|
||||
showOnboardInfo() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Onboard Someone",
|
||||
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, preferably).
|
||||
text: "1) Check that they've entered their name. 2) Go to the scanning page via the Identity page and then the through the QR icon at the top, and then scan and register them. 3) Have them go to that page and scan you.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,215 +6,156 @@
|
||||
Time Safari
|
||||
</h1>
|
||||
|
||||
<!-- show the actions for recognizing a give -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
text: 'I\'m a toast. Don\'t mind me.',
|
||||
},
|
||||
5000,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Toast (self-dismiss)
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Information Alert',
|
||||
text: 'Just wanted you to know.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Success Alert',
|
||||
text: 'Congratulations!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'warning',
|
||||
title: 'Warning Alert',
|
||||
text: 'You might wanna look at this.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Danger Alert',
|
||||
text: 'Something terrible has happened!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Danger
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notification Permission
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold">Quick Action</h2>
|
||||
<p class="mb-4">Record a gift from a contact:</p>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<EntityIcon
|
||||
:entityId="Anonymous"
|
||||
: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"
|
||||
>
|
||||
Anonymous
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length > 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
|
||||
<!-- If there are no contacts, show this instead: -->
|
||||
<div
|
||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||
v-if="allContacts.length === 0"
|
||||
v-if="!activeDid"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
(No contacts to show.)
|
||||
<p class="text-lg mb-3">
|
||||
You need an <b>identifier</b> before you can record anyone's gives.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Create Your Identifier</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isRegistered"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
Someone must register your account before you can record anyone's gives.
|
||||
To do this:
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
1. Show Them Your Identity Info</router-link
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
2. Check Your Limits</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- activeDid && isRegistered -->
|
||||
<h2 class="text-xl font-bold mb-4">Record Something Given</h2>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<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"
|
||||
>
|
||||
Anonymous/Unnamed
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
|
||||
<!-- If there are no contacts, show this instead: -->
|
||||
<div
|
||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||
v-if="allContacts.length === 0"
|
||||
>
|
||||
(No contacts to show.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
@dialog-result="handleDialogResult"
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
showGivenToUser="true"
|
||||
/>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300 py-2"
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||
v-if="record.jwtId == feedLastViewedClaimId"
|
||||
>
|
||||
You've seen all the following
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
|
||||
<span class="">{{ this.giveDescription(record) }}</span>
|
||||
<a @click="onClickLoadClaim(record.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
<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…
|
||||
</p>
|
||||
</div>
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300 py-2"
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||
v-if="record.jwtId == feedLastViewedId"
|
||||
>
|
||||
You've seen all claims below:
|
||||
</div>
|
||||
<div class="flex">
|
||||
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
|
||||
<!-- icon values: "coins" = money; "clock" = time; "gift" = others -->
|
||||
<span class="">{{ this.giveDescription(record) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
GiveServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
@@ -225,7 +166,7 @@ interface Notification {
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
components: { GiftedDialog, QuickNav, EntityIcon, InfiniteScroll },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
@@ -234,11 +175,11 @@ export default class HomeView extends Vue {
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
feedAllLoaded = false;
|
||||
feedData = [];
|
||||
feedPreviousOldestId?: string;
|
||||
feedLastViewedId?: string;
|
||||
feedLastViewedClaimId?: string;
|
||||
isHiddenSpinner = true;
|
||||
isRegistered = false;
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
@@ -248,18 +189,12 @@ export default class HomeView extends Vue {
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
.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;
|
||||
return identity; // may be null
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
@@ -278,12 +213,16 @@ export default class HomeView extends Vue {
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
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.feedLastViewedId = settings?.lastViewedClaimId;
|
||||
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
// this returns a Promise but we don't need to wait for it
|
||||
this.updateAllFeed();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
this.$notify(
|
||||
@@ -308,7 +247,9 @@ export default class HomeView extends Vue {
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const account = allAccounts.find(
|
||||
(acc) => acc.did === this.activeDid,
|
||||
) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
@@ -324,24 +265,33 @@ export default class HomeView extends Vue {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
public async loadMoreGives(payload: boolean) {
|
||||
if (payload) {
|
||||
this.updateAllFeed();
|
||||
}
|
||||
}
|
||||
|
||||
public async updateAllFeed() {
|
||||
this.isHiddenSpinner = false;
|
||||
await this.retrieveClaims(this.apiServer, this.feedPreviousOldestId)
|
||||
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
this.feedData = this.feedData.concat(results.data);
|
||||
this.feedAllLoaded = results.hitLimit;
|
||||
this.feedPreviousOldestId =
|
||||
results.data[results.data.length - 1].jwtId;
|
||||
// The following update is only done on the first load.
|
||||
if (
|
||||
this.feedLastViewedId == null ||
|
||||
this.feedLastViewedId < results.data[0].jwtId
|
||||
this.feedLastViewedClaimId == null ||
|
||||
this.feedLastViewedClaimId < results.data[0].jwtId
|
||||
) {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: results.data[0].jwtId,
|
||||
});
|
||||
// but not for this page because we need to remember what it was before
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -351,17 +301,22 @@ export default class HomeView extends Vue {
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
title: "Feed Error",
|
||||
text: e.userMessage || "There was an error retrieving feed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
this.isHiddenSpinner = true;
|
||||
}
|
||||
|
||||
public async retrieveClaims(endorserApiServer: string, beforeId?: string) {
|
||||
/**
|
||||
* Retrieve claims in reverse chronological order
|
||||
*
|
||||
* @param beforeId the earliest ID (of previous searches) to search earlier
|
||||
* @return claims in reverse chronological order
|
||||
*/
|
||||
public async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
const response = await fetch(
|
||||
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
|
||||
@@ -385,6 +340,7 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -397,9 +353,18 @@ export default class HomeView extends Vue {
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
const gaveAmount = claim.object?.amountOfThisGood
|
||||
let gaveAmount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: claim.description || "something unknown";
|
||||
: "";
|
||||
if (claim.description) {
|
||||
if (gaveAmount) {
|
||||
gaveAmount = gaveAmount + ", and also: ";
|
||||
}
|
||||
gaveAmount = gaveAmount + claim.description;
|
||||
}
|
||||
if (!gaveAmount) {
|
||||
gaveAmount = "something not described";
|
||||
}
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const gaveRecipientId =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -413,7 +378,14 @@ export default class HomeView extends Vue {
|
||||
this.allContacts,
|
||||
)
|
||||
: "";
|
||||
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
|
||||
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
|
||||
}
|
||||
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
displayAmount(code: string, amt: number) {
|
||||
@@ -427,124 +399,5 @@ export default class HomeView extends Vue {
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was "cancel" so do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
) {
|
||||
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 && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
);
|
||||
|
||||
if (this.isGiveCreationError(result)) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give caught:", error);
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return result.data?.error?.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<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">
|
||||
{{ firstName }} {{ lastName }}
|
||||
{{ givenName }}
|
||||
</h2>
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ activeDid }}</code>
|
||||
@@ -49,7 +49,9 @@
|
||||
</ul>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- id used by puppeteer test script -->
|
||||
<router-link
|
||||
id="start-link"
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
@@ -69,7 +71,7 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Notification {
|
||||
@@ -88,8 +90,7 @@ export default class IdentitySwitcherView extends Vue {
|
||||
public activeDid = "";
|
||||
public apiServer = "";
|
||||
public apiServerInput = "";
|
||||
public firstName = "";
|
||||
public lastName = "";
|
||||
public givenName = "";
|
||||
public otherIdentities: Array<{ did: string }> = [];
|
||||
public showContactGives = false;
|
||||
|
||||
@@ -99,19 +100,20 @@ export default class IdentitySwitcherView extends Vue {
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
const identity = JSON.parse((account?.identity as string) || "null");
|
||||
return identity;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.firstName = settings?.firstName || "No";
|
||||
this.lastName = settings?.lastName || "Name";
|
||||
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);
|
||||
@@ -149,7 +151,7 @@ export default class IdentitySwitcherView extends Vue {
|
||||
did = undefined;
|
||||
}
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: did,
|
||||
});
|
||||
this.activeDid = did || "";
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Enter your seed phrase below to import your identity on this device.
|
||||
</p>
|
||||
<!-- id used by puppeteer test script -->
|
||||
<input
|
||||
id="seed-input"
|
||||
type="text"
|
||||
placeholder="Seed Phrase"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
@@ -74,7 +76,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
|
||||
|
||||
mnemonic = "";
|
||||
address = "";
|
||||
|
||||
@@ -10,21 +10,15 @@
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
[New/Edit] Identity
|
||||
Edit Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
placeholder="Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="firstName"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="lastName"
|
||||
v-model="givenName"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
@@ -50,37 +44,31 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class NewEditAccountView extends Vue {
|
||||
firstName =
|
||||
localStorage.getItem("firstName") === null
|
||||
? "--"
|
||||
: localStorage.getItem("firstName");
|
||||
lastName =
|
||||
localStorage.getItem("lastName") === null
|
||||
? "--"
|
||||
: localStorage.getItem("lastName");
|
||||
givenName = "";
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.firstName = settings?.firstName || "";
|
||||
this.lastName = settings?.lastName || "";
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.givenName =
|
||||
(settings?.firstName || "") +
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
|
||||
}
|
||||
|
||||
onClickSaveChanges() {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.firstName,
|
||||
lastName: this.lastName,
|
||||
firstName: this.givenName,
|
||||
lastName: "", // deprecated, pre v 0.1.3
|
||||
});
|
||||
localStorage.setItem("firstName", this.firstName as string);
|
||||
localStorage.setItem("lastName", this.lastName as string);
|
||||
this.$router.push({ name: "account" });
|
||||
localStorage.setItem("firstName", this.givenName as string);
|
||||
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
@@ -10,7 +11,7 @@
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
[New/Edit] Plan
|
||||
Edit Idea
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -23,32 +24,43 @@
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Project Name"
|
||||
placeholder="Idea Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="projectName"
|
||||
v-model="fullClaim.name"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
rows="5"
|
||||
v-model="description"
|
||||
maxlength="500"
|
||||
v-model="fullClaim.description"
|
||||
maxlength="5000"
|
||||
></textarea>
|
||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||
{{ description.length }}/500 max. characters
|
||||
{{ fullClaim.description?.length }}/5000 max. characters
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="fullClaim.url"
|
||||
placeholder="Website"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
v-model="includeLocation"
|
||||
@change="includeLocation = true"
|
||||
@click="includeLocation = !includeLocation"
|
||||
/>
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
||||
<div class="px-2 py-2">
|
||||
For your security, we recommend you choose a location nearby but not
|
||||
exactly at the place.
|
||||
</div>
|
||||
|
||||
<l-map
|
||||
ref="map"
|
||||
v-model:zoom="zoom"
|
||||
@@ -66,7 +78,7 @@
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="latitude || longitude"
|
||||
v-if="latitude && longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="maybeEraseLatLong()"
|
||||
/>
|
||||
@@ -107,6 +119,7 @@ import * as didJwt from "did-jwt";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
@@ -122,20 +135,24 @@ interface Notification {
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { LMap, LMarker, LTileLayer },
|
||||
components: { LMap, LMarker, LTileLayer, QuickNav },
|
||||
})
|
||||
export default class NewEditProjectView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
description = "";
|
||||
errorMessage = "";
|
||||
fullClaim: PlanVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PlanAction",
|
||||
name: "",
|
||||
description: "",
|
||||
}; // this default is only to avoid errors before plan is loaded
|
||||
includeLocation = false;
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
numAccounts = 0;
|
||||
projectName = "";
|
||||
zoom = 2;
|
||||
|
||||
async beforeCreate() {
|
||||
@@ -207,9 +224,12 @@ export default class NewEditProjectView extends Vue {
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
const claim = resp.data.claim;
|
||||
this.projectName = claim.name;
|
||||
this.description = claim.description;
|
||||
this.fullClaim = resp.data.claim;
|
||||
if (this.fullClaim?.location) {
|
||||
this.includeLocation = true;
|
||||
this.latitude = this.fullClaim.location.geo.latitude;
|
||||
this.longitude = this.fullClaim.location.geo.longitude;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got error retrieving that project", error);
|
||||
@@ -218,13 +238,7 @@ export default class NewEditProjectView extends Vue {
|
||||
|
||||
private async SaveProject(identity: IIdentifier) {
|
||||
// Make a claim
|
||||
const vcClaim: PlanVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PlanAction",
|
||||
name: this.projectName,
|
||||
description: this.description,
|
||||
identifier: this.projectId || undefined,
|
||||
};
|
||||
const vcClaim: PlanVerifiableCredential = this.fullClaim;
|
||||
if (this.projectId) {
|
||||
vcClaim.identifier = this.projectId;
|
||||
}
|
||||
@@ -286,6 +300,20 @@ export default class NewEditProjectView extends Vue {
|
||||
2000,
|
||||
this,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Got unexpected 'data' inside response from server",
|
||||
resp,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Saving Idea",
|
||||
text: "Server did not save the idea. Try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error saving the project.";
|
||||
@@ -293,8 +321,8 @@ export default class NewEditProjectView extends Vue {
|
||||
error?: { message?: string };
|
||||
}>;
|
||||
if (serverError) {
|
||||
console.log("Got error from server", serverError);
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
console.log(serverError);
|
||||
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Identity
|
||||
</h1>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center py-12">
|
||||
<span />
|
||||
@@ -46,7 +60,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class AccountViewView extends Vue {
|
||||
export default class NewIdentifierView extends Vue {
|
||||
loading = true;
|
||||
|
||||
async mounted() {
|
||||
@@ -74,7 +88,7 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
this.loading = false;
|
||||
setTimeout(() => {
|
||||
this.$router.push({ name: "account" });
|
||||
this.$router.push({ name: "home" });
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<QuickNav />
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
View Plan
|
||||
Idea
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ issuer }}
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="timeSince">
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ timeSince }}
|
||||
</div>
|
||||
@@ -45,8 +45,13 @@
|
||||
:href="getOpenStreetMapUrl()"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>
|
||||
Map View
|
||||
>Map View
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="url">
|
||||
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
||||
<a :href="addScheme(url)" target="_blank" class="underline"
|
||||
>{{ domainForWebsite(this.url) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,8 +61,11 @@
|
||||
<div class="text-sm text-slate-500">
|
||||
<div v-if="!expanded">
|
||||
{{ truncatedDesc }}
|
||||
<a v-if="description.length >= truncateLength" @click="expandText"
|
||||
>Read More</a
|
||||
<a
|
||||
v-if="description.length >= truncateLength"
|
||||
@click="expandText"
|
||||
class="uppercase text-xs font-semibold text-slate-700"
|
||||
>... Read More</a
|
||||
>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -65,7 +73,7 @@
|
||||
<a
|
||||
@click="collapseText"
|
||||
class="uppercase text-xs font-semibold text-slate-700"
|
||||
>Read Less</a
|
||||
>- Read Less</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,35 +88,45 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="activeDid" class="text-center">
|
||||
<div v-if="activeDid" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="openDialog({ name: 'you', did: activeDid })"
|
||||
@click="openOfferDialog({ name: 'you', did: activeDid })"
|
||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
I offer…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeDid">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="openGiftDialog({ name: 'you', did: activeDid })"
|
||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
I gave…
|
||||
</button>
|
||||
<p class="mt-2 mb-4 text-center">Or, record a gift from:</p>
|
||||
<p class="mt-2 mb-4 text-center">Or, record a contribution from:</p>
|
||||
</div>
|
||||
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<li @click="openGiftDialog()">
|
||||
<EntityIcon
|
||||
:entityId="Anonymous"
|
||||
: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"
|
||||
>
|
||||
Anonymous
|
||||
Anonymous/Unnamed
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
@click="openGiftDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
@@ -125,7 +143,7 @@
|
||||
|
||||
<!-- 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"
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
@@ -134,13 +152,51 @@
|
||||
</div>
|
||||
|
||||
<!-- Gifts to & from this -->
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Given to this Project
|
||||
Offered To This Idea
|
||||
</h3>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<div v-if="offersToThis.length === 0">
|
||||
(None yet. Record one above.)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="offer in offersToThis"
|
||||
:key="offer.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span>
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<a @click="onClickLoadClaim(offer.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
<span v-if="offer.amount">
|
||||
<fa
|
||||
:icon="iconForUnitCode(offer.unit)"
|
||||
class="fa-fw text-slate-400"
|
||||
/>{{ offer.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="offer.objectDescription" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ offer.objectDescription }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
|
||||
|
||||
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="give in givesToThis"
|
||||
:key="give.id"
|
||||
@@ -151,9 +207,14 @@
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<span v-if="give.amount"
|
||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.amount }}
|
||||
<a @click="onClickLoadClaim(give.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
<span v-if="give.amount">
|
||||
<fa
|
||||
:icon="iconForUnitCode(give.unit)"
|
||||
class="fa-fw text-slate-400"
|
||||
/>{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
@@ -164,42 +225,52 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
…and from this Project
|
||||
</h3>
|
||||
<div class="grid items-start grid-cols-1 gap-4">
|
||||
<div
|
||||
v-if="fulfillersToThis.length > 0"
|
||||
class="bg-slate-100 px-4 py-3 rounded-md"
|
||||
>
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Contributions To This Idea
|
||||
</h3>
|
||||
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||
<div class="text-center">
|
||||
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
|
||||
<button
|
||||
@click="onClickLoadProject(plan.handleId)"
|
||||
class="text-blue-500"
|
||||
>
|
||||
{{ plan.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="give in givesByThis"
|
||||
:key="give.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<span v-if="give.amount"
|
||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.description }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Contributions By This Idea
|
||||
</h3>
|
||||
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
||||
class="text-blue-500"
|
||||
>
|
||||
{{ fulfilledByThis.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
@dialog-result="handleDialogResult"
|
||||
ref="customGiveDialog"
|
||||
message="Received from"
|
||||
:projectId="this.projectId"
|
||||
>
|
||||
</GiftedDialog>
|
||||
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
|
||||
</OfferDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -210,20 +281,22 @@ import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { isGlobalUri } from "@/libs/util";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
GiveServerRecord,
|
||||
ResultWithType,
|
||||
OfferServerRecord,
|
||||
PlanServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
@@ -233,7 +306,7 @@ interface Notification {
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
||||
})
|
||||
export default class ProjectViewView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
@@ -244,20 +317,23 @@ export default class ProjectViewView extends Vue {
|
||||
apiServer = "";
|
||||
description = "";
|
||||
expanded = false;
|
||||
fulfilledByThis: PlanServerRecord | null = null;
|
||||
fulfillersToThis: Array<PlanServerRecord> = [];
|
||||
givesToThis: Array<GiveServerRecord> = [];
|
||||
givesByThis: Array<GiveServerRecord> = [];
|
||||
issuer = "";
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
name = "";
|
||||
issuer = "";
|
||||
offersToThis: Array<OfferServerRecord> = [];
|
||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||
timeSince = "";
|
||||
truncatedDesc = "";
|
||||
truncateLength = 40;
|
||||
url = "";
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
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();
|
||||
@@ -266,24 +342,23 @@ export default class ProjectViewView extends Vue {
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
const account = accountsArr?.find((acc) => acc.did === this.activeDid);
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
this.LoadProject(identity);
|
||||
|
||||
const pathParam = window.location.pathname.substring("/project/".length);
|
||||
if (pathParam) {
|
||||
this.projectId = decodeURIComponent(pathParam);
|
||||
}
|
||||
this.LoadProject(this.projectId, identity);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -322,11 +397,11 @@ export default class ProjectViewView extends Vue {
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
async LoadProject(identity: IIdentifier) {
|
||||
async LoadProject(projectId: string, identity: IIdentifier) {
|
||||
this.projectId = projectId;
|
||||
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/claim/byHandle/" +
|
||||
encodeURIComponent(this.projectId);
|
||||
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@@ -350,19 +425,22 @@ export default class ProjectViewView extends Vue {
|
||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
||||
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
||||
} else if (resp.status === 404) {
|
||||
// actually, axios throws an error so we never get here
|
||||
this.url = resp.data.claim?.url || "";
|
||||
} else {
|
||||
// actually, axios throws an error on 404 so we probably never get here
|
||||
console.log("Error getting project:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "That project does not exist.",
|
||||
text: "There was a problem getting that project. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error retrieving project:", error);
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.response?.status === 404) {
|
||||
this.$notify(
|
||||
@@ -384,14 +462,13 @@ export default class ProjectViewView extends Vue {
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Error retrieving project:", serverError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const givesInUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/givesForPlans?planIds=" +
|
||||
encodeURIComponent(JSON.stringify([this.projectId]));
|
||||
encodeURIComponent(JSON.stringify([projectId]));
|
||||
try {
|
||||
const resp = await this.axios.get(givesInUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
@@ -424,21 +501,21 @@ export default class ProjectViewView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
const givesOutUrl =
|
||||
const offersToUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/givesProvidedBy?providerId=" +
|
||||
encodeURIComponent(this.projectId);
|
||||
"/api/v2/report/offersToPlans?planIds=" +
|
||||
encodeURIComponent(JSON.stringify([projectId]));
|
||||
try {
|
||||
const resp = await this.axios.get(givesOutUrl, { headers });
|
||||
const resp = await this.axios.get(offersToUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.givesByThis = resp.data.data;
|
||||
this.offersToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve gives by this project.",
|
||||
text: "Failed to retrieve offers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -450,20 +527,100 @@ export default class ProjectViewView extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving gives by project.",
|
||||
text: "Something went wrong retrieving offers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving gives by this project:",
|
||||
"Error retrieving offers to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const fulfilledByUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
||||
encodeURIComponent(projectId);
|
||||
try {
|
||||
const resp = await this.axios.get(fulfilledByUrl, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fulfilledByThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve plans fulfilled by this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving plans fulfilled by this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving plans fulfilled by this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const fulfillersToUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
||||
encodeURIComponent(projectId);
|
||||
try {
|
||||
const resp = await this.axios.get(fulfillersToUrl, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fulfillersToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve plan fulfillers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving plan fulfillers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving plan fulfillers to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(contact: GiverInputInfo) {
|
||||
const dialog: GiftedDialog = this.$refs.customDialog as GiftedDialog;
|
||||
dialog.open(contact);
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
**/
|
||||
async onClickLoadProject(projectId: string) {
|
||||
localStorage.setItem("projectId", projectId);
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(projectId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
this.LoadProject(projectId, await this.getIdentity(this.activeDid));
|
||||
}
|
||||
|
||||
getOpenStreetMapUrl() {
|
||||
@@ -480,95 +637,65 @@ export default class ProjectViewView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was not "confirm" so do nothing
|
||||
}
|
||||
openGiftDialog(contact: GiverInputInfo) {
|
||||
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
async recordGive(giverDid?: string, description?: string, hours?: number) {
|
||||
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;
|
||||
}
|
||||
openOfferDialog() {
|
||||
(this.$refs.customOfferDialog as OfferDialog).open();
|
||||
}
|
||||
|
||||
if (!description && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
this.projectId,
|
||||
);
|
||||
if (result.type == "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
UNIT_CODES: Record<string, Record<string, string>> = {
|
||||
BTC: {
|
||||
name: "Bitcoin",
|
||||
faIcon: "bitcoin-sign",
|
||||
},
|
||||
HUR: {
|
||||
name: "hours",
|
||||
faIcon: "clock",
|
||||
},
|
||||
USD: {
|
||||
name: "US Dollars",
|
||||
faIcon: "dollar",
|
||||
},
|
||||
};
|
||||
|
||||
iconForUnitCode(unitCode: string) {
|
||||
return this.UNIT_CODES[unitCode]?.faIcon || "question";
|
||||
}
|
||||
|
||||
// return an HTTPS URL if it's not a global URL
|
||||
addScheme(url: string) {
|
||||
if (!isGlobalUri(url)) {
|
||||
return "https://" + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// return just the domain for display, if possible
|
||||
domainForWebsite(url: string) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
if (!hostname) {
|
||||
// happens for non-http URLs
|
||||
return url;
|
||||
} else if (url.endsWith(hostname)) {
|
||||
// it's just the domain
|
||||
return hostname;
|
||||
} else {
|
||||
console.log("Error with give creation:", result);
|
||||
if (result.type != "error") {
|
||||
console.log(
|
||||
"... and it has an unexpected result type of",
|
||||
(result as ResultWithType).type,
|
||||
);
|
||||
}
|
||||
const message =
|
||||
result?.error?.userMessage ||
|
||||
"There was an error recording the Give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
// there's more, but don't bother displaying the whole thing
|
||||
return hostname + "...";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// must not be a valid URL
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Plans
|
||||
Your Ideas
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
@@ -98,8 +98,6 @@ export default class ProjectsView extends Vue {
|
||||
projects: ProjectData[] = [];
|
||||
current: IIdentifier;
|
||||
isLoading = false;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
@@ -176,7 +174,7 @@ export default class ProjectsView extends Vue {
|
||||
onClickLoadProject(id: string) {
|
||||
localStorage.setItem("projectId", id);
|
||||
const route = {
|
||||
name: "project",
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
286
src/views/SearchAreaView.vue
Normal file
286
src/views/SearchAreaView.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Area for Nearby Search
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-4">
|
||||
This location is only stored on your device. It is used to show you more
|
||||
appropriate projects but is not stored on any servers.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
||||
Click to Choose a Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="storeSearchBox"
|
||||
>
|
||||
Store This Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="forgetSearchBox"
|
||||
>
|
||||
Delete Stored Location
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="resetLatLong"
|
||||
>
|
||||
Reset Marker
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="isNewMarkerSet = false"
|
||||
>
|
||||
Erase Marker
|
||||
</button>
|
||||
<div v-if="isNewMarkerSet">
|
||||
Click on the pin to erase it. Click anywhere else to set a different
|
||||
different corner.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 600px; width: 800px">
|
||||
<l-map
|
||||
ref="map"
|
||||
:center="[localCenterLat, localCenterLong]"
|
||||
v-model:zoom="localZoom"
|
||||
@click="setMapPoint"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="isNewMarkerSet"
|
||||
:lat-lng="[localCenterLat, localCenterLong]"
|
||||
@click="isNewMarkerSet = false"
|
||||
/>
|
||||
<l-rectangle
|
||||
v-if="isNewMarkerSet"
|
||||
:bounds="[
|
||||
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
||||
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
||||
]"
|
||||
:weight="1"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { db } from "@/db/index";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
||||
const WORLD_ZOOM = 2;
|
||||
const DEFAULT_ZOOM = 2;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
LRectangle,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class DiscoverView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
isChoosingSearchBox = false;
|
||||
isNewMarkerSet = false;
|
||||
|
||||
// "local" vars are for the currently selected map box
|
||||
localCenterLat = 0;
|
||||
localCenterLong = 0;
|
||||
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localZoom = DEFAULT_ZOOM;
|
||||
|
||||
// searchBox reflects what is stored in the database
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||
this.resetLatLong();
|
||||
}
|
||||
|
||||
setMapPoint(event: LeafletMouseEvent) {
|
||||
if (this.isNewMarkerSet) {
|
||||
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
||||
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
||||
} else {
|
||||
// marker is not set
|
||||
this.localCenterLat = event.latlng.lat;
|
||||
this.localCenterLong = event.latlng.lng;
|
||||
|
||||
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
// Guess at a size for the bounding box.
|
||||
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
|
||||
const bounds = event.target.boxZoom?._map?.getBounds();
|
||||
if (bounds) {
|
||||
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
|
||||
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
|
||||
}
|
||||
this.localLatDiff = latDiff;
|
||||
this.localLongDiff = longDiff;
|
||||
this.isNewMarkerSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
public resetLatLong() {
|
||||
if (this.searchBox?.bbox) {
|
||||
const bbox = this.searchBox.bbox;
|
||||
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
||||
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
||||
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
this.isNewMarkerSet = true;
|
||||
} else {
|
||||
this.isNewMarkerSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async storeSearchBox() {
|
||||
if (this.localCenterLong || this.localCenterLat) {
|
||||
try {
|
||||
const newSearchBox = {
|
||||
name: "Local",
|
||||
bbox: {
|
||||
eastLong: this.localCenterLong + this.localLongDiff,
|
||||
maxLat: this.localCenterLat + this.localLatDiff,
|
||||
minLat: this.localCenterLat - this.localLatDiff,
|
||||
westLong: this.localCenterLong - this.localLongDiff,
|
||||
},
|
||||
};
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [newSearchBox],
|
||||
});
|
||||
this.searchBox = newSearchBox;
|
||||
this.isChoosingSearchBox = false;
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Saved",
|
||||
text: "That has been saved in your preferences.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.$router.back();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "No Location Selected",
|
||||
text: "Select a location on the map.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forgetSearchBox() {
|
||||
try {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [],
|
||||
});
|
||||
this.searchBox = null;
|
||||
this.localCenterLat = 0;
|
||||
this.localCenterLong = 0;
|
||||
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localZoom = DEFAULT_ZOOM;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.isNewMarkerSet = false;
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelSearchBoxSelect() {
|
||||
this.isChoosingSearchBox = false;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,12 +3,26 @@
|
||||
id="Content"
|
||||
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
|
||||
>
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Start Here
|
||||
</h1>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Start Here
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- id used by puppeteer test script -->
|
||||
<div id="start-question" class="mt-8">
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Do you have an identity to import?
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Achievements & Statistics
|
||||
</h1>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Achievements & Statistics
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Here is a view of the activity you can see.
|
||||
@@ -32,7 +46,7 @@
|
||||
{{ worldProperties.animationDurationSeconds }} seconds
|
||||
</div>
|
||||
</div>
|
||||
<button class="float-right" @click="captureGraphics()">Screenshot</button>
|
||||
<button class="float-right text-blue-600" @click="captureGraphics()">Screenshot</button>
|
||||
<div id="scene-container" class="h-screen"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
165
src/views/TestView.vue
Normal file
165
src/views/TestView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Test
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
text: 'I\'m a toast. Without a timeout, I\'m stuck.',
|
||||
},
|
||||
5000,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Toast
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Information Alert',
|
||||
text: 'Just wanted you to know.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Success Alert',
|
||||
text: 'Congratulations!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'warning',
|
||||
title: 'Warning Alert',
|
||||
text: 'You might wanna look at this.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Danger Alert',
|
||||
text: 'Something terrible has happened!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Danger
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif ON
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-mute',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif MUTE
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-off',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif OFF
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
69
sw_scripts/additional-scripts.js
Normal file
69
sw_scripts/additional-scripts.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/* eslint-env serviceworker */
|
||||
/* global workbox */
|
||||
importScripts(
|
||||
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
|
||||
);
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
console.log("Adding event listener for:", event);
|
||||
importScripts(
|
||||
"safari-notifications.js",
|
||||
"nacl.js",
|
||||
"noble-curves.js",
|
||||
"noble-hashes.js",
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("push", function (event) {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
let payload;
|
||||
if (event.data) {
|
||||
payload = JSON.parse(event.data.text());
|
||||
}
|
||||
const message = await self.getNotificationCount();
|
||||
if (message) {
|
||||
console.log("Will notify user:", message);
|
||||
const title = payload ? payload.title : "Custom Title";
|
||||
const options = {
|
||||
body: message,
|
||||
icon: payload ? payload.icon : "icon.png",
|
||||
badge: payload ? payload.badge : "badge.png",
|
||||
};
|
||||
await self.registration.showNotification(title, options);
|
||||
} else {
|
||||
console.log("No notification message, so will not tell the user.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing the push event:", error);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "SEND_LOCAL_DATA") {
|
||||
self.secret = event.data.data;
|
||||
event.ports[0].postMessage({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(clients.claim());
|
||||
console.log("Service worker activated", event);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
console.log("Got fetch event", event.request);
|
||||
});
|
||||
|
||||
self.addEventListener("error", (event) => {
|
||||
console.error("Error in Service Worker:", event.message);
|
||||
console.error("File:", event.filename);
|
||||
console.error("Line:", event.lineno);
|
||||
console.error("Column:", event.colno);
|
||||
console.error("Error Object:", event.error);
|
||||
});
|
||||
|
||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||
1051
sw_scripts/nacl.js
Normal file
1051
sw_scripts/nacl.js
Normal file
File diff suppressed because it is too large
Load Diff
5248
sw_scripts/noble-curves.js
Normal file
5248
sw_scripts/noble-curves.js
Normal file
File diff suppressed because it is too large
Load Diff
3068
sw_scripts/noble-hashes.js
Normal file
3068
sw_scripts/noble-hashes.js
Normal file
File diff suppressed because it is too large
Load Diff
537
sw_scripts/safari-notifications.js
Normal file
537
sw_scripts/safari-notifications.js
Normal file
@@ -0,0 +1,537 @@
|
||||
function bufferFromBase64(base64) {
|
||||
const binaryString = atob(base64);
|
||||
const length = binaryString.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function fromString(str, encoding = "utf8") {
|
||||
if (encoding === "utf8") {
|
||||
return new TextEncoder().encode(str);
|
||||
} else if (encoding === "base16") {
|
||||
if (str.length % 2 !== 0) {
|
||||
throw new Error("Invalid hex string length.");
|
||||
}
|
||||
let bytes = new Uint8Array(str.length / 2);
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
} else if (encoding === "base64url") {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) {
|
||||
str += "=";
|
||||
}
|
||||
return new Uint8Array(bufferFromBase64(str));
|
||||
} else {
|
||||
throw new Error(`Unsupported encoding "${encoding}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array to a string with the given encoding.
|
||||
*
|
||||
* @param {Uint8Array} byteArray - The Uint8Array to convert.
|
||||
* @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url').
|
||||
* @returns {string} - The encoded string.
|
||||
* @throws {Error} - Throws an error if the encoding is unsupported.
|
||||
*/
|
||||
function toString(byteArray, encoding = "utf8") {
|
||||
switch (encoding) {
|
||||
case "utf8":
|
||||
return decodeUTF8(byteArray);
|
||||
case "base16":
|
||||
return toBase16(byteArray);
|
||||
case "base64url":
|
||||
return toBase64Url(byteArray);
|
||||
default:
|
||||
throw new Error(`Unsupported encoding "${encoding}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Uint8Array as a UTF-8 string.
|
||||
*
|
||||
* @param {Uint8Array} byteArray
|
||||
* @returns {string}
|
||||
*/
|
||||
function decodeUTF8(byteArray) {
|
||||
return new TextDecoder().decode(byteArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array to a base16 (hex) encoded string.
|
||||
*
|
||||
* @param {Uint8Array} byteArray
|
||||
* @returns {string}
|
||||
*/
|
||||
function toBase16(byteArray) {
|
||||
return Array.from(byteArray)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array to a base64url encoded string.
|
||||
*
|
||||
* @param {Uint8Array} byteArray
|
||||
* @returns {string}
|
||||
*/
|
||||
function toBase64Url(byteArray) {
|
||||
let uint8Array = new Uint8Array(byteArray);
|
||||
let binaryString = "";
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
binaryString += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
let base64 = btoa(binaryString);
|
||||
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
const u8a = { toString, fromString };
|
||||
|
||||
function sha256(payload) {
|
||||
const data = typeof payload === "string" ? u8a.fromString(payload) : payload;
|
||||
return nobleHashes.sha256(data);
|
||||
}
|
||||
|
||||
async function accessToken(identifier) {
|
||||
const did = identifier["did"];
|
||||
const privateKeyHex = identifier["keys"][0]["privateKeyHex"];
|
||||
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + 60; // add one minute
|
||||
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
|
||||
const jwt = await createJWT(tokenPayload, {
|
||||
alg,
|
||||
issuer: did,
|
||||
signer,
|
||||
});
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async function createJWT(payload, options, header = {}) {
|
||||
const { issuer, signer, alg, expiresIn, canonicalize } = options;
|
||||
|
||||
if (!signer)
|
||||
throw new Error(
|
||||
"missing_signer: No Signer functionality has been configured",
|
||||
);
|
||||
if (!issuer)
|
||||
throw new Error("missing_issuer: No issuing DID has been configured");
|
||||
if (!header.typ) header.typ = "JWT";
|
||||
if (!header.alg) header.alg = alg;
|
||||
|
||||
const timestamps = {
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: undefined,
|
||||
};
|
||||
|
||||
if (expiresIn) {
|
||||
if (typeof expiresIn === "number") {
|
||||
timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn);
|
||||
} else {
|
||||
throw new Error("invalid_argument: JWT expiresIn is not a number");
|
||||
}
|
||||
}
|
||||
|
||||
const fullPayload = { ...timestamps, ...payload, iss: issuer };
|
||||
return createJWS(fullPayload, signer, header, { canonicalize });
|
||||
}
|
||||
|
||||
const defaultAlg = "ES256K";
|
||||
|
||||
async function createJWS(payload, signer, header = {}, options = {}) {
|
||||
if (!header.alg) header.alg = defaultAlg;
|
||||
const encodedPayload =
|
||||
typeof payload === "string"
|
||||
? payload
|
||||
: encodeSection(payload, options.canonicalize);
|
||||
const signingInput = [
|
||||
encodeSection(header, options.canonicalize),
|
||||
encodedPayload,
|
||||
].join(".");
|
||||
|
||||
const jwtSigner = ES256KSignerAlg(false);
|
||||
const signature = await jwtSigner(signingInput, signer);
|
||||
|
||||
// JWS Compact Serialization
|
||||
// https://www.rfc-editor.org/rfc/rfc7515#section-7.1
|
||||
return [signingInput, signature].join(".");
|
||||
}
|
||||
|
||||
function canonicalizeData(object) {
|
||||
if (typeof object === "number" && isNaN(object)) {
|
||||
throw new Error("NaN is not allowed");
|
||||
}
|
||||
|
||||
if (typeof object === "number" && !isFinite(object)) {
|
||||
throw new Error("Infinity is not allowed");
|
||||
}
|
||||
|
||||
if (object === null || typeof object !== "object") {
|
||||
return JSON.stringify(object);
|
||||
}
|
||||
|
||||
if (object.toJSON instanceof Function) {
|
||||
return serialize(object.toJSON());
|
||||
}
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
const values = object.reduce((t, cv, ci) => {
|
||||
const comma = ci === 0 ? "" : ",";
|
||||
const value = cv === undefined || typeof cv === "symbol" ? null : cv;
|
||||
return `${t}${comma}${serialize(value)}`;
|
||||
}, "");
|
||||
return `[${values}]`;
|
||||
}
|
||||
|
||||
const values = Object.keys(object)
|
||||
.sort()
|
||||
.reduce((t, cv) => {
|
||||
if (object[cv] === undefined || typeof object[cv] === "symbol") {
|
||||
return t;
|
||||
}
|
||||
const comma = t.length === 0 ? "" : ",";
|
||||
return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`;
|
||||
}, "");
|
||||
return `{${values}}`;
|
||||
}
|
||||
|
||||
function encodeSection(data, shouldCanonicalize = false) {
|
||||
if (shouldCanonicalize) {
|
||||
return encodeBase64url(canonicalizeData(data));
|
||||
} else {
|
||||
return encodeBase64url(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64url(s) {
|
||||
return bytesToBase64url(u8a.fromString(s));
|
||||
}
|
||||
|
||||
function instanceOfEcdsaSignature(object) {
|
||||
return typeof object === "object" && "r" in object && "s" in object;
|
||||
}
|
||||
|
||||
function ES256KSignerAlg(recoverable) {
|
||||
return async function sign(payload, signer) {
|
||||
const signature = await signer(payload);
|
||||
if (instanceOfEcdsaSignature(signature)) {
|
||||
return toJose(signature, recoverable);
|
||||
} else {
|
||||
if (
|
||||
recoverable &&
|
||||
typeof fromJose(signature).recoveryParam === "undefined"
|
||||
) {
|
||||
throw new Error(
|
||||
`not_supported: ES256K-R not supported when signer doesn't provide a recovery param`,
|
||||
);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function leftpad(data, size = 64) {
|
||||
if (data.length === size) return data;
|
||||
return "0".repeat(size - data.length) + data;
|
||||
}
|
||||
|
||||
async function SimpleSigner(hexPrivateKey) {
|
||||
const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true);
|
||||
return async (data) => {
|
||||
const signature = await signer(data);
|
||||
return fromJose(signature);
|
||||
};
|
||||
}
|
||||
|
||||
function hexToBytes(s, minLength) {
|
||||
let input = s.startsWith("0x") ? s.substring(2) : s;
|
||||
|
||||
if (input.length % 2 !== 0) {
|
||||
input = `0${input}`;
|
||||
}
|
||||
|
||||
if (minLength) {
|
||||
const paddedLength = Math.max(input.length, minLength * 2);
|
||||
input = input.padStart(paddedLength, "00");
|
||||
}
|
||||
|
||||
return u8a.fromString(input.toLowerCase(), "base16");
|
||||
}
|
||||
|
||||
function ES256KSigner(privateKey, recoverable = false) {
|
||||
const privateKeyBytes = privateKey;
|
||||
if (privateKeyBytes.length !== 32) {
|
||||
throw new Error(
|
||||
`bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return async function (data) {
|
||||
const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes);
|
||||
return toJose(
|
||||
{
|
||||
r: leftpad(signature.r.toString(16)),
|
||||
s: leftpad(signature.s.toString(16)),
|
||||
recoveryParam: signature.recovery,
|
||||
},
|
||||
recoverable,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function toJose(signature, recoverable) {
|
||||
const { r, s, recoveryParam } = signature;
|
||||
const jose = new Uint8Array(recoverable ? 65 : 64);
|
||||
jose.set(u8a.fromString(r, "base16"), 0);
|
||||
jose.set(u8a.fromString(s, "base16"), 32);
|
||||
|
||||
if (recoverable) {
|
||||
if (typeof recoveryParam === "undefined") {
|
||||
throw new Error("Signer did not return a recoveryParam");
|
||||
}
|
||||
jose[64] = recoveryParam;
|
||||
}
|
||||
return bytesToBase64url(jose);
|
||||
}
|
||||
|
||||
function bytesToBase64url(b) {
|
||||
return u8a.toString(b, "base64url");
|
||||
}
|
||||
|
||||
function base64ToBytes(s) {
|
||||
const inputBase64Url = s
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
return u8a.fromString(inputBase64Url, "base64url");
|
||||
}
|
||||
|
||||
function bytesToHex(b) {
|
||||
return u8a.toString(b, "base16");
|
||||
}
|
||||
|
||||
function fromJose(signature) {
|
||||
const signatureBytes = 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 };
|
||||
}
|
||||
|
||||
function validateBase64(s) {
|
||||
if (
|
||||
!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(
|
||||
s,
|
||||
)
|
||||
) {
|
||||
throw new TypeError("invalid encoding");
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(s) {
|
||||
validateBase64(s);
|
||||
var i,
|
||||
d = atob(s),
|
||||
b = new Uint8Array(d.length);
|
||||
for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function getSettingById(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let openRequest = indexedDB.open("TimeSafari");
|
||||
|
||||
openRequest.onupgradeneeded = (event) => {
|
||||
// Handle database setup if necessary
|
||||
let db = event.target.result;
|
||||
if (!db.objectStoreNames.contains("settings")) {
|
||||
db.createObjectStore("settings", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
openRequest.onsuccess = (event) => {
|
||||
let db = event.target.result;
|
||||
let transaction = db.transaction("settings", "readonly");
|
||||
let objectStore = transaction.objectStore("settings");
|
||||
let getRequest = objectStore.get(id);
|
||||
|
||||
getRequest.onsuccess = () => resolve(getRequest.result);
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
};
|
||||
|
||||
openRequest.onerror = () => reject(openRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function setMostRecentNotified(id) {
|
||||
try {
|
||||
const db = await openIndexedDB("TimeSafari");
|
||||
const transaction = db.transaction("settings", "readwrite");
|
||||
const store = transaction.objectStore("settings");
|
||||
const data = await getRecord(store, 1);
|
||||
|
||||
if (data) {
|
||||
data["lastNotifiedClaimId"] = id;
|
||||
await updateRecord(store, data);
|
||||
} else {
|
||||
console.error("IndexedDB settings record not found.");
|
||||
}
|
||||
|
||||
transaction.oncomplete = () => db.close();
|
||||
} catch (error) {
|
||||
console.error("IndexedDB error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function openIndexedDB(dbName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function getRecord(store, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRecord(store, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(data);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAllAccounts() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let openRequest = indexedDB.open("TimeSafariAccounts");
|
||||
|
||||
openRequest.onupgradeneeded = function (event) {
|
||||
let db = event.target.result;
|
||||
if (!db.objectStoreNames.contains("accounts")) {
|
||||
db.createObjectStore("accounts", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
openRequest.onsuccess = function (event) {
|
||||
let db = event.target.result;
|
||||
let transaction = db.transaction("accounts", "readonly");
|
||||
let objectStore = transaction.objectStore("accounts");
|
||||
let getAllRequest = objectStore.getAll();
|
||||
|
||||
getAllRequest.onsuccess = function () {
|
||||
resolve(getAllRequest.result);
|
||||
};
|
||||
getAllRequest.onerror = function () {
|
||||
reject(getAllRequest.error);
|
||||
};
|
||||
};
|
||||
|
||||
openRequest.onerror = function () {
|
||||
reject(openRequest.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getNotificationCount() {
|
||||
let secret = null;
|
||||
let accounts = [];
|
||||
let result = null;
|
||||
if ("secret" in self) {
|
||||
secret = self.secret;
|
||||
const secretUint8Array = self.decodeBase64(secret);
|
||||
// 1 is our master settings ID; see MASTER_SETTINGS_KEY
|
||||
const settings = await getSettingById(1);
|
||||
let lastNotifiedClaimId = null;
|
||||
if ("lastNotifiedClaimId" in settings) {
|
||||
lastNotifiedClaimId = settings["lastNotifiedClaimId"];
|
||||
}
|
||||
const activeDid = settings["activeDid"];
|
||||
accounts = await fetchAllAccounts();
|
||||
let did = null;
|
||||
for (var i = 0; i < accounts.length; i++) {
|
||||
let account = accounts[i];
|
||||
let did = account["did"];
|
||||
if (did == activeDid) {
|
||||
let publicKeyHex = account["publicKeyHex"];
|
||||
let identity = account["identity"];
|
||||
const messageWithNonceAsUint8Array = self.decodeBase64(identity);
|
||||
const nonce = messageWithNonceAsUint8Array.slice(0, 24);
|
||||
const message = messageWithNonceAsUint8Array.slice(24, identity.length);
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decrypted = self.secretbox.open(message, nonce, secretUint8Array);
|
||||
|
||||
const msg = decoder.decode(decrypted);
|
||||
const identifier = JSON.parse(JSON.parse(msg));
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identifier));
|
||||
|
||||
let response = await fetch(
|
||||
settings["apiServer"] + "/api/v2/report/claims",
|
||||
{
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
},
|
||||
);
|
||||
if (response.status == 200) {
|
||||
let json = await response.json();
|
||||
let claims = json["data"];
|
||||
let newClaims = 0;
|
||||
for (var i = 0; i < claims.length; i++) {
|
||||
let claim = claims[i];
|
||||
if (claim["id"] === lastNotifiedClaimId) {
|
||||
break;
|
||||
}
|
||||
newClaims++;
|
||||
}
|
||||
if (newClaims > 0) {
|
||||
result = `There are ${newClaims} new activities on TimeSafari`;
|
||||
}
|
||||
const most_recent_notified = claims[0]["id"];
|
||||
await setMostRecentNotified(most_recent_notified);
|
||||
} else {
|
||||
console.error(
|
||||
"The service worker got a bad response status when fetching claims:",
|
||||
response.status,
|
||||
response,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
self.getNotificationCount = getNotificationCount;
|
||||
self.decodeBase64 = decodeBase64;
|
||||
@@ -1,4 +1,8 @@
|
||||
const { defineConfig } = require("@vue/cli-service");
|
||||
const { gitDescribeSync } = require("git-describe");
|
||||
|
||||
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash;
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
configureWebpack: {
|
||||
@@ -11,5 +15,9 @@ module.exports = defineConfig({
|
||||
iconPaths: {
|
||||
faviconSVG: "img/icons/safari-pinned-tab.svg",
|
||||
},
|
||||
workboxPluginMode: "InjectManifest",
|
||||
workboxOptions: {
|
||||
swSrc: "./sw_scripts/additional-scripts.js",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
67
web-push.md
67
web-push.md
@@ -1,3 +1,6 @@
|
||||
|
||||
# Overivew of Web Push
|
||||
|
||||
Web Push notifications is a web browser messaging protocol defined by the W3C.
|
||||
|
||||
Discussions of this interesting technology are clouded because of a
|
||||
@@ -26,8 +29,8 @@ from the SERVICE.
|
||||
The SERVICE will provide context and obtain explicit permission before prompting
|
||||
for notification permission:
|
||||
|
||||
In order to provide this context and explict permission a two-step opt-in process
|
||||
where the user is first presented with a pre-permission dialog box that explains
|
||||
In order to provide this context and explicit permission, a two-step opt-in process
|
||||
first presents the user with a pre-permission dialog box that explains
|
||||
what the notifications are for and why they are useful. This may help reduce the
|
||||
possibility of users clicking "don't allow".
|
||||
|
||||
@@ -88,7 +91,7 @@ The `sw.js` file contains the logic for what a service worker should do.
|
||||
It executes in a separate thread of execution from the web page but provides a
|
||||
means of communicating between itself and the web page via messages.
|
||||
|
||||
Note that there is a scope can specify what network requests it may
|
||||
Note that there is a scope that can specify what network requests it may
|
||||
intercept.
|
||||
|
||||
The Vue project already has its own service worker but it is possible to
|
||||
@@ -358,3 +361,61 @@ unsubscribeFromPush().catch((err) => {
|
||||
```
|
||||
|
||||
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
|
||||
|
||||
|
||||
# NOTIFICATION DIALOG WORKFLOW
|
||||
|
||||
## ON APP FIRST-LAUNCH:
|
||||
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
|
||||
|
||||
- "Turn on Notifications": triggers the browser's own notification permission prompt.
|
||||
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
|
||||
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
|
||||
|
||||
## IF THE USER CHOOSES "NEVER":
|
||||
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
|
||||
|
||||
## TO TEMPORARILY MUTE NOTIFICATIONS:
|
||||
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
|
||||
|
||||
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
|
||||
- "Mute until I turn it back on" button to indefinitely mute notifications.
|
||||
- "Cancel" to make no changes and dismiss the dialog.
|
||||
|
||||
## TO UNMUTE NOTIFICATIONS:
|
||||
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
|
||||
|
||||
## TO TURN OFF NOTIFICATIONS:
|
||||
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
|
||||
|
||||
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
|
||||
- "Leave it On" to make no changes and dismiss the dialog.
|
||||
|
||||
# NOTIFICATION STATES
|
||||
|
||||
* Unpermissioned. Push server cannot send notifications to the user because it does not have permission.
|
||||
This may be the same as when the user gave permission in the past but has since revoked it at the OS or browser
|
||||
level, outside the app. (User can change to Permissioned when the user gives permission.)
|
||||
* Permissioned. (User can change to Unpermissioned via the OS or browser settings.)
|
||||
* Active. (User can change to Muted when the user mutes notifications.)
|
||||
* Muted. (User can change to Active when the user toggles it.)
|
||||
(Turning mute off automatically after some amount of time is not planned in version 1.)
|
||||
|
||||
|
||||
# TROUBLESHOOTING
|
||||
|
||||
## Desktop
|
||||
|
||||
#### Firefox
|
||||
|
||||
Go to `about:debugging` and click on `Inspect` for the service worker.
|
||||
|
||||
#### Chrome
|
||||
|
||||
Go to `chrome://inspect/#service-workers` and click on `Inspect` for the service worker.
|
||||
|
||||
## Mobile
|
||||
|
||||
#### Android
|
||||
|
||||
#### iOS
|
||||
|
||||
Reference in New Issue
Block a user