Browse Source

Merge branch 'master' into set-push-server

adjust-note
Trent Larson 11 months ago
parent
commit
7f4d31a79c
  1. 181
      README.md
  2. 25
      openssl_signing_console.rst
  3. 32
      openssl_signing_console.sh
  4. 8
      package-lock.json
  5. 8
      package.json
  6. 65
      project.task.yaml
  7. 177
      sample.txt
  8. 175
      src/App.vue
  9. 35
      src/components/GiftedDialog.vue
  10. 317
      src/components/OfferDialog.vue
  11. 126
      src/libs/endorserServer.ts
  12. 2
      src/registerServiceWorker.ts
  13. 8
      src/router/index.ts
  14. 9
      src/views/AccountViewView.vue
  15. 19
      src/views/ContactAmountsView.vue
  16. 6
      src/views/ContactGiftingView.vue
  17. 65
      src/views/ContactQRScanShowView.vue
  18. 67
      src/views/ContactsView.vue
  19. 271
      src/views/DiscoverView.vue
  20. 123
      src/views/HelpView.vue
  21. 10
      src/views/HomeView.vue
  22. 2
      src/views/ImportAccountView.vue
  23. 7
      src/views/NewEditProjectView.vue
  24. 24
      src/views/NewIdentifierView.vue
  25. 163
      src/views/ProjectViewView.vue
  26. 2
      src/views/ProjectsView.vue
  27. 286
      src/views/SearchAreaView.vue
  28. 21
      src/views/StartView.vue
  29. 26
      src/views/StatisticsView.vue
  30. 30
      src/views/TestView.vue
  31. 80
      sw_scripts/additional-scripts.js
  32. 1051
      sw_scripts/nacl.js
  33. 5248
      sw_scripts/noble-curves.js
  34. 3068
      sw_scripts/noble-hashes.js
  35. 5679
      sw_scripts/safari-notifications.js
  36. 3
      vue.config.js

181
README.md

@ -1,4 +1,4 @@
# kickstart-for-time-pwa # TimeSafari.app - Crowd-Funder for Time - PWA
## Project setup ## Project setup
@ -13,6 +13,11 @@ npm install
npm run serve npm run serve
``` ```
### Lints and fixes files
```
npm run lint
```
### Compiles and minifies for production ### 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/",` If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
@ -21,41 +26,19 @@ If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js,
npm run build npm run build
``` ```
### Lints and fixes files ... then copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
```
npm run lint
```
## Tests
###
For your own web-push tests, change the 'vapid' URL in App.vue, and install apps on the same domain.
### Test key contents ## Tests
See [this page](openssl_signing_console.rst)
### Register new user on test server ### 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 On the test server, User #0 has rights to register others, so you can start
playing one of two ways: playing one of two ways:
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing this seed phrase: - Import the keys for the test User `did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` 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` `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).) (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: - Alternatively, register someone else under User #0 automatically:
@ -66,14 +49,35 @@ playing one of two ways:
### Create multiple identifiers ### 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 ### 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 'vapid' URL in App.vue, and install apps on the same domain.
### 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 ## Scenarios
@ -88,12 +92,13 @@ See [Configuration Reference](https://cli.vuejs.org/config/).
- Click on the "Registration Unknown" button and register that person to be able to make claims as 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 * Clear cache for localhost.
(because it'll generate a new one automatically if you start on the `/account` page).
* Unregister service worker (in Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers` or `about:debugging`). * Unregister service worker (in Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers` or `about:debugging`).
* Clear notifications (in Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search). * Clear notification permission (in Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search).
## Other ## Other
@ -103,110 +108,10 @@ See [Configuration Reference](https://cli.vuejs.org/config/).
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`. * 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. They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
``` * [Customize Vue configuration](https://cli.vuejs.org/config/).
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
// 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! Gifts make the world go 'round!

25
openssl_signing_console.rst

@ -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: 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"}' 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"}' 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: 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') 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') 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: Concatenate the encoded header, payload, and a secret to create the signing input:
signing_input="$header_b64.$payload_b64" 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) 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: 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" 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 output an error.
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".

32
openssl_signing_console.sh

@ -1,25 +1,39 @@
#!/bin/bash #!/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 ecparam -name secp256k1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem openssl ec -in private.pem -pubout -out public.pem
# Use test data
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}' header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}' 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') 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') payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
signing_input="$header_b64.$payload_b64" 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 echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
signature_b64=$(base64 -w 0 < signature.bin | 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")
# 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"
echo Resulting JWT: $jwt

8
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "kickstart-for-time-pwa", "name": "crowd-funder-for-time-pwa",
"version": "0.1.3", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "kickstart-for-time-pwa", "name": "crowd-funder-for-time-pwa",
"version": "0.1.3", "version": "0.1.4",
"dependencies": { "dependencies": {
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/fontawesome-svg-core": "^6.4.2",

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "kickstart-for-time-pwa", "name": "crowd-funder-for-time-pwa",
"version": "0.1.3", "version": "0.1.4",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
@ -72,13 +72,13 @@
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.15",
"eslint": "^8.48.0", "eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"postcss": "^8.4.29", "postcss": "^8.4.29",
"prettier": "^3.0.3", "prettier": "^3.1.0",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "~5.2.2" "typescript": "~5.2.2"
} }

65
project.task.yaml

@ -1,55 +1,45 @@
tasks: tasks:
- remove hard-coded anomalistlabs.com
- don't show "Give" & "Offer" on project screen if they don't have an identifier
- allow some gives even if they aren't registered
- in endorser-push-server - mount folder for persistent sqlite DB outside of container - in endorser-push-server - mount folder for persistent sqlite DB outside of container
- extract private_key_hex in webpush.py
- 40 notifications : - 40 notifications :
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew - 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 - .2 change the "claims" verbiage in feeds (eg. safari-notifications.js)
- .5 allow to manage their notifications even without an identity
- 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
- .1 add instructions for map location selection
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) - 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s)
- .3 fix the Project-location-selection map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
- 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
- 24 Move to Vite assignee:matthew
- .5 switch so DiscoverView shows anywhere by default, and no number unless search is done (and maybe a better filter UI, including "mine" to consolidate with ProjectsView)
- .2 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
- .5 Add infinite scroll to gifts on the home page - .5 Add infinite scroll to gifts on the home page
- .5 bug - search for "Safari" does not find the project, but if already on the "Anywhere" tab it shows all - .5 bug - search for "Safari" does not find the project, but if already on the "Anywhere" tab it shows all
- .2 figure out why endorser-mobile search doesn't find recently created PlanAction - .2 figure out why endorser-mobile search doesn't find recently created PlanAction
- .1 when creating a plan, select location and then make sure you can deselect on Android - .1 when creating a plan, select location and then make sure you can deselect on Android
- .5 add link to further project / people when a project pays ahead
- .5 add project ID to the URL of the project-view, to make a project publicly-accessible
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page - .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 - .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164 assignee:trent
- .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" - .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .1 Make give description text box into something that expands as they type - fix cert generation (since it didn't happen automatically for Nov 30)
- .1 Make contact info specific to Time Safari - rather pointing at CommunityCred.org
- Discuss whether the remaining tasks are worthwhile before MVP release. - Discuss whether the remaining tasks are worthwhile before MVP release.
- .1 Make give description text box into something that expands as they type?
- 04 allow user to download claims, mine + ones I can see about me from others - 04 allow user to download claims, mine + ones I can see about me from others
- 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
- .5 customize favicon assignee-group:ui - .5 customize favicon assignee-group:ui
- .2 Show a warning if both giver and recipient are the same (but still allow?) - .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 - 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 Display a more appealing confirmation on the map when erasing the marker
- .5 make a VC details page - .5 make a VC details page, or link to endorser.ch
- .1 Add units or different icon to the coins (to distinguish $, BTC, hours, etc) - .1 Add units or different icon to the coins (to distinguish $, BTC, hours, etc)
- .5 include the hash of the latest commit on help page next to version - .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) - .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
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
- allow download of each VC (to show that they can actually own their data)
- contacts v+ : - contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings). - 01 Import all the non-sensitive data (ie. contacts & settings).
@ -58,14 +48,17 @@ tasks:
- stats v1 : - stats v1 :
- 01 show numeric stats - 01 show numeric stats
- 04 show different graphic for projects vs people on world - 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats - 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists - .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") - 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) - 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- Release Minimum Viable Product : - Release Minimum Viable Product :
- generate new webpush.db entries, data/webpush.db private_key_hex & subscription_info & vapid_claims email
- .5 deploy endorser.ch server above Dec 1 (to get plan searches by names as well as descriptions)
- 08 thorough testing for errors & edge cases - 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). - Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Add disclaimers. - Add disclaimers.
- Switch default server to the public server. - Switch default server to the public server.
@ -75,7 +68,9 @@ tasks:
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- .5 show seed phrase in a QR code for transfer to another device - .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
- 24 Move to Vite
- 32 accept images for projects - 32 accept images for projects
- 32 accept images for contacts - 32 accept images for contacts
@ -85,6 +80,10 @@ tasks:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances - 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) - 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 : - Stats :
- 01 point out user's location on the world - 01 point out user's location on the world
- 01 present a credential selected from the stats - 01 present a credential selected from the stats
@ -101,11 +100,10 @@ tasks:
- Multiple identities - Multiple identities
- Peer DID - Support KERI AIDs
- Support Peer DIDs
- DIDComm - Support messaging through DIDComm
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
- Do we want split first name & last name? - Do we want split first name & last name?
@ -113,6 +111,7 @@ tasks:
- pull, w/ scheduled runs - 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. - 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
- 16 From the home screen, make the quick action even easier.
log: log:
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29 - videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29

