Compare commits

...

44 Commits

Author SHA1 Message Date
08d9ca3a25 Merge branch 'master' into many-misc 2023-11-20 00:36:49 -05:00
607666a2f9 add display of offers on project page 2023-11-19 19:44:11 -07:00
0a618cc4ff add button to record an offer on a project 2023-11-19 17:08:42 -07:00
e387794db3 Merge pull request 'much miscellany for small features and documentation & testing' (#82) from many-misc into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#82
2023-11-18 20:48:49 -05:00
ab1a725c1b fix & doc: don't show number of projects until loaded; refactor task list 2023-11-18 15:18:55 -07:00
46d76013e8 make a back button at the top and the tabs at the bottom of each page 2023-11-18 14:49:38 -07:00
faf8f4f6a9 feat: default to remote project search if no search area filter is selected 2023-11-18 14:38:55 -07:00
154fcd98a5 doc: refactor registration message & tests 2023-11-18 14:13:19 -07:00
c391385500 feat & doc: automatically set visibility & alert about registration, alert to help onboard (and refine docs & tasks) 2023-11-18 13:43:01 -07:00
b64f35869e fix: update openssl script according to new info 2023-11-18 06:25:30 -07:00
45fbf7ade5 Merge pull request 'Update docs, for openssl & tasks' (#81) from openssl into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#81
2023-11-17 23:49:29 -05:00
92fcffdfc5 update the script commands for JWT signature generation & validation 2023-11-17 20:33:57 -07:00
5f5562f5e3 doc: update tasks 2023-11-17 17:47:41 -07:00
74ed025377 move search area selection out of discover onto it's own screen 2023-11-17 15:23:48 -07:00
f36ecfd8db misc look-and-feel tweaks 2023-11-17 13:54:32 -07:00
fc70a11bd8 Merge pull request 'allow access to a different project by link' (#79) from project-ids into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#79
2023-11-12 05:12:30 -05:00
73f890beac modify English verbiage for push-server instructions 2023-11-11 22:26:10 -07:00
67dce9e678 allow for a project ID in the URL 2023-11-11 22:25:06 -07:00
2b66ddfb83 update CHANGELOG 2023-11-11 13:25:05 -07:00
56fc2893a2 fix version in package-lock; modify a project task 2023-11-11 13:11:40 -07:00
Matthew Raymer
552ad5a267 Two static scripts for push notifications 2023-11-08 06:02:07 -05:00
Matthew Raymer
910f57ec7d Update version 2023-11-08 05:53:50 -05:00
Matthew Raymer
e813315dad Fixes and updates 2023-11-08 05:52:29 -05:00
aea9626c06 Merge pull request 'web-push-permissions' (#67) from web-push-permissions into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#67
2023-11-08 01:49:49 -05:00
Matthew Aaron Raymer
7f0f1b7fc8 Merging changes 2023-11-08 14:47:48 +08:00
Matthew Raymer
cfc4d0a947 Updates package file 2023-11-07 21:12:49 +08:00
Matthew Raymer
8684488def Stip out old approach 2023-11-07 08:04:26 -05:00
Matthew Raymer
a820a7b131 Minor formatting changes 2023-11-07 21:01:37 +08:00
30d45c0acf tweak some more tasks (so it's clear which ones require UI expertise) 2023-11-06 18:07:57 -07:00
221bb2a27c move other route into alphabetical order (no logic changes) 2023-11-06 18:04:35 -07:00
2961e29831 move routes into alphabetical order (no logic changes) 2023-11-06 09:09:34 -07:00
5ae5e110c2 refine task list 2023-11-06 09:08:56 -07:00
20c2954be1 remove unused properties 2023-11-06 08:59:31 -07:00
a848e1fa81 Merge pull request 'consolidate into GiftedDialog because the result was always the same' (#76) from more-smalls into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#76
2023-11-06 07:56:07 -05:00
Matthew Raymer
ee28b18b14 Added message handler 2023-11-04 08:46:48 -04:00
Matthew Raymer
2d38183dce Some updates and nudging toward notification ui 2023-10-16 07:43:10 -04:00
Matthew Raymer
082a6eae1f Refactor db setup a bit 2023-10-16 18:49:18 +08:00
Matthew Raymer
d07fb47721 Debugging 2023-10-04 22:16:58 -04:00
Matthew Raymer
ccb6160bca Updates to additional scripts 2023-10-02 04:41:50 -04:00
Matthew Raymer
2eaa4203aa Debugging and ironing out the flow 2023-09-12 08:03:03 -04:00
Matthew Raymer
f27a18c712 Fix db path; add new pathing for web-push; load VAPID at boot 2023-09-11 07:51:59 -04:00
Matthew Raymer
2c4a920c3c Updates for web push workflow 2023-09-08 21:04:50 +08:00
Matthew Raymer
ed91cadd9d Added permission step; UI workflow documentation 2023-09-07 20:42:15 +08:00
Matthew Raymer
a6de282aec Another upgrade of package.json 2023-09-07 18:31:55 +08:00
34 changed files with 19391 additions and 14524 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules
signature.bin
*.pem
verified.txt
myenv
*~
# local env files

View File

@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.2] - 2023.11.01
## [0.1.3] - 2023.11
### Added
- Contact name editing
### Changed
- Don't show actions on front page if not registered.
### Removed
- Home page Notiwind test buttons
## [0.1.2] - 2023.11.01 - 7f6c93802911a030a89fe3706e18b5c17151e5bb
### Added
- Basics: create ID, record a give, declare a project, search, and get notifications.

165
README.md
View File

@@ -26,36 +26,18 @@ npm run build
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
See [this page](openssl_signing_console.rst)
### Register new user on test server
New users require registration. This can be done with a claim payload like this
by an existing user:
```
const vcClaim = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { identifier: identity0.did },
object: SERVICE_ID,
participant: { identifier: newIdentity.did },
};
```
On the test server, User #0 has rights to register others, so you can start
playing one of two ways:
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing this seed phrase:
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
- Import the keys for the test User `did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` by importing this seed phrase:
`rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
- Alternatively, register someone else under User #0 automatically:
@@ -66,14 +48,35 @@ playing one of two ways:
### Create multiple identifiers
Go to /start and create or import a new one. Then switch identifiers on the bottom of the Your Identity page.
Under the "Your Identity" screen, click "Advanced", click "Switch Identity / No Identity", then "Add Another Identity...".
### Create keys with alternate tools
See [this page](openssl_signing_console.rst)
[This page](openssl_signing_console.rst) is a tool to create a JWT from a locally-generated keypair.
### Web-push
For your own web-push tests, change the '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
@@ -90,8 +93,8 @@ See [Configuration Reference](https://cli.vuejs.org/config/).
### Clear data & restart
Clear cache for localhost, then go to http://localhost:8080/start
(because it'll generate a new one automatically if you start on the `/account` page).
Clear the browser cache for localhost.
@@ -102,110 +105,10 @@ Clear cache for localhost, then go to http://localhost:8080/start
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
```
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
* [Customize Vue configuration](https://cli.vuejs.org/config/).
// Import an existing ID
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
// just to get rid of variability that might cause an error
mnemonic = mnemonic.trim().toLowerCase()
/**
// an approach I pieced together
// requires: yarn add elliptic
// ... plus:
// const EC = require('elliptic').ec
// const secp256k1 = new EC('secp256k1')
//
const keyHex: string = bip39.mnemonicToEntropy(mnemonic)
// returns a KeyPair from the elliptic.ec library
const keyPair = secp256k1.keyFromPrivate(keyHex, 'hex')
// this code is from did-provider-eth createIdentifier
const privateHex = keyPair.getPrivate('hex')
const publicHex = keyPair.getPublic('hex')
const address = didJwt.toEthereumAddress(publicHex)
**/
/**
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
// ... which almost works but the didJwt.toEthereumAddress is wrong
// requires: yarn add bip32
// ... plus: import * as bip32 from 'bip32'
//
const seed: Buffer = await bip39.mnemonicToSeed(mnemonic)
const root = bip32.fromSeed(seed)
const node = root.derivePath(UPORT_ROOT_DERIVATION_PATH)
const privateHex = node.privateKey.toString("hex")
const publicHex = node.publicKey.toString("hex")
const address = didJwt.toEthereumAddress('0x' + publicHex)
**/
/**
// from https://github.com/uport-project/veramo/discussions/346#discussioncomment-302234
// requires: yarn add @ethersproject/hdnode
// ... plus: import { HDNode } from '@ethersproject/hdnode'
**/
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic)
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH)
const privateHex = rootNode.privateKey.substring(2) // original starts with '0x'
const publicHex = rootNode.publicKey.substring(2) // original starts with '0x'
let address = rootNode.address
const prevIds = previousIdentifiers || [];
if (toLowercase) {
const foundEqual = R.find(
(id) => utility.rawAddressOfDid(id.did) === address,
prevIds
)
if (foundEqual) {
// They're trying to create a lowercase version of one that exists in normal case.
// (We really should notify the user.)
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a normal-case version of the DID since a regular version exists."}))
} else {
address = address.toLowerCase()
}
} else {
// They're not trying to convert to lowercase.
const foundLower = R.find((id) =>
utility.rawAddressOfDid(id.did) === address.toLowerCase(),
prevIds
)
if (foundLower) {
// They're trying to create a normal case version of one that exists in lowercase.
// (We really should notify the user.)
appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a lowercase version of the DID since a lowercase version exists."}))
address = address.toLowerCase()
}
}
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... derived keys and address..."}))
const newId = newIdentifier(address, publicHex, privateHex, UPORT_ROOT_DERIVATION_PATH)
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... created new ID..."}))
// awaiting because otherwise the UI may not see that a mnemonic was created
const savedId = await storeIdentifier(newId, mnemonic, mnemonicPassword)
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... stored new ID..."}))
return savedId
}
// Create a totally new ID
export const createAndStoreIdentifier = async (mnemonicPassword) => {
// This doesn't give us the entropy/seed.
//const id = await agent.didManagerCreate()
const entropy = crypto.randomBytes(32)
const mnemonic = bip39.entropyToMnemonic(entropy)
appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... generated mnemonic..."}))
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
}
```
## Kudos
### Kudos
Gifts make the world go 'round!

View File

@@ -1,8 +1,11 @@
Prerequisites:
JWT Creation & Verification
jq
To run this in a script, see ./openssl_signing_console.sh
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
Prerequisites: openssl, jq
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using
a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
@@ -15,20 +18,22 @@ openssl ec -in private.pem -pubout -out public.pem
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
Next, create a payload object as a JSON object containing the claims you want to include in the JWT. For example schema.org :
Next, create a payload object as a JSON object containing the claims you want to include in the JWT.
For example schema.org :
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
Concatenate the encoded header, payload, and a secret to create the signing input:
signing_input="$header_b64.$payload_b64"
Create the signature by signing the signing input with a ES256K algorithm and your secret. You can use the openssl command line utility to do this:
Create the signature by signing the signing input with a ES256K algorithm and your secret.
You can use the openssl command line utility to do this:
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
@@ -43,7 +48,7 @@ Authorization: Bearer $jwt
To verify the JWT, you can use the openssl utility with the public key:
openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
This will verify the signature and output Verified OK if the signature is valid. If the signature is not valid, it will output an error.
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature")
This will verify the signature and output "Verified OK" if the signature is valid.
If the signature is not valid, it will give an error response and output "Verification failure".

View File

@@ -1,25 +1,39 @@
#!/bin/bash
# Generate a JWT, with signature verified using OpenSSL
#
# Prerequisites: openssl, jq
#
# Usage: source ./openssl_signing_console.sh
#
# For a more complete explanation, see ./openssl_signing_console.rst
# Generate a key and extract the public part
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
# Use test data
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
signing_input="$header_b64.$payload_b64"
echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem -out signature.bin
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem | openssl base64 -e)
# Read binary signature from file and encode it to Base64 URL-Safe format
signature_b64=$(base64 -w 0 < signature.bin | tr -d '=' | tr '+' '-' | tr '/' '_')
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
# Read binary signature and encode it to Base64 URL-Safe format
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
# Construct the JWT
jwt="$signing_input.$signature_b64"
openssl dgst -sha256 -verify public.pem -signature signature.bin -out verified.txt <(echo -n "$signing_input")
echo Resulting JWT: $jwt

26465
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,59 +9,59 @@
},
"dependencies": {
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@tweenjs/tween.js": "^21.0.0",
"@veramo/core": "^5.2.0",
"@veramo/credential-w3c": "^5.2.0",
"@veramo/data-store": "^5.2.0",
"@veramo/did-manager": "^5.1.2",
"@veramo/did-provider-ethr": "^5.1.2",
"@veramo/did-resolver": "^5.2.0",
"@veramo/key-manager": "^5.1.2",
"@vueuse/core": "^10.2.1",
"@veramo/core": "^5.4.1",
"@veramo/credential-w3c": "^5.4.1",
"@veramo/data-store": "^5.4.1",
"@veramo/did-manager": "^5.4.1",
"@veramo/did-provider-ethr": "^5.4.1",
"@veramo/did-resolver": "^5.4.1",
"@veramo/key-manager": "^5.4.1",
"@vueuse/core": "^10.4.1",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.4.0",
"axios": "^1.5.0",
"buffer": "^6.0.3",
"class-transformer": "^0.5.1",
"core-js": "^3.31.1",
"core-js": "^3.32.1",
"dexie": "^3.2.4",
"dexie-export-import": "^4.0.7",
"did-jwt": "^7.2.4",
"ethereum-cryptography": "^2.0.0",
"did-jwt": "^7.2.7",
"ethereum-cryptography": "^2.1.2",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.0.0",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"localstorage-slim": "^2.4.0",
"luxon": "^3.3.0",
"localstorage-slim": "^2.5.0",
"luxon": "^3.4.3",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"qr-code-generator-vue3": "^1.4.21",
"ramda": "^0.29.0",
"readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"three": "^0.154.0",
"three": "^0.156.1",
"vue": "^3.3.4",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^2.1.20",
"vue-qrcode-reader": "^5.3.4",
"vue-router": "^4.2.3",
"vue-facing-decorator": "^3.0.2",
"vue-qrcode-reader": "^5.4.1",
"vue-router": "^4.2.4",
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/ramda": "^0.29.3",
"@types/three": "^0.152.1",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@types/three": "^0.155.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
@@ -71,16 +71,15 @@
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0-alpha.1",
"eslint-plugin-vue": "^9.15.1",
"autoprefixer": "^10.4.15",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.24",
"prettier": "^3.0.0",
"tailwindcss": "^3.3.2",
"typescript": "~5.1.6"
},
"pkgx": "node^18 npm^10"
"postcss": "^8.4.29",
"prettier": "^3.0.3",
"tailwindcss": "^3.3.3",
"typescript": "~5.2.2"
}
}

View File

@@ -2,59 +2,38 @@
tasks:
- in endorser-push-server - mount folder for persistent sqlite DB outside of container
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
- 40 notifications :
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
- .5 add project ID to the URL of the project-view, to make a project publicly-accessible
- .5 add link to further project / people when a project pays ahead
- .5 show checkbox on "gave" modal to say that user is recipient (so it could be uncheked for someone unspecified)
- 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
- .5 allow to manage their notifications even without an identity
- .5 bug - on the discover page, enter a search term and search and see a duplicate project show at the end of the list
- 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
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) assignee-group:ui
- SEE: https://github.com/emmanuelsw/notiwind assignee:jose assignee-group:ui
- 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
- .2 fit as many icons as possible on home & project view screens but only going halfway down the page
- .1 Remove notification alert visuals on 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
- .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
- .5 include a version, maybe the hash of the latest commit -- figuring out how it works on prod now
- .5 add link to further project / people when a project pays ahead
- .5 add project ID to the URL, to make a project publicly-accessible
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164 assignee:trent
- .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
- .1 Make contact info specific to Time Safari - rather pointing at CommunityCred.org
- 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
- .5 change the derivation path, and regenerate test IDs
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
- .5 customize favicon assignee-group:ui
- .5 Do we want to combine first name & last name?
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker assignee-group:ui
- .5 make a VC details page
- .5 Display a more appealing confirmation on the map when erasing the marker
- .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 remove firstName (& lastName) from localStorage
- .5 include the hash of the latest commit on help page next to version
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
@@ -63,6 +42,7 @@ tasks:
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
@@ -70,6 +50,7 @@ tasks:
- Release Minimum Viable Product :
- 08 thorough testing for errors & edge cases
- 01 ensure ability to recover server remotely, and add redundant access
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Add disclaimers.
- Switch default server to the public server.
@@ -79,7 +60,9 @@ tasks:
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 on DiscoverView, switch to a filter UI (eg. just from friend
- 24 Move to Vite
- 32 accept images for projects
- 32 accept images for contacts
@@ -89,6 +72,10 @@ tasks:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 add "back" button to all screens that aren't part of the bottom tray
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
@@ -105,11 +92,10 @@ tasks:
- Multiple identities
- Peer DID
- DIDComm
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
- Support KERI AIDs
- Support Peer DIDs
- Support messaging through DIDComm
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
- Do we want split first name & last name?
@@ -117,6 +103,7 @@ tasks:
- pull, w/ scheduled runs
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
- 16 From the home screen, make the quick action even easier.
log:
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29

View File

@@ -162,17 +162,22 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="
close(notification.id);
turnOnNotifications();
"
>
Turn on Notifications
</button>
<div class="grid grid-cols-2 gap-2">
<button
@click="close(notification.id)"
@click="maybeLater(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Maybe Later
</button>
<button
@click="never(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
>
Never
@@ -254,4 +259,168 @@
<style></style>
<script lang="ts"></script>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import axios from "axios";
@Component
export default class App extends Vue {
b64 = "";
mounted() {
axios
.get("https://timesafari-pwa.anomalistlabs.com/web-push/vapid")
.then((response) => {
this.b64 = response.data.vapidKey;
console.log(this.b64);
})
.catch((error) => {
console.error("API error", error);
});
}
private askPermission(): Promise<NotificationPermission> {
// Check if Notifications are supported
if (!("Notification" in window)) {
alert("This browser does not support notifications.");
return Promise.reject("This browser does not support notifications.");
}
// Check existing permissions
if (Notification.permission === "granted") {
return Promise.resolve("granted");
}
// Request permission
return new Promise((resolve, reject) => {
const permissionResult = Notification.requestPermission((result) => {
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.");
return Promise.reject("We weren't granted permission.");
}
return permissionResult;
});
}
async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
// Call the function and handle promises
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
// Assuming the subscription object is available
return navigator.serviceWorker.ready;
})
.then((registration) => {
// Fetch the existing subscription object from the registration
return registration.pushManager.getSubscription();
})
.then((subscription) => {
if (subscription) {
console.log(subscription);
return this.sendSubscriptionToServer(subscription);
} else {
throw new Error("Subscription object is not available.");
}
})
.then(() => {
console.log("Subscription data sent to server.");
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
);
});
})
.catch((error) => {
console.error("An error occurred:", error);
// Handle error appropriately here
});
}
// Function to convert URL base64 to Uint8Array
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// The subscribeToPush method
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
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";
console.warn(errorMsg);
reject(new Error(errorMsg));
}
});
}
private sendSubscriptionToServer(
subscription: PushSubscription,
): Promise<void> {
console.log(subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to send subscription to server");
}
console.log("Subscription sent to server successfully.");
});
}
never(ID: string) {
alert(ID);
}
maybeLater(ID: string) {
alert(ID);
}
}
</script>

View File

@@ -0,0 +1,317 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Description, prerequisites, terms, etc."
v-model="description"
/>
<div class="flex flex-row mb-6">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Hours
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="text"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="hours"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex flex-row mb-6">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Expiration
</span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
v-model="expirationDateInput"
/>
</div>
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
<button
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="confirm"
>
Sign &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>

View File

@@ -4,7 +4,7 @@
* See also ../libs/veramo/setup.ts
*/
export enum AppString {
APP_NAME = "Time Safari",
APP_NAME = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",

View File

@@ -9,59 +9,39 @@ import {
} from "./tables/settings";
import { AppString } from "@/constants/app";
// a separate DB because the seed is super-sensitive data
type SensitiveTables = {
accounts: Table<Account>;
};
// Define types for tables that hold sensitive and non-sensitive data
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
settings: Table<Settings>;
};
/**
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
* https://typescript-eslint.io/rules/no-unnecessary-type-constraint/
*
* and change *any* to *unknown*
*
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
*/
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = Object.assign({}, AccountsSchema);
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
const NonsensitiveSchemas = { ...ContactsSchema, ...SettingsSchema };
/**
* Needed to enable a special webpack setting to allow *await* below:
* https://stackoverflow.com/questions/72474803/error-the-top-level-await-experiment-is-not-enabled-set-experiments-toplevelaw
*/
/**
* Create password and place password in localStorage.
*
* It's good practice to keep the data encrypted at rest, so we'll do that even
* if the secret is stored right next to the app.
*/
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
if (localStorage.getItem("secret") == null) {
localStorage.setItem("secret", secret);
}
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(SensitiveSchemas);
// Define the schema for our databases
accountsDB.version(1).stores(SensitiveSchemas);
db.version(1).stores(NonsensitiveSchemas);
// initialize, a la https://dexie.org/docs/Tutorial/Design#the-populate-event
db.on("populate", function () {
// ensure there's an initial entry for settings
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY,
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,

View File

@@ -1,29 +1,47 @@
/**
* BoundingBox type describes the geographical bounding box coordinates.
*/
export type BoundingBox = {
eastLong: number;
maxLat: number;
minLat: number;
westLong: number;
eastLong: number; // Eastern longitude
maxLat: number; // Maximum (Northernmost) latitude
minLat: number; // Minimum (Southernmost) latitude
westLong: number; // Western longitude
};
// a singleton
/**
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
id: number; // there's only one entry: MASTER_SETTINGS_KEY
activeDid?: string;
apiServer?: string;
firstName?: string;
id: number; // Only one entry using MASTER_SETTINGS_KEY
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
firstName?: string; // User's first name
lastName?: string; // User's last name
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string; // Last notified claim ID
isRegistered?: boolean;
lastName?: string; // deprecated, pre v 0.1.3
lastViewedClaimId?: string;
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
name: string;
bbox: BoundingBox;
}>;
showContactGivesInline?: boolean;
showContactGivesInline?: boolean; // Display contact inline or not
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
};
/**
* Schema for the Settings table in the database.
*/
export const SettingsSchema = {
settings: "id",
};
/**
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1;

View File

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

View File

@@ -79,14 +79,6 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
},
{
path: "/discover",
name: "discover",
@@ -99,6 +91,14 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
},
{
path: "/import-account",
name: "import-account",
@@ -148,15 +148,7 @@ const routes: Array<RouteRecordRaw> = [
),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
},
{
path: "/project",
path: "/project/:id?",
name: "project",
component: () =>
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
@@ -168,6 +160,22 @@ const routes: Array<RouteRecordRaw> = [
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
},
{
path: "/search-area",
name: "search-area",
component: () =>
import(
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
),
},
{
path: "/seed-backup",
name: "seed-backup",

View File

@@ -186,8 +186,9 @@
</p>
<p>
You have done {{ limits.doneRegistrationsThisMonth }} registrations
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
registrations counter resets at
out of {{ limits.maxRegistrationsPerMonth }} for this month. (You can
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) }}
</p>
</div>
@@ -655,11 +656,11 @@ export default class AccountViewView extends Vue {
this.$notify(
{
group: "alert",
type: "toast",
type: "success",
title: "Download Started",
text: "See your downloads directory for the backup.",
},
5000,
-1,
);
}

View File

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

View File

@@ -2,10 +2,23 @@
<QuickNav selected="Profile"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
</div>
<!--
Play with display options: https://qr-code-styling.com/
@@ -137,7 +150,7 @@ export default class ContactQRScanShow extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) {
if (content[0]?.rawValue) {
console.log("onDetect", content[0].rawValue);
//console.log("onDetect", content[0].rawValue);
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
this.$router.push({ name: "contacts" });
} else {

View File

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

View File

@@ -41,7 +41,7 @@
Nearby
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
>{{ localCount }}</span
>{{ localCount > -1 ? localCount : "?" }}</span
>
</a>
</li>
@@ -59,7 +59,7 @@
Anywhere
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
>{{ remoteCount }}</span
>{{ remoteCount > -1 ? remoteCount : "?" }}</span
>
</a>
</li>
@@ -67,46 +67,14 @@
</div>
<div v-if="isLocalActive">
<div v-if="!isChoosingSearchBox">
<div>
<button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="isChoosingSearchBox = true"
@click="$router.push({ name: 'search-area' })"
>
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
</button>
</div>
<div v-else>
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
Choose Location Below for Nearby Search
</button>
<button
v-if="isNewMarkerSet"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="storeSearchBox"
>
Store This Location for Nearby Search
</button>
<button
v-if="searchBox"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="forgetSearchBox"
>
Delete Stored Location
</button>
<button
v-if="isNewMarkerSet"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="resetLatLong"
>
Reset Marker
</button>
<button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="cancelSearchBoxSelect"
>
Cancel
</button>
</div>
</div>
<!-- Loading Animation -->
@@ -118,7 +86,7 @@
</div>
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData" v-if="!isChoosingSearchBox">
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
@@ -150,50 +118,11 @@
</li>
</ul>
</InfiniteScroll>
<div
v-if="isLocalActive && isChoosingSearchBox"
style="height: 600px; width: 800px"
>
<l-map
ref="map"
:center="[localCenterLat, localCenterLong]"
v-model:zoom="localZoom"
@click="setMapPoint"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="isNewMarkerSet"
:lat-lng="[localCenterLat, localCenterLong]"
@click="isNewMarkerSet = false"
/>
<l-rectangle
v-if="isNewMarkerSet"
:bounds="[
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
]"
:weight="1"
/>
</l-map>
</div>
</section>
</template>
<script lang="ts">
import { LeafletMouseEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
@@ -204,10 +133,6 @@ import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue";
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
interface Notification {
group: string;
type: string;
@@ -217,13 +142,9 @@ interface Notification {
@Component({
components: {
LRectangle,
QuickNav,
InfiniteScroll,
EntityIcon,
LMap,
LMarker,
LTileLayer,
},
})
export default class DiscoverView extends Vue {
@@ -235,17 +156,10 @@ export default class DiscoverView extends Vue {
apiServer = "";
searchTerms = "";
projects: ProjectData[] = [];
isChoosingSearchBox = false;
isLocalActive = true;
isRemoteActive = false;
isNewMarkerSet = false;
localCenterLat = 0;
localCenterLong = 0;
localLatDiff = DEFAULT_LAT_LONG_DIFF;
localLongDiff = DEFAULT_LAT_LONG_DIFF;
localCount = 0;
localZoom = DEFAULT_ZOOM;
remoteCount = 0;
localCount = -1;
remoteCount = -1;
searchBox: { name: string; bbox: BoundingBox } | null = null;
isLoading = false;
@@ -258,7 +172,6 @@ export default class DiscoverView extends Vue {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.searchBox = settings?.searchBoxes?.[0] || null;
this.resetLatLong();
this.allContacts = await db.contacts.toArray();
@@ -266,7 +179,13 @@ export default class DiscoverView extends Vue {
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
this.searchLocal();
if (this.searchBox) {
await this.searchLocal();
} else {
this.isLocalActive = false;
this.isRemoteActive = true;
await this.searchAll();
}
}
public async buildHeaders(): Promise<HeadersInit> {
@@ -462,128 +381,6 @@ export default class DiscoverView extends Vue {
this.$router.push(route);
}
setMapPoint(event: LeafletMouseEvent) {
if (this.isNewMarkerSet) {
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
} else {
// marker is not set
this.localCenterLat = event.latlng.lat;
this.localCenterLong = event.latlng.lng;
let latDiff = DEFAULT_LAT_LONG_DIFF;
let longDiff = DEFAULT_LAT_LONG_DIFF;
// Guess at a size for the bounding box.
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
const bounds = event.target.boxZoom?._map?.getBounds();
if (bounds) {
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
}
this.localLatDiff = latDiff;
this.localLongDiff = longDiff;
this.isNewMarkerSet = true;
}
}
public resetLatLong() {
if (this.searchBox?.bbox) {
const bbox = this.searchBox.bbox;
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
this.localZoom = WORLD_ZOOM;
this.isNewMarkerSet = true;
} else {
this.isNewMarkerSet = false;
}
}
public async storeSearchBox() {
if (this.localCenterLong || this.localCenterLat) {
try {
const newSearchBox = {
name: "Local",
bbox: {
eastLong: this.localCenterLong + this.localLongDiff,
maxLat: this.localCenterLat + this.localLatDiff,
minLat: this.localCenterLat - this.localLatDiff,
westLong: this.localCenterLong - this.localLongDiff,
},
};
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [newSearchBox],
});
this.searchBox = newSearchBox;
this.isChoosingSearchBox = false;
this.searchLocal();
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
);
console.error(
"Telling user to retry the location search setting because:",
err,
);
}
} else {
this.$notify(
{
group: "alert",
type: "warning",
title: "No Location Selected",
text: "Select a location on the map.",
},
-1,
);
}
}
public async forgetSearchBox() {
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [],
});
this.searchBox = null;
this.localCenterLat = 0;
this.localCenterLong = 0;
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
this.localZoom = DEFAULT_ZOOM;
this.isChoosingSearchBox = false;
this.isNewMarkerSet = false;
this.searchLocal();
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
);
console.error(
"Telling user to retry the location search setting because:",
err,
);
}
}
public cancelSearchBoxSelect() {
this.isChoosingSearchBox = false;
this.localZoom = WORLD_ZOOM;
}
public computedLocalTabClassNames() {
return {
"inline-block": true,

View File

@@ -1,11 +1,25 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help
</h1>
</div>
<div>
<p>
@@ -15,7 +29,7 @@
<h2 class="text-xl font-semibold">What is the philosophy here?</h2>
<p>
We are building networks of people who want to grow a gifting society.
We are building networks of people who want to grow a giving society.
First of all, you can record ways you've seen people give, and that
leaves a permanent record -- one that came from you, and the recipient
can prove it was for them. This is personally gratifying, but it extends
@@ -36,22 +50,39 @@
the control; this app gives you the control.
</p>
<h2 class="text-xl font-semibold">How do I take my first action?</h2>
<h2 class="text-xl font-semibold">How do I get started?</h2>
<p>
You need someone to register you -- usually the person who told you
about this app, on the Contacts
<fa icon="circle-user" class="fa-fw" /> page. After they register you,
you can select any contact on the home page (or "anonymous") and record
your appreciation for... whatever. The main goal is to record what
people have given you, to grow gifting economies. Each claim is recorded
on a custom ledger. The day after being registered, you'll be able to
able to register others; later, you can create projects, too.
<fa icon="users" class="fa-fw" /> page. After they register you, you can
select any contact on the home page (or "anonymous") and record your
appreciation for... whatever. The main goal is to record what people
have given you, to grow giving economies. Each claim is recorded on a
custom ledger. The day after being registered, you'll be able to able to
register others; later, you can create projects, too.
</p>
<p>
Note that there are limits to how many others each person can register,
so you may have to wait.
</p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<button class="text-blue-500" @click="showOnboardInfo">
Click here to show an alert with the steps.
</button>
To start scanning, go
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
</p>
<p>
If they are not nearby to scan QR codes, tell them to copy their ID from
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
typically starts with "did:ethr:...", and send it to you. Go to the
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
top form. To add a name, put a comma and then their name; to add their
public key, put another comma followed by the key.
</p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
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?
</h2>
<ul class="list-disc list-inside">
<li>Make sure you have your backup file (above), then contact us.</li>
<li>
Make sure you have your backup file (above), then contact us with
your interest. This is functionality that has to be written, and
your interest will help us prioritize it, but there are also manual
ways to restore your data.
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">
How do I add someone to my contacts?
</h2>
<p>
Tell them to copy their ID, which typically starts with "did:ethr:...",
and send it to you. Go to the Contacts
<fa icon="circle-user" class="fa-fw" /> page and enter that into the top
form. You may add a name by adding a comma followed by their name; you
may also add their public key by adding another comma followed by the
key.
</p>
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p>
Before doing this, note that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features)
so beware if you think that may cause confusion. You can
so beware. You can
<router-link to="start" class="text-blue-500">
create another identity here.
</router-link>
@@ -151,10 +175,10 @@
<fa icon="eye-slash" class="fa-fw" />.
</p>
<p>
Sometimes the reason you don't see something is because the search time
is limited. Go to the bottom and make sure to load all the data on a
list. If you still don't see it, try a search or view on a different
page.
Sometimes the reason you don't see something is because the search
results are limited. Go to the bottom and make sure to load all the data
on a list. If you still don't see it, try a search or view on a
different page.
</p>
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
@@ -171,11 +195,11 @@
</p>
<h2 class="text-xl font-semibold">
For any other questions, including remove your data:
For any other questions, including removing your data:
</h2>
<p>
Contact us through
<a href="https://communitycred.org">CommunityCred.org</a>.
Contact us at
<a mailto="info@TimeSafari.app">info@TimeSafari.app</a>
</p>
</div>
</section>
@@ -186,8 +210,30 @@ import * as Package from "../../package.json";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ components: { QuickNav } })
export default class Help extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
package = Package;
showOnboardInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Onboard Someone",
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, preferably).
text: "1) Check that they've entered their name. 2) Go to the scanning page via the Identity page and then the through the QR icon at the top, and then scan and register them. 3) Have them go to that page and scan you.",
},
-1,
);
}
}
</script>

View File

@@ -29,7 +29,7 @@
<div v-else>
<!-- 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">
<li @click="openDialog()">

View File

@@ -50,6 +50,11 @@
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" style="height: 600px; width: 800px">
<div class="px-2 py-2">
For your security, we recommend you choose a location nearby but not
exactly at the place.
</div>
<l-map
ref="map"
v-model:zoom="zoom"

View File

@@ -1,11 +1,25 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Identity
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Identity
</h1>
</div>
<div class="flex justify-center py-12">
<span />

View File

@@ -80,10 +80,21 @@
</button>
</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 v-if="activeDid" class="text-center">
<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"
>
I gave&hellip;
@@ -93,7 +104,7 @@
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()">
<li @click="openGiftDialog()">
<EntityIcon
:entityId="null"
:iconSize="64"
@@ -108,7 +119,7 @@
<li
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
@click="openGiftDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
@@ -134,13 +145,48 @@
</div>
<!-- Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Given to this Project
Offered To This Project
</h3>
<ul class="text-sm border-t border-slate-300">
<div v-if="offersToThis.length === 0">
(None yet. Record one above.)
</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="offer in offersToThis"
:key="offer.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
</span>
<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">
<h3 class="text-sm uppercase font-semibold mb-3">
Given To This Project
</h3>
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
:key="give.id"
@@ -164,42 +210,46 @@
</ul>
</div>
<div class="bg-slate-100 px-4 py-3 rounded-md">
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
&hellip;and from this Project
Contributions By This Project
</h3>
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
<ul class="text-sm border-t border-slate-300">
<li
v-for="give in givesByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span
><fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
</span>
<span v-if="give.amount"
><fa icon="coins" class="fa-fw text-slate-400"></fa>
{{ give.amount }}
</span>
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400"></fa>
{{ give.description }}
</div>
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions To This Project
</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>
<GiftedDialog
ref="customDialog"
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
</OfferDialog>
</section>
</template>
@@ -210,6 +260,7 @@ import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@@ -218,6 +269,8 @@ import {
didInfo,
GiverInputInfo,
GiveServerRecord,
OfferServerRecord,
PlanServerRecord,
} from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
@@ -231,7 +284,7 @@ interface Notification {
}
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
})
export default class ProjectViewView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
@@ -242,12 +295,14 @@ export default class ProjectViewView extends Vue {
apiServer = "";
description = "";
expanded = false;
fulfilledByThis: PlanServerRecord | null = null;
fulfillersToThis: Array<PlanServerRecord> = [];
givesToThis: Array<GiveServerRecord> = [];
givesByThis: Array<GiveServerRecord> = [];
issuer = "";
latitude = 0;
longitude = 0;
name = "";
issuer = "";
offersToThis: Array<OfferServerRecord> = [];
projectId = localStorage.getItem("projectId") || ""; // handle ID
timeSince = "";
truncatedDesc = "";
@@ -266,7 +321,12 @@ export default class ProjectViewView extends Vue {
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
this.LoadProject(identity);
const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) {
this.projectId = decodeURIComponent(pathParam);
}
this.LoadProject(this.projectId, identity);
}
public async getIdentity(activeDid: string) {
@@ -320,11 +380,11 @@ export default class ProjectViewView extends Vue {
this.expanded = false;
}
async LoadProject(identity: IIdentifier) {
async LoadProject(projectId: string, identity: IIdentifier) {
this.projectId = projectId;
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
@@ -389,7 +449,7 @@ export default class ProjectViewView extends Vue {
const givesInUrl =
this.apiServer +
"/api/v2/report/givesForPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
@@ -422,21 +482,21 @@ export default class ProjectViewView extends Vue {
);
}
const givesOutUrl =
const offersToUrl =
this.apiServer +
"/api/v2/report/givesProvidedBy?providerId=" +
encodeURIComponent(this.projectId);
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesOutUrl, { headers });
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesByThis = resp.data.data;
this.offersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives by this project.",
text: "Failed to retrieve offers to this project.",
},
-1,
);
@@ -448,15 +508,100 @@ export default class ProjectViewView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives by project.",
text: "Something went wrong retrieving offers to this project.",
},
-1,
);
console.error(
"Error retrieving gives by this project:",
"Error retrieving offers to this project:",
serverError.message,
);
}
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfilledByUrl, { headers });
if (resp.status === 200) {
this.fulfilledByThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
-1,
);
console.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
const fulfillersToUrl =
this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfillersToUrl, { headers });
if (resp.status === 200) {
this.fulfillersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plan fulfillers to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plan fulfillers to this project.",
},
-1,
);
console.error(
"Error retrieving plan fulfillers to this project:",
serverError.message,
);
}
}
/**
* Handle clicking on a project entry found in the list
* @param id of the project
**/
async onClickLoadProject(projectId: string) {
localStorage.setItem("projectId", projectId);
const route = {
path: "/project/" + encodeURIComponent(projectId),
};
this.$router.push(route);
this.LoadProject(projectId, await this.getIdentity(this.activeDid));
}
getOpenStreetMapUrl() {
@@ -473,8 +618,12 @@ export default class ProjectViewView extends Vue {
);
}
openDialog(contact: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(contact);
openGiftDialog(contact: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
}
openOfferDialog() {
(this.$refs.customOfferDialog as OfferDialog).open();
}
}
</script>

View File

@@ -98,8 +98,6 @@ export default class ProjectsView extends Vue {
projects: ProjectData[] = [];
current: IIdentifier;
isLoading = false;
alertTitle = "";
alertMessage = "";
numAccounts = 0;
async beforeCreate() {

View File

@@ -0,0 +1,286 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Area for Nearby Search
</h1>
</div>
<div class="px-2 py-4">
This location is only stored on your device. It is used to show you more
appropriate projects but is not stored on any servers.
</div>
<div>
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
Click to Choose a Location for Nearby Search
</button>
<button
v-if="isNewMarkerSet"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="storeSearchBox"
>
Store This Location for Nearby Search
</button>
<button
v-if="searchBox"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="forgetSearchBox"
>
Delete Stored Location
</button>
<button
v-if="searchBox"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="resetLatLong"
>
Reset Marker
</button>
<button
v-if="isNewMarkerSet"
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="isNewMarkerSet = false"
>
Erase Marker
</button>
<div v-if="isNewMarkerSet">
Click on the pin to erase it. Click anywhere else to set a different
different corner.
</div>
</div>
<div style="height: 600px; width: 800px">
<l-map
ref="map"
:center="[localCenterLat, localCenterLong]"
v-model:zoom="localZoom"
@click="setMapPoint"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="isNewMarkerSet"
:lat-lng="[localCenterLat, localCenterLong]"
@click="isNewMarkerSet = false"
/>
<l-rectangle
v-if="isNewMarkerSet"
:bounds="[
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
]"
:weight="1"
/>
</l-map>
</div>
</section>
</template>
<script lang="ts">
import { LeafletMouseEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { db } from "@/db/index";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: {
QuickNav,
LRectangle,
LMap,
LMarker,
LTileLayer,
},
})
export default class DiscoverView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
isChoosingSearchBox = false;
isNewMarkerSet = false;
// "local" vars are for the currently selected map box
localCenterLat = 0;
localCenterLong = 0;
localLatDiff = DEFAULT_LAT_LONG_DIFF;
localLongDiff = DEFAULT_LAT_LONG_DIFF;
localZoom = DEFAULT_ZOOM;
// searchBox reflects what is stored in the database
searchBox: { name: string; bbox: BoundingBox } | null = null;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.searchBox = settings?.searchBoxes?.[0] || null;
this.resetLatLong();
}
setMapPoint(event: LeafletMouseEvent) {
if (this.isNewMarkerSet) {
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
} else {
// marker is not set
this.localCenterLat = event.latlng.lat;
this.localCenterLong = event.latlng.lng;
let latDiff = DEFAULT_LAT_LONG_DIFF;
let longDiff = DEFAULT_LAT_LONG_DIFF;
// Guess at a size for the bounding box.
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
const bounds = event.target.boxZoom?._map?.getBounds();
if (bounds) {
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
}
this.localLatDiff = latDiff;
this.localLongDiff = longDiff;
this.isNewMarkerSet = true;
}
}
public resetLatLong() {
if (this.searchBox?.bbox) {
const bbox = this.searchBox.bbox;
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
this.localZoom = WORLD_ZOOM;
this.isNewMarkerSet = true;
} else {
this.isNewMarkerSet = false;
}
}
public async storeSearchBox() {
if (this.localCenterLong || this.localCenterLat) {
try {
const newSearchBox = {
name: "Local",
bbox: {
eastLong: this.localCenterLong + this.localLongDiff,
maxLat: this.localCenterLat + this.localLatDiff,
minLat: this.localCenterLat - this.localLatDiff,
westLong: this.localCenterLong - this.localLongDiff,
},
};
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [newSearchBox],
});
this.searchBox = newSearchBox;
this.isChoosingSearchBox = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Saved",
text: "That has been saved in your preferences.",
},
-1,
);
this.$router.back();
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
);
console.error(
"Telling user to retry the location search setting because:",
err,
);
}
} else {
this.$notify(
{
group: "alert",
type: "warning",
title: "No Location Selected",
text: "Select a location on the map.",
},
-1,
);
}
}
public async forgetSearchBox() {
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [],
});
this.searchBox = null;
this.localCenterLat = 0;
this.localCenterLong = 0;
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
this.localZoom = DEFAULT_ZOOM;
this.isChoosingSearchBox = false;
this.isNewMarkerSet = false;
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
);
console.error(
"Telling user to retry the location search setting because:",
err,
);
}
}
public cancelSearchBoxSelect() {
this.isChoosingSearchBox = false;
this.localZoom = WORLD_ZOOM;
}
}
</script>

View File

@@ -3,10 +3,23 @@
id="Content"
class="p-6 pb-24 min-h-screen flex flex-col justify-center"
>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Start Here
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Start Here
</h1>
</div>
<!-- id used by puppeteer test script -->
<div id="start-question" class="mt-8">

View File

@@ -1,11 +1,25 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Achievements & Statistics
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Achievements & Statistics
</h1>
</div>
<div>
Here is a view of the activity you can see.
@@ -32,7 +46,7 @@
{{ worldProperties.animationDurationSeconds }} seconds
</div>
</div>
<button class="float-right" @click="captureGraphics()">Screenshot</button>
<button class="float-right text-blue-600" @click="captureGraphics()">Screenshot</button>
<div id="scene-container" class="h-screen"></div>
</section>
</template>

View File

@@ -1,11 +1,25 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Test
</h1>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Test
</h1>
</div>
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
@@ -16,14 +30,14 @@
{
group: 'alert',
type: 'toast',
text: 'I\'m a toast. Don\'t mind me.',
text: 'I\'m a toast. Without a timeout, I\'m stuck.',
},
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

View File

@@ -0,0 +1,33 @@
const notifications = require("./safari-notifications.js");
self.addEventListener("push", function (event) {
let payload;
if (event.data) {
payload = JSON.parse(event.data.text());
}
const title = payload ? payload.title : "Custom Title";
const options = {
body: payload ? payload.body : "Custom body text",
icon: payload ? payload.icon : "icon.png",
badge: payload ? payload.badge : "badge.png",
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("message", function (event) {
const data = event.data;
const result = notifications.getNotificationCount()
switch (data.command) {
case "account":
break;
default:
console.log("Unknown command:", data.command);
}
});

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,3 +1,6 @@
# Overivew of Web Push
Web Push notifications is a web browser messaging protocol defined by the W3C.
Discussions of this interesting technology are clouded because of a
@@ -26,8 +29,8 @@ from the SERVICE.
The SERVICE will provide context and obtain explicit permission before prompting
for notification permission:
In order to provide this context and explict permission a two-step opt-in process
where the user is first presented with a pre-permission dialog box that explains
In order to provide this context and explicit permission, a two-step opt-in process
first presents the user with a pre-permission dialog box that explains
what the notifications are for and why they are useful. This may help reduce the
possibility of users clicking "don't allow".
@@ -88,7 +91,7 @@ The `sw.js` file contains the logic for what a service worker should do.
It executes in a separate thread of execution from the web page but provides a
means of communicating between itself and the web page via messages.
Note that there is a scope can specify what network requests it may
Note that there is a scope that can specify what network requests it may
intercept.
The Vue project already has its own service worker but it is possible to
@@ -360,30 +363,30 @@ unsubscribeFromPush().catch((err) => {
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
## NOTIFICATION DIALOG WORKFLOW
# NOTIFICATION DIALOG WORKFLOW
# ON APP FIRST-LAUNCH:
## ON APP FIRST-LAUNCH:
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
- "Turn on Notifications": triggers the browser's own notification permission prompt.
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
# IF THE USER CHOOSES "NEVER":
## IF THE USER CHOOSES "NEVER":
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
# TO TEMPORARILY MUTE NOTIFICATIONS:
## TO TEMPORARILY MUTE NOTIFICATIONS:
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
- "Mute until I turn it back on" button to indefinitely mute notifications.
- "Cancel" to make no changes and dismiss the dialog.
# TO UNMUTE NOTIFICATIONS:
## TO UNMUTE NOTIFICATIONS:
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
# TO TURN OFF NOTIFICATIONS:
## TO TURN OFF NOTIFICATIONS:
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
- "Leave it On" to make no changes and dismiss the dialog.
- "Leave it On" to make no changes and dismiss the dialog.