177
sample.txt

@ -1,177 +0,0 @@
> kickstart-for-time-pwa@0.1.0 build
> vue-cli-service build
All browser targets in the browserslist configuration have supported ES module.
Therefore we don't build two separate bundles for differential loading.
WARNING Compiled with 5 warnings6:06:43 PM
[eslint]
/home/matthew/projects/kick-starter-for-time-pwa/src/components/World/components/objects/landmarks.js
98:11 warning Unexpected console statement no-console
133:7 warning Unexpected console statement no-console
144:5 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/router/index.ts
210:3 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/AccountViewView.vue
362:7 warning Unexpected console statement no-console
375:7 warning Unexpected console statement no-console
404:7 warning Unexpected console statement no-console
516:7 warning Unexpected console statement no-console
536:7 warning Unexpected console statement no-console
630:5 warning Unexpected console statement no-console
682:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactAmountsView.vue
206:9 warning Unexpected console statement no-console
233:9 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactGiftingView.vue
244:9 warning Unexpected console statement no-console
267:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactsView.vue
340:9 warning Unexpected console statement no-console
577:9 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/DiscoverView.vue
315:9 warning Unexpected console statement no-console
343:7 warning Unexpected console statement no-console
390:9 warning Unexpected console statement no-console
423:7 warning Unexpected console statement no-console
532:9 warning Unexpected console statement no-console
575:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/HomeView.vue
349:9 warning Unexpected console statement no-console
498:9 warning Unexpected console statement no-console
521:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/IdentitySwitcherView.vue
142:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ImportAccountView.vue
123:9 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ImportDerivedAccountView.vue
159:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/NewEditProjectView.vue
183:9 warning Unexpected console statement no-console
215:7 warning Unexpected console statement no-console
297:13 warning Unexpected console statement no-console
320:11 warning Unexpected console statement no-console
345:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ProjectViewView.vue
387:9 warning Unexpected console statement no-console
421:7 warning Unexpected console statement no-console
457:7 warning Unexpected console statement no-console
552:9 warning Unexpected console statement no-console
554:11 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ProjectsView.vue
131:9 warning Unexpected console statement no-console
144:7 warning Unexpected console statement no-console
221:9 warning Unexpected console statement no-console
237:7 warning Unexpected console statement no-console
/home/matthew/projects/kick-starter-for-time-pwa/src/views/SeedBackupView.vue
94:7 warning Unexpected console statement no-console
✖ 44 problems (0 errors, 44 warnings)
You may use special comments to disable some warnings.
Use // eslint-disable-next-line to ignore the next line.
Use /* eslint-disable */ to ignore all warnings in a file.
warning
/models/lupine_plant/textures/lambert2SG_baseColor.png is 3.75 MB, and won't be precached. Configure maximumFileSizeToCacheInBytes to change this limit.
warning
/models/lupine_plant/textures/lambert2SG_normal.png is 4.91 MB, and won't be precached. Configure maximumFileSizeToCacheInBytes to change this limit.
warning
asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
js/project.44f30c9f.js (318 KiB)
js/statistics.8a97010a.js (586 KiB)
js/chunk-vendors.a4845bfb.js (411 KiB)
js/705.f6a6ce2a.js (252 KiB)
img/textures/leafy-autumn-forest-floor.jpg (705 KiB)
models/lupine_plant/textures/lambert2SG_baseColor.png (3.58 MiB)
models/lupine_plant/textures/lambert2SG_normal.png (4.69 MiB)
warning
entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
app (447 KiB)
js/chunk-vendors.a4845bfb.js
css/app.8f21529c.css
js/app.8833cebc.js
File Size Gzipped
dist/js/statistics.8a97010a.js 585.72 KiB 148.80 KiB
dist/js/chunk-vendors.a4845bfb.js 411.44 KiB 137.82 KiB
dist/js/project.44f30c9f.js 317.61 KiB 78.67 KiB
dist/js/705.f6a6ce2a.js 251.66 KiB 87.12 KiB
dist/js/891.33615e4f.js 147.32 KiB 42.09 KiB
dist/js/153.e2c8e249.js 146.26 KiB 42.21 KiB
dist/js/820.13565d16.js 66.10 KiB 18.33 KiB
dist/js/contact-qr.e170ec33.js 54.85 KiB 15.63 KiB
dist/js/772.7b4c53a7.js 30.29 KiB 7.21 KiB
dist/js/361.898a4525.js 27.40 KiB 8.19 KiB
dist/js/account.77d86130.js 17.51 KiB 5.93 KiB
dist/js/app.8833cebc.js 17.31 KiB 5.84 KiB
dist/js/contacts.3fc90ff8.js 16.94 KiB 5.52 KiB
dist/js/discover.24106939.js 15.30 KiB 5.22 KiB
dist/js/536.3bb13201.js 15.23 KiB 4.84 KiB
dist/workbox-5b385ed2.js 14.11 KiB 4.93 KiB
dist/js/home.218b99dd.js 13.89 KiB 4.97 KiB
dist/js/help.50d3117b.js 12.49 KiB 4.38 KiB
dist/js/projects.417a6cb7.js 8.71 KiB 3.00 KiB
dist/js/contact-amounts.a32b0ccd.js 8.44 KiB 3.25 KiB
dist/js/229.120e09bf.js 7.99 KiB 2.72 KiB
dist/js/identity-switcher.c7937333.js 7.44 KiB 2.52 KiB
dist/js/new-edit-project.0552181b.js 7.36 KiB 3.11 KiB
dist/js/300.dcaeb2a3.js 6.56 KiB 3.24 KiB
dist/js/seed-backup.76a0f7b3.js 3.99 KiB 1.97 KiB
dist/js/import-derive.c688d4b8.js 3.81 KiB 1.82 KiB
dist/js/import-account.c3fa35fd.js 3.54 KiB 1.66 KiB
dist/js/new-edit-account.bb763be2.js 3.39 KiB 1.51 KiB
dist/js/431.5a6d64e0.js 3.38 KiB 2.56 KiB
dist/service-worker.js 3.37 KiB 1.38 KiB
dist/js/scan-contact.46be989a.js 2.79 KiB 1.18 KiB
dist/js/start.091a7740.js 2.70 KiB 1.30 KiB
dist/js/new-identifier.bb379420.js 2.12 KiB 1.18 KiB
dist/js/93.b873dbbf.js 2.08 KiB 1.61 KiB
dist/js/new-edit-commitment.9248d367.j 1.96 KiB 1.05 KiB
s
dist/js/confirm-contact.02004d1d.js 1.89 KiB 1.04 KiB
dist/js/858.ae4c08ec.js 0.97 KiB 0.78 KiB
dist/css/app.8f21529c.css 18.41 KiB 4.39 KiB
dist/css/discover.73ee9bd3.css 14.77 KiB 6.25 KiB
dist/css/new-edit-project.73ee9bd3.css 14.77 KiB 6.25 KiB
dist/css/contacts.abb5e493.css 0.40 KiB 0.23 KiB
dist/css/contact-amounts.5b26ccd4.css 0.31 KiB 0.20 KiB
dist/css/home.828bc66e.css 0.25 KiB 0.19 KiB
dist/css/project.828bc66e.css 0.25 KiB 0.19 KiB
dist/css/statistics.828bc66e.css 0.25 KiB 0.19 KiB
Images and other types of assets omitted.
Build at: 2023-09-07T10:06:43.972Z - Hash: 2b39fcd4d0e78263 - Time: 32016ms
DONE Build complete. The dist directory is ready to be deployed.
INFO Check out deployment instructions at https://cli.vuejs.org/guide/deployment.html

175
src/App.vue

@ -262,6 +262,28 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import axios from "axios"; 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 { AppString } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
@ -289,10 +311,15 @@ export default class App extends Vue {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;
} }
await axios.get(pushUrl + "/web-push/vapid").then((response) => { await axios
this.b64 = response.data?.vapidKey; .get(pushUrl + "/web-push/vapid")
console.log("Got vapid key:", this.b64); .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) { if (!this.b64) {
this.$notify( this.$notify(
{ {
@ -318,36 +345,75 @@ export default class App extends Vue {
} }
} }
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> { private askPermission(): Promise<NotificationPermission> {
// Check if Notifications are supported 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)) { if (!("Notification" in window)) {
alert("This browser does not support notifications."); alert("This browser does not support notifications.");
return Promise.reject("This browser does not support notifications."); return Promise.reject("This browser does not support notifications.");
} }
// Check existing permissions
if (Notification.permission === "granted") { if (Notification.permission === "granted") {
return Promise.resolve("granted"); return Promise.resolve();
} }
return Promise.resolve();
}
// Request permission private requestNotificationPermission(): Promise<NotificationPermission> {
return new Promise((resolve, reject) => { return Notification.requestPermission().then((permission) => {
const permissionResult = Notification.requestPermission((result) => { if (permission !== "granted") {
resolve(result);
});
if (permissionResult) {
permissionResult.then(resolve, reject);
}
}).then((permissionResult) => {
console.log("Permission result:", permissionResult);
if (permissionResult !== "granted") {
alert("We need notification permission to provide certain features."); alert("We need notification permission to provide certain features.");
return Promise.reject("We weren't granted permission."); throw new Error("We weren't granted permission.");
} }
return permission;
return permissionResult;
}); });
} }
@ -406,34 +472,49 @@ export default class App extends Vue {
return outputArray; return outputArray;
} }
// The subscribeToPush method
private subscribeToPush(): Promise<void> { private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if ("serviceWorker" in navigator && "PushManager" in window) { if (!("serviceWorker" in navigator && "PushManager" in window)) {
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
console.log(options);
navigator.serviceWorker.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
console.log("Push subscription successful:", subscription);
resolve();
})
.catch((error) => {
console.error("Push subscription failed:", error, options);
reject(error);
});
} else {
const errorMsg = "Push messaging is not supported"; const errorMsg = "Push messaging is not supported";
console.warn(errorMsg); console.warn(errorMsg);
reject(new Error(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);
});
}); });
} }

35
src/components/GiftedDialog.vue

@ -10,7 +10,7 @@
placeholder="What was received" placeholder="What was received"
v-model="description" v-model="description"
/> />
<div class="flex flex-row mb-6"> <div class="flex flex-row">
<span <span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2" class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
>Hours</span >Hours</span
@ -33,7 +33,13 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</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 <button
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2" class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="confirm" @click="confirm"
@ -70,12 +76,14 @@ export default class GiftedDialog extends Vue {
@Prop message = ""; @Prop message = "";
@Prop projectId = ""; @Prop projectId = "";
@Prop showGivenToUser = false;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
giver?: GiverInputInfo; giver?: GiverInputInfo; // undefined means no identified giver agent
description = ""; description = "";
givenToUser = false;
hours = "0"; hours = "0";
visible = false; visible = false;
@ -103,11 +111,17 @@ export default class GiftedDialog extends Vue {
} }
open(giver: GiverInputInfo) { open(giver: GiverInputInfo) {
this.description = "";
this.giver = giver; this.giver = giver;
// if we show "given to user" selection, default checkbox to true
this.givenToUser = this.showGivenToUser;
this.hours = "0";
this.visible = true; this.visible = true;
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false; this.visible = false;
} }
@ -121,8 +135,13 @@ export default class GiftedDialog extends Vue {
cancel() { cancel() {
this.close(); this.close();
this.eraseValues();
}
eraseValues() {
this.description = ""; this.description = "";
this.giver = undefined; this.giver = undefined;
this.givenToUser = this.showGivenToUser;
this.hours = "0"; this.hours = "0";
} }
@ -138,14 +157,12 @@ export default class GiftedDialog extends Vue {
1000, 1000,
); );
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
this.recordGive( await this.recordGive(
this.giver?.did as string | undefined, this.giver?.did as string | undefined,
this.description, this.description,
parseFloat(this.hours), parseFloat(this.hours),
).then(() => { ).then(() => {
this.description = ""; this.eraseValues();
this.giver = undefined;
this.hours = "0";
}); });
} }
@ -209,7 +226,7 @@ export default class GiftedDialog extends Vue {
this.apiServer, this.apiServer,
identity, identity,
giverDid, giverDid,
this.activeDid, this.givenToUser ? this.activeDid : undefined,
description, description,
hours, hours,
this.projectId, this.projectId,
@ -238,7 +255,7 @@ export default class GiftedDialog extends Vue {
title: "Success", title: "Success",
text: "That gift was recorded.", text: "That gift was recorded.",
}, },
10000, 7000,
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

317
src/components/OfferDialog.vue

@ -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 &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</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>

126
src/libs/endorserServer.ts

@ -59,17 +59,40 @@ export interface GiveServerRecord {
unit: string; unit: string;
} }
export interface OfferServerRecord {
amount: number;
amountGiven: number;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
unit: string;
validThrough: string;
}
export interface GiveVerifiableCredential { export interface GiveVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": "GiveAction";
agent?: { identifier: string }; agent?: { identifier: string };
description?: string; description?: string;
fulfills?: { "@type": string; identifier: string }; fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
identifier?: string; identifier?: string;
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
recipient?: { identifier: string }; recipient?: { identifier: string };
} }
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;
}
export interface PlanVerifiableCredential { export interface PlanVerifiableCredential {
"@context": "https://schema.org"; "@context": "https://schema.org";
"@type": "PlanAction"; "@type": "PlanAction";
@ -152,7 +175,7 @@ export interface ErrorResult {
error: InternalError; 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 * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
@ -172,20 +195,81 @@ export async function createAndSubmitGive(
description?: string, description?: string,
hours?: number, hours?: number,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitGiveResult> { ): Promise<CreateAndSubmitClaimResult> {
try { const vcClaim: GiveVerifiableCredential = {
const vcClaim: GiveVerifiableCredential = { "@context": "https://schema.org",
"@context": "https://schema.org", "@type": "GiveAction",
"@type": "GiveAction", recipient: toDid ? { identifier: toDid } : undefined,
recipient: toDid ? { identifier: toDid } : undefined, agent: fromDid ? { identifier: fromDid } : undefined,
agent: fromDid ? { identifier: fromDid } : undefined, description: description || undefined,
description: description || undefined, object: hours ? { amountOfThisGood: hours, unitCode: "HUR" } : undefined,
object: hours ? { amountOfThisGood: hours, unitCode: "HUR" } : undefined, fulfills: fulfillsProjectHandleId
fulfills: fulfillsProjectHandleId ? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId } : undefined,
: undefined, };
return createAndSubmitClaim(
vcClaim as GenericClaim,
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 (description) {
vcClaim.itemOffered = { description };
}
if (fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
return createAndSubmitClaim(
vcClaim as GenericClaim,
identity,
apiServer,
axios,
);
}
export async function createAndSubmitClaim(
vcClaim: GenericClaim,
identity: IIdentifier,
apiServer: string,
axios: Axios,
): Promise<CreateAndSubmitClaimResult> {
try {
const vcPayload = { const vcPayload = {
vc: { vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"], "@context": ["https://www.w3.org/2018/credentials/v1"],
@ -226,15 +310,11 @@ export async function createAndSubmitGive(
}); });
return { type: "success", response }; return { type: "success", response };
} catch (error: unknown) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error creating claim:", error);
const errorMessage: string = const errorMessage: string =
error === null error.response?.data?.error?.message || error.message || "Unknown error";
? "Null error"
: error instanceof Error
? error.message
: typeof error === "object" && error !== null && "message" in error
? (error as { message: string }).message
: "Unknown error";
return { return {
type: "error", type: "error",

2
src/registerServiceWorker.ts

@ -3,7 +3,7 @@
import { register } from "register-service-worker"; import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, { register("/additional-scripts.js", {
ready() { ready() {
console.log( console.log(
"App is being served from cache by a service worker.\n" + "App is being served from cache by a service worker.\n" +

8
src/router/index.ts

@ -168,6 +168,14 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue" /* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
), ),
}, },
{
path: "/search-area",
name: "search-area",
component: () =>
import(
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
),
},
{ {
path: "/seed-backup", path: "/seed-backup",
name: "seed-backup", name: "seed-backup",

9
src/views/AccountViewView.vue

@ -186,8 +186,9 @@
</p> </p>
<p> <p>
You have done {{ limits.doneRegistrationsThisMonth }} registrations You have done {{ limits.doneRegistrationsThisMonth }} registrations
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your out of {{ limits.maxRegistrationsPerMonth }} for this month. (You can
registrations counter resets at register nobody on your first day, and after that only one a day in
your first month.) Your registration counter resets at
{{ readableTime(limits.nextMonthBeginDateTime) }} {{ readableTime(limits.nextMonthBeginDateTime) }}
</p> </p>
</div> </div>
@ -700,11 +701,11 @@ export default class AccountViewView extends Vue {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "toast", type: "success",
title: "Download Started", title: "Download Started",
text: "See your downloads directory for the backup.", text: "See your downloads directory for the backup.",
}, },
5000, -1,
); );
} }

19
src/views/ContactAmountsView.vue

@ -2,8 +2,11 @@
<QuickNav selected="Contacts"></QuickNav> <QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8"> <div class="mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1
id="ViewBreadcrumb"
class="text-lg text-center font-light relative px-7"
>
<!-- Back --> <!-- Back -->
<router-link <router-link
:to="{ name: 'contacts' }" :to="{ name: 'contacts' }"
@ -11,11 +14,12 @@
><fa icon="chevron-left" class="fa-fw"></fa ><fa icon="chevron-left" class="fa-fw"></fa
></router-link> ></router-link>
</h1> </h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Given with {{ contact?.name }}
</h1>
</div> </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"> <div class="flex justify-around">
<span /> <span />
<span class="justify-around">(Only 50 most recent)</span> <span class="justify-around">(Only 50 most recent)</span>
@ -358,7 +362,10 @@ export default class ContactsView extends Vue {
</script> </script>
<style> <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 container */
.tooltip { .tooltip {
position: relative; position: relative;

6
src/views/ContactGiftingView.vue

@ -66,7 +66,11 @@
</li> </li>
</ul> </ul>
<GiftedDialog ref="customDialog" message="Received from"> </GiftedDialog> <GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
</section> </section>
</template> </template>

65
src/views/ContactQRScanShowView.vue

@ -2,21 +2,36 @@
<QuickNav selected="Profile"></QuickNav> <QuickNav selected="Profile"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4"> <div class="mb-8">
Your Contact Info <!-- Back -->
</h1> <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"
Play with display options: https://qr-code-styling.com/ @click="$router.back()"
See docs: https://www.npmjs.com/package/qr-code-generator-vue3 >
--> <fa icon="chevron-left" class="fa-fw"></fa>
<QRCodeVue3 </h1>
:value="this.qrValue" </div>
:cornersSquareOptions="{ type: 'extra-rounded' }"
:dotsOptions="{ type: 'square' }" <!-- Heading -->
class="flex justify-center" <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
/> Your Contact Info
</h1>
</div>
<div @click="onCopyToClipboard()">
<!--
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>
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1> <h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" /> <qrcode-stream @detect="onScanDetect" @error="onScanError" />
@ -27,6 +42,7 @@
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader"; import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@ -137,7 +153,7 @@ export default class ContactQRScanShow extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) { onScanDetect(content: any) {
if (content[0]?.rawValue) { if (content[0]?.rawValue) {
console.log("onDetect", content[0].rawValue); //console.log("onDetect", content[0].rawValue);
localStorage.setItem("contactEndorserUrl", content[0].rawValue); localStorage.setItem("contactEndorserUrl", content[0].rawValue);
this.$router.push({ name: "contacts" }); this.$router.push({ name: "contacts" });
} else { } else {
@ -166,5 +182,22 @@ export default class ContactQRScanShow extends Vue {
-1, -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> </script>

67
src/views/ContactsView.vue

@ -113,7 +113,7 @@
<button <button
v-if="contact.seesMe" v-if="contact.seesMe"
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md" class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
@click="setVisibility(contact, false)" @click="setVisibility(contact, false, true)"
title="They can see you" title="They can see you"
> >
<fa icon="eye" class="fa-fw" /> <fa icon="eye" class="fa-fw" />
@ -121,7 +121,7 @@
<button <button
v-else v-else
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md" class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
@click="setVisibility(contact, true)" @click="setVisibility(contact, true, true)"
title="They cannot see you" title="They cannot see you"
> >
<fa icon="eye-slash" class="fa-fw" /> <fa icon="eye-slash" class="fa-fw" />
@ -137,7 +137,7 @@
<button <button
@click="register(contact)" @click="register(contact)"
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md" class="text-sm uppercase bg-slate-500 text-white ml-6 px-2 py-1.5 rounded-md"
> >
<fa <fa
v-if="contact.registered" v-if="contact.registered"
@ -155,7 +155,7 @@
<button <button
@click="deleteContact(contact)" @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" title="Delete"
> >
<fa icon="trash-can" class="fa-fw" /> <fa icon="trash-can" class="fa-fw" />
@ -218,7 +218,7 @@
</div> </div>
</li> </li>
</ul> </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 v-if="contactEdit !== null" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
@ -531,6 +531,7 @@ export default class ContactsView extends Vue {
); );
return; return;
} }
newContact.seesMe = true; // since we will immediately set that on the server
return db.contacts return db.contacts
.add(newContact) .add(newContact)
.then(() => { .then(() => {
@ -539,12 +540,28 @@ export default class ContactsView extends Vue {
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""), (a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts, allContacts,
); );
this.setVisibility(newContact, true, false);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Contact added", title: "Contact Added",
text: newContact.name + " was added.", text:
newContact.name +
" was added, and your activity is visible to them.",
},
-1,
);
// 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, -1,
); );
@ -556,7 +573,7 @@ export default class ContactsView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Contact Not Added", title: "Contact Not Added",
text: "An error prevented importing.", text: "An error prevented this import.",
}, },
-1, -1,
); );
@ -566,11 +583,13 @@ export default class ContactsView extends Vue {
async deleteContact(contact: Contact) { async deleteContact(contact: Contact) {
if ( if (
confirm( 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) + this.nameForDid(this.contacts, contact.did) +
" with DID " + " with DID " +
contact.did + contact.did +
" ?", " from your contact list?",
) )
) { ) {
await db.open(); await db.open();
@ -692,7 +711,11 @@ export default class ContactsView extends Vue {
} }
} }
async setVisibility(contact: Contact, visibility: boolean) { async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const url = const url =
this.apiServer + this.apiServer +
"/api/report/" + "/api/report/" +
@ -704,6 +727,21 @@ export default class ContactsView extends Vue {
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
if (resp.status === 200) { if (resp.status === 200) {
if (showSuccessAlert) {
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
this.nameForDid(this.contacts, contact.did) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
-1,
);
}
contact.seesMe = visibility; contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility }); db.contacts.update(contact.did, { seesMe: visibility });
} else { } else {
@ -756,7 +794,7 @@ export default class ContactsView extends Vue {
{ {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Refreshed", title: "Visibility Refreshed",
text: text:
this.nameForContact(contact, true) + this.nameForContact(contact, true) +
" can " + " can " +
@ -1050,7 +1088,10 @@ export default class ContactsView extends Vue {
max-width: 500px; max-width: 500px;
} }
/* 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 container */
.tooltip { .tooltip {
position: relative; position: relative;

271
src/views/DiscoverView.vue

@ -9,7 +9,7 @@
</h1> </h1>
<!-- Quick Search --> <!-- 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 <input
type="text" type="text"
v-model="searchTerms" v-model="searchTerms"
@ -17,7 +17,7 @@
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
/> />
<button <button
@click="searchAll()" @click="searchSelected()"
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
> >
<fa icon="magnifying-glass" class="fa-fw"></fa> <fa icon="magnifying-glass" class="fa-fw"></fa>
@ -41,8 +41,10 @@
Nearby Nearby
<span <span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md" 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> </a>
</li> </li>
<li> <li>
@ -59,54 +61,24 @@
Anywhere Anywhere
<span <span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md" 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> </a>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="isLocalActive"> <div v-if="isLocalActive">
<div v-if="!isChoosingSearchBox"> <div>
<button <button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500" class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="isChoosingSearchBox = true" @click="$router.push({ name: 'search-area' })"
> >
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
</button> </button>
</div> </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> </div>
<!-- Loading Animation --> <!-- Loading Animation -->
@ -118,7 +90,7 @@
</div> </div>
<!-- Results List --> <!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData" v-if="!isChoosingSearchBox"> <InfiniteScroll @reached-bottom="loadMoreData">
<ul> <ul>
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
@ -150,50 +122,11 @@
</li> </li>
</ul> </ul>
</InfiniteScroll> </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> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { LeafletMouseEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
@ -204,10 +137,6 @@ import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
interface Notification { interface Notification {
group: string; group: string;
type: string; type: string;
@ -217,13 +146,9 @@ interface Notification {
@Component({ @Component({
components: { components: {
LRectangle,
QuickNav, QuickNav,
InfiniteScroll, InfiniteScroll,
EntityIcon, EntityIcon,
LMap,
LMarker,
LTileLayer,
}, },
}) })
export default class DiscoverView extends Vue { export default class DiscoverView extends Vue {
@ -235,19 +160,12 @@ export default class DiscoverView extends Vue {
apiServer = ""; apiServer = "";
searchTerms = ""; searchTerms = "";
projects: ProjectData[] = []; projects: ProjectData[] = [];
isChoosingSearchBox = false; isLoading = false;
isLocalActive = true; isLocalActive = true;
isRemoteActive = false; isRemoteActive = false;
isNewMarkerSet = false; localCount = -1;
localCenterLat = 0; remoteCount = -1;
localCenterLong = 0;
localLatDiff = DEFAULT_LAT_LONG_DIFF;
localLongDiff = DEFAULT_LAT_LONG_DIFF;
localCount = 0;
localZoom = DEFAULT_ZOOM;
remoteCount = 0;
searchBox: { name: string; bbox: BoundingBox } | null = null; searchBox: { name: string; bbox: BoundingBox } | null = null;
isLoading = false;
// make this function available to the Vue template // make this function available to the Vue template
didInfo = didInfo; didInfo = didInfo;
@ -258,7 +176,6 @@ export default class DiscoverView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.searchBox = settings?.searchBoxes?.[0] || null; this.searchBox = settings?.searchBoxes?.[0] || null;
this.resetLatLong();
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
@ -266,7 +183,26 @@ export default class DiscoverView extends Vue {
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
this.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> { public async buildHeaders(): Promise<HeadersInit> {
@ -294,6 +230,13 @@ export default class DiscoverView extends Vue {
} }
public async searchAll(beforeId?: string) { 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); let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
if (beforeId) { if (beforeId) {
@ -356,13 +299,21 @@ export default class DiscoverView extends Vue {
} }
public async searchLocal(beforeId?: string) { public async searchLocal(beforeId?: string) {
this.resetCounts();
if (!this.searchBox) { if (!this.searchBox) {
this.projects = []; this.projects = [];
return; return;
} }
if (!beforeId) {
// this was an initial search so clear any previous results
this.projects = [];
}
const claimContents = const claimContents =
"claimContents=" + encodeURIComponent(this.searchTerms); "claimContents=" + encodeURIComponent(this.searchTerms);
let queryParams = [ let queryParams = [
claimContents, claimContents,
"minLocLat=" + this.searchBox.bbox.minLat, "minLocLat=" + this.searchBox.bbox.minLat,
@ -457,133 +408,11 @@ export default class DiscoverView extends Vue {
onClickLoadProject(id: string) { onClickLoadProject(id: string) {
localStorage.setItem("projectId", id); localStorage.setItem("projectId", id);
const route = { const route = {
name: "project", path: "/project/" + encodeURIComponent(id),
}; };
this.$router.push(route); 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() { public computedLocalTabClassNames() {
return { return {
"inline-block": true, "inline-block": true,

123
src/views/HelpView.vue

@ -1,11 +1,25 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div class="mb-8">
Help <!-- Back -->
</h1> <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> <div>
<p> <p>
@ -15,7 +29,7 @@
<h2 class="text-xl font-semibold">What is the philosophy here?</h2> <h2 class="text-xl font-semibold">What is the philosophy here?</h2>
<p> <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 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 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 can prove it was for them. This is personally gratifying, but it extends
@ -36,22 +50,39 @@
the control; this app gives you the control. the control; this app gives you the control.
</p> </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> <p>
You need someone to register you -- usually the person who told you You need someone to register you -- usually the person who told you
about this app, on the Contacts about this app, on the Contacts
<fa icon="circle-user" class="fa-fw" /> page. After they register you, <fa icon="users" class="fa-fw" /> page. After they register you, you can
you can select any contact on the home page (or "anonymous") and record select any contact on the home page (or "anonymous") and record your
your appreciation for... whatever. The main goal is to record what appreciation for... whatever. The main goal is to record what people
people have given you, to grow gifting economies. Each claim is recorded have given you, to grow giving economies. Each claim is recorded on a
on a custom ledger. The day after being registered, you'll be able to custom ledger. The day after being registered, you'll be able to able to
able to register others; later, you can create projects, too. register others; later, you can create projects, too.
</p> </p>
<p> <p>
Note that there are limits to how many others each person can register, Note that there are limits to how many others each person can register,
so you may have to wait. so you may have to wait.
</p> </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> <h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p> <p>
There are two sets of data to backup: the identifier secrets and the There are two sets of data to backup: the identifier secrets and the
@ -113,27 +144,20 @@
How do I restore my other (non-identifier-secret) data? How do I restore my other (non-identifier-secret) data?
</h2> </h2>
<ul class="list-disc list-inside"> <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> </ul>
</div> </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> <h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p> <p>
Before doing this, note that it is an advanced feature that affects Before doing this, note that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features) functionality (eg. the words "Alt ID" next to results, backup features)
so beware if you think that may cause confusion. You can so beware. You can
<router-link to="start" class="text-blue-500"> <router-link to="start" class="text-blue-500">
create another identity here. create another identity here.
</router-link> </router-link>
@ -151,10 +175,10 @@
<fa icon="eye-slash" class="fa-fw" />. <fa icon="eye-slash" class="fa-fw" />.
</p> </p>
<p> <p>
Sometimes the reason you don't see something is because the search time Sometimes the reason you don't see something is because the search
is limited. Go to the bottom and make sure to load all the data on a results are limited. Go to the bottom and make sure to load all the data
list. If you still don't see it, try a search or view on a different on a list. If you still don't see it, try a search or view on a
page. different page.
</p> </p>
<h2 class="text-xl font-semibold">What is your privacy policy?</h2> <h2 class="text-xl font-semibold">What is your privacy policy?</h2>
@ -165,17 +189,26 @@
</a> </a>
</p> </p>
<h2 class="text-xl font-semibold">Where can I read more?</h2>
<p>
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> <h2 class="text-xl font-semibold">What app version is this?</h2>
<p> <p>
{{ package.version }} {{ package.version }}
</p> </p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
For any other questions, including remove your data: For any other questions, including removing your data:
</h2> </h2>
<p> <p>
Contact us through Contact us at
<a href="https://communitycred.org">CommunityCred.org</a>. <a mailto="info@TimeSafari.app">info@TimeSafari.app</a>
</p> </p>
</div> </div>
</section> </section>
@ -186,8 +219,30 @@ import * as Package from "../../package.json";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class Help extends Vue { export default class Help extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
package = Package; package = Package;
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> </script>

10
src/views/HomeView.vue

@ -29,7 +29,7 @@
<div v-else> <div v-else>
<!-- activeDid && isRegistered --> <!-- activeDid && isRegistered -->
<h2 class="text-xl font-bold">Record a Gift</h2> <h2 class="text-xl font-bold">Record Something Given</h2>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5"> <ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()"> <li @click="openDialog()">
@ -81,7 +81,11 @@
</div> </div>
</div> </div>
<GiftedDialog ref="customDialog" message="Received from"> </GiftedDialog> <GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-bold mb-4">Latest Activity</h2> <h2 class="text-xl font-bold mb-4">Latest Activity</h2>
@ -100,7 +104,7 @@
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm" 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" v-if="record.jwtId == feedLastViewedId"
> >
You've seen all claims below: You've seen all the following before
</div> </div>
<div class="flex"> <div class="flex">
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa> <fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>

2
src/views/ImportAccountView.vue

@ -76,7 +76,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
components: {}, components: {},
}) })
export default class ImportAccountView extends Vue { 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 = ""; mnemonic = "";
address = ""; address = "";

7
src/views/NewEditProjectView.vue

@ -45,11 +45,16 @@
type="checkbox" type="checkbox"
class="mr-2" class="mr-2"
v-model="includeLocation" v-model="includeLocation"
@change="includeLocation = true" @click="includeLocation = !includeLocation"
/> />
<label for="includeLocation">Include Location</label> <label for="includeLocation">Include Location</label>
</div> </div>
<div v-if="includeLocation" style="height: 600px; width: 800px"> <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 <l-map
ref="map" ref="map"
v-model:zoom="zoom" v-model:zoom="zoom"

24
src/views/NewIdentifierView.vue

@ -1,11 +1,25 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div class="mb-8">
Your Identity <!-- Back -->
</h1> <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"> <div class="flex justify-center py-12">
<span /> <span />

163
src/views/ProjectViewView.vue

@ -80,10 +80,21 @@
</button> </button>
</div> </div>
<div class="mb-4">
<div v-if="activeDid" class="text-center">
<button
@click="openOfferDialog({ name: 'you', did: activeDid })"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
I offer&hellip;
</button>
</div>
</div>
<div> <div>
<div v-if="activeDid" class="text-center"> <div v-if="activeDid" class="text-center">
<button <button
@click="openDialog({ name: 'you', did: activeDid })" @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" class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
> >
I gave&hellip; I gave&hellip;
@ -93,7 +104,7 @@
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p> <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"> <ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()"> <li @click="openGiftDialog()">
<EntityIcon <EntityIcon
:entityId="null" :entityId="null"
:iconSize="64" :iconSize="64"
@ -108,7 +119,7 @@
<li <li
v-for="contact in allContacts" v-for="contact in allContacts"
:key="contact.did" :key="contact.did"
@click="openDialog(contact)" @click="openGiftDialog(contact)"
> >
<EntityIcon <EntityIcon
:entityId="contact.did" :entityId="contact.did"
@ -134,7 +145,40 @@
</div> </div>
<!-- Gifts to & from this --> <!-- 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">
Offered To This Project
</h3>
<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>
<span v-if="offer.amount">
<fa icon="coins" class="fa-fw text-slate-400"></fa>
{{ 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"> <div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3"> <h3 class="text-sm uppercase font-semibold mb-3">
Given To This Project Given To This Project
@ -166,44 +210,48 @@
</ul> </ul>
</div> </div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md"> <div class="grid items-start grid-cols-1 gap-4">
<h3 class="text-sm uppercase font-semibold mb-3"> <div
Contributions By This Project v-if="fulfillersToThis.length > 0"
</h3> class="bg-slate-100 px-4 py-3 rounded-md"
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
> >
{{ fulfilledByThis.name }} <h3 class="text-sm uppercase font-semibold mb-3">
</button> Contributions To This Project
</div> </h3>
<ul>
<li v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
>
{{ plan.name }}
</button>
</li>
</ul>
</div>
<div <div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
v-if="fulfillersToThis.length > 0" <h3 class="text-sm uppercase font-semibold mb-3">
class="bg-slate-100 px-4 py-3 rounded-md" Contributions By This Project
> </h3>
<h3 class="text-sm uppercase font-semibold mb-3"> <button
Contributions To This Project @click="onClickLoadProject(fulfilledByThis.handleId)"
</h3> class="text-blue-500"
<ul> >
<li v-for="plan in fulfillersToThis" :key="plan.handleId"> {{ fulfilledByThis.name }}
<button </button>
@click="onClickLoadProject(plan.handleId)" </div>
class="text-blue-500"
>
{{ plan.name }}
</button>
</li>
</ul>
</div> </div>
</div> </div>
<GiftedDialog <GiftedDialog
ref="customDialog" ref="customGiveDialog"
message="Received from" message="Received from"
:projectId="this.projectId" :projectId="this.projectId"
> >
</GiftedDialog> </GiftedDialog>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
</OfferDialog>
</section> </section>
</template> </template>
@ -214,6 +262,7 @@ import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@ -222,6 +271,7 @@ import {
didInfo, didInfo,
GiverInputInfo, GiverInputInfo,
GiveServerRecord, GiveServerRecord,
OfferServerRecord,
PlanServerRecord, PlanServerRecord,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@ -236,7 +286,7 @@ interface Notification {
} }
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
}) })
export default class ProjectViewView extends Vue { export default class ProjectViewView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: Notification, timeout?: number) => void;
@ -250,10 +300,11 @@ export default class ProjectViewView extends Vue {
fulfilledByThis: PlanServerRecord | null = null; fulfilledByThis: PlanServerRecord | null = null;
fulfillersToThis: Array<PlanServerRecord> = []; fulfillersToThis: Array<PlanServerRecord> = [];
givesToThis: Array<GiveServerRecord> = []; givesToThis: Array<GiveServerRecord> = [];
issuer = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
name = ""; name = "";
issuer = ""; offersToThis: Array<OfferServerRecord> = [];
projectId = localStorage.getItem("projectId") || ""; // handle ID projectId = localStorage.getItem("projectId") || ""; // handle ID
timeSince = ""; timeSince = "";
truncatedDesc = ""; truncatedDesc = "";
@ -433,6 +484,42 @@ export default class ProjectViewView extends Vue {
); );
} }
const offersToUrl =
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve offers 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 offers to this project.",
},
-1,
);
console.error(
"Error retrieving offers to this project:",
serverError.message,
);
}
const fulfilledByUrl = const fulfilledByUrl =
this.apiServer + this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" + "/api/v2/report/planFulfilledByPlan?planHandleId=" +
@ -533,8 +620,12 @@ export default class ProjectViewView extends Vue {
); );
} }
openDialog(contact: GiverInputInfo) { openGiftDialog(contact: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(contact); (this.$refs.customGiveDialog as GiftedDialog).open(contact);
}
openOfferDialog() {
(this.$refs.customOfferDialog as typeof OfferDialog).open();
} }
} }
</script> </script>

2
src/views/ProjectsView.vue

@ -174,7 +174,7 @@ export default class ProjectsView extends Vue {
onClickLoadProject(id: string) { onClickLoadProject(id: string) {
localStorage.setItem("projectId", id); localStorage.setItem("projectId", id);
const route = { const route = {
name: "project", path: "/project/" + encodeURIComponent(id),
}; };
this.$router.push(route); this.$router.push(route);
} }

286
src/views/SearchAreaView.vue

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

21
src/views/StartView.vue

@ -3,10 +3,23 @@
id="Content" id="Content"
class="p-6 pb-24 min-h-screen flex flex-col justify-center" class="p-6 pb-24 min-h-screen flex flex-col justify-center"
> >
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div class="mb-8">
Start Here <!-- Back -->
</h1> <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">
Start Here
</h1>
</div>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
<div id="start-question" class="mt-8"> <div id="start-question" class="mt-8">

26
src/views/StatisticsView.vue

@ -1,11 +1,25 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div class="mb-8">
Achievements & Statistics <!-- Back -->
</h1> <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> <div>
Here is a view of the activity you can see. Here is a view of the activity you can see.
@ -32,7 +46,7 @@
{{ worldProperties.animationDurationSeconds }} seconds {{ worldProperties.animationDurationSeconds }} seconds
</div> </div>
</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> <div id="scene-container" class="h-screen"></div>
</section> </section>
</template> </template>

30
src/views/TestView.vue

@ -1,11 +1,25 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Breadcrumb -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div class="mb-8">
Test <!-- Back -->
</h1> <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"> <div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2> <h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
@ -16,14 +30,14 @@
{ {
group: 'alert', group: 'alert',
type: 'toast', type: 'toast',
text: 'I\'m a toast. Don\'t mind me.', text: 'I\'m a toast. Without a timeout, I\'m stuck.',
}, },
5000, 5000,
) )
" "
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
> >
Toast (self-dismiss) Toast
</button> </button>
<button <button

80
sw_scripts/additional-scripts.js

@ -1,33 +1,65 @@
const notifications = require("./safari-notifications.js"); /* eslint-env serviceworker */
/* global workbox */
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
);
self.addEventListener("install", (event) => {
console.error(event);
importScripts(
"safari-notifications.js",
"nacl.js",
"noble-curves.js",
"noble-hashes.js",
);
});
self.addEventListener("push", function (event) { self.addEventListener("push", function (event) {
let payload; event.waitUntil(
if (event.data) { (async () => {
payload = JSON.parse(event.data.text()); try {
} let payload;
if (event.data) {
const title = payload ? payload.title : "Custom Title"; payload = JSON.parse(event.data.text());
const options = { }
body: payload ? payload.body : "Custom body text", const message = await self.getNotificationCount();
icon: payload ? payload.icon : "icon.png", console.error(message);
badge: payload ? payload.badge : "badge.png", const title = payload ? payload.title : "Custom Title";
}; const options = {
body: message,
event.waitUntil(self.registration.showNotification(title, options)); icon: payload ? payload.icon : "icon.png",
badge: payload ? payload.badge : "badge.png",
};
await self.registration.showNotification(title, options);
} catch (error) {
console.error("Error in 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("message", function (event) { self.addEventListener("activate", (event) => {
const data = event.data; event.waitUntil(clients.claim());
console.log("Service worker activated", event);
const result = notifications.getNotificationCount() });
switch (data.command) { self.addEventListener("fetch", (event) => {
case "account": console.log(event.request);
break; });
default: self.addEventListener("error", (event) => {
console.log("Unknown command:", data.command); 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

File diff suppressed because it is too large

5248
sw_scripts/noble-curves.js

File diff suppressed because it is too large

3068
sw_scripts/noble-hashes.js

File diff suppressed because it is too large

5679
sw_scripts/safari-notifications.js

File diff suppressed because it is too large

3
vue.config.js

@ -11,8 +11,9 @@ module.exports = defineConfig({
iconPaths: { iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg", faviconSVG: "img/icons/safari-pinned-tab.svg",
}, },
workboxPluginMode: "InjectManifest",
workboxOptions: { workboxOptions: {
importScripts: ["additional-scripts.js"], swSrc: "./sw_scripts/additional-scripts.js",
}, },
}, },
}); });

Loading…
Cancel
Save