forked from trent_larson/crowd-funder-for-time-pwa
Compare commits
175 Commits
world-fix
...
move-id-sw
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d15b7bfb8 | |||
| 5c57ee3e72 | |||
|
|
3f7bcbfd76 | ||
| ef0988c9ec | |||
| 22de6113e9 | |||
| 87139f203c | |||
| c8de13d376 | |||
| 2ccfb283b4 | |||
| 552fce3281 | |||
| 12de3dec4f | |||
| b171e1ae13 | |||
| dc54006fca | |||
| 9b4db018f5 | |||
| 519f320a2e | |||
|
|
f1b3094026 | ||
|
|
e5ad87f4d5 | ||
|
|
7de6171911 | ||
|
|
bb6bacac97 | ||
|
|
40fc6a29a4 | ||
|
|
9ec19fa4ee | ||
| 28b20f86ea | |||
| 502109de4b | |||
| 97274a701d | |||
| 81a6d73f2f | |||
| 5804f692b7 | |||
| 257aa8d49e | |||
| 34806b514b | |||
| 0024238ca8 | |||
| 0af05b4b0d | |||
| b9d59eb642 | |||
| 0c05505c46 | |||
| 98c093f655 | |||
| 88112e0629 | |||
|
|
6ab92a83bd | ||
|
|
bfc52151c0 | ||
|
|
868b5413de | ||
|
|
50005a0dc3 | ||
|
|
9247b6ed1f | ||
|
|
75f26ccf2d | ||
|
|
bfd2498906 | ||
|
|
4933017e9c | ||
|
|
18c23451bb | ||
|
|
304985f88d | ||
|
|
9a41aff8f0 | ||
|
|
e19cd980b4 | ||
| 6d1756b4a5 | |||
| ac4c92d8e8 | |||
| 937a3cb6c6 | |||
| 194f741984 | |||
| b31c0d975c | |||
| f09684d7cd | |||
| 1767a48a7f | |||
| d2e2fc707e | |||
| bf6830a1a8 | |||
|
|
fe09f5180d | ||
|
|
5addc3c206 | ||
|
|
69f2f3cfd2 | ||
|
|
4de66b1609 | ||
|
|
4b87692231 | ||
|
|
503bb1bd93 | ||
|
|
9fa3b8be0b | ||
| 3b1a9b9c5b | |||
|
|
f55e50067f | ||
| 7f48149d6f | |||
| c5b4921583 | |||
| b28689ad06 | |||
| 0444b5be32 | |||
| 4866416aae | |||
| e48a4ed05b | |||
| 87cfead094 | |||
| 179a5cd9f8 | |||
|
|
eff67c2a4a | ||
|
|
db22d559b7 | ||
|
|
c4443f2ed1 | ||
| be348461f1 | |||
| 6e2c596030 | |||
|
|
05a7758c65 | ||
|
|
c502869c5f | ||
|
|
b7aacd63e6 | ||
|
|
5bc0e27b30 | ||
|
|
a4fe94f081 | ||
|
|
8de95566df | ||
|
|
97569697f6 | ||
|
|
b9ed9d748b | ||
|
|
790d44db81 | ||
| e2bf469dc1 | |||
| d0ec7930e1 | |||
| 3e2cd1291c | |||
| 8d42fe905d | |||
| 592ffacebc | |||
| b706e65598 | |||
|
|
6e3066ae92 | ||
| a7e98c8f1a | |||
| f07c804b24 | |||
|
|
e8eae544f3 | ||
|
|
7c77578f79 | ||
|
|
34636d6047 | ||
|
|
5134e2f562 | ||
| 91b46eaaee | |||
| 31d1a449ae | |||
| 1248132076 | |||
| 015704c94e | |||
| 540ef916c2 | |||
| bee7c87a8f | |||
| 6bbc88f86c | |||
| 624abbb179 | |||
| 110ed009b2 | |||
| a5892238d5 | |||
| 8eb80a9ede | |||
|
|
32125133f0 | ||
|
|
47ade49e31 | ||
|
|
47ce91cca1 | ||
|
|
3e52b504b0 | ||
|
|
4ecea1ab0e | ||
|
|
b9fdc920ea | ||
|
|
0907d59a6a | ||
| 59ce15c744 | |||
|
|
9960a96a20 | ||
|
|
098c6c0fa0 | ||
|
|
ead37ede74 | ||
|
|
f428199228 | ||
|
|
1405b88323 | ||
|
|
44fc2850dd | ||
|
|
52d411470e | ||
|
|
ab678a900a | ||
|
|
efa59e170f | ||
|
|
7a4ceaa455 | ||
| d7a9fb6d54 | |||
| 78b98bab5e | |||
| 2493f2ad39 | |||
| cf2b80b1f5 | |||
| 00954693b5 | |||
| 2dd77f898f | |||
| c1f218c2f3 | |||
| b5e78e5dc8 | |||
| b86323ec83 | |||
| 8add6448fb | |||
| 47442655cb | |||
|
|
1d362c314b | ||
| 3eda246e85 | |||
|
|
3f13d3ea33 | ||
|
|
cef346e487 | ||
|
|
fed23a61ee | ||
|
|
b6b7c56157 | ||
| 3f8be3b4de | |||
| 21af37c2c2 | |||
| 0b7a35c9b8 | |||
| 0257901c5b | |||
| d9d6096275 | |||
| ed7d37c649 | |||
| 81dd6eb595 | |||
|
|
c61bb88788 | ||
|
|
3bd55f3ad2 | ||
|
|
3471afdf25 | ||
|
|
e25a83ff1b | ||
|
|
0fbdb45d3e | ||
|
|
dc23ba1375 | ||
|
|
08137eb000 | ||
|
|
5d49965166 | ||
| 8e8aa4356d | |||
| 59a354027e | |||
|
|
5dc80ce12a | ||
|
|
754bced2a9 | ||
|
|
e3f58bd593 | ||
|
|
3b41014083 | ||
| f568149745 | |||
| a27d035e9b | |||
| 16d0be681c | |||
|
|
5be67fd4c9 | ||
|
|
dda3ad057d | ||
|
|
cf54096326 | ||
| 49c3971cf2 | |||
| cd8bc73bac | |||
| e42b3ff11d | |||
|
|
4758a740de |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,13 +1,16 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
signature.bin
|
||||
*.pem
|
||||
verified.txt
|
||||
|
||||
*~
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -11,6 +11,9 @@ npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
Prerequisites:
|
||||
|
||||
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:
|
||||
|
||||
25
openssl_signing_console.sh
Executable file
25
openssl_signing_console.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
||||
openssl ec -in private.pem -pubout -out public.pem
|
||||
|
||||
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')
|
||||
|
||||
signing_input="$header_b64.$payload_b64"
|
||||
|
||||
echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem -out signature.bin
|
||||
|
||||
# Read binary signature from file and encode it to Base64 URL-Safe format
|
||||
signature_b64=$(base64 -w 0 < signature.bin | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
|
||||
# Construct the JWT
|
||||
jwt="$signing_input.$signature_b64"
|
||||
|
||||
openssl dgst -sha256 -verify public.pem -signature signature.bin -out verified.txt <(echo -n "$signing_input")
|
||||
|
||||
|
||||
1016
package-lock.json
generated
1016
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -21,45 +21,47 @@
|
||||
"@veramo/did-provider-ethr": "^5.1.2",
|
||||
"@veramo/did-resolver": "^5.2.0",
|
||||
"@veramo/key-manager": "^5.1.2",
|
||||
"@vueuse/core": "^10.2.0",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"axios": "^1.4.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"core-js": "^3.31.0",
|
||||
"core-js": "^3.31.1",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-export-import": "^4.0.7",
|
||||
"did-jwt": "^7.2.2",
|
||||
"did-jwt": "^7.2.4",
|
||||
"ethereum-cryptography": "^2.0.0",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"ethr-did-resolver": "^8.0.0",
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"localstorage-slim": "^2.4.0",
|
||||
"luxon": "^3.3.0",
|
||||
"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",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"ramda": "^0.29.0",
|
||||
"readable-stream": "^4.4.0",
|
||||
"readable-stream": "^4.4.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"three": "^0.153.0",
|
||||
"three": "^0.154.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-class-component": "^8.0.0-0",
|
||||
"vue-facing-decorator": "^2.1.20",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^4.2.2",
|
||||
"web-did-resolver": "^2.0.24"
|
||||
"vue-router": "^4.2.3",
|
||||
"web-did-resolver": "^2.0.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ramda": "^0.29.2",
|
||||
"@types/ramda": "^0.29.3",
|
||||
"@types/three": "^0.152.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||
"@typescript-eslint/parser": "^5.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||
"@vue/cli-plugin-pwa": "~5.0.8",
|
||||
@@ -69,13 +71,14 @@
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.15.0",
|
||||
"eslint-plugin-prettier": "^5.0.0-alpha.1",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "~5.1.3"
|
||||
"typescript": "~5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,79 @@
|
||||
|
||||
tasks:
|
||||
- replace user-affecting console.log & console.error with error messages (eg. catches)
|
||||
- if there's no identity, handle it on pages which expect an identity (eg. project -- look for JSON.parse identity calls)
|
||||
- .1 show an appropriate message when there are no contacts
|
||||
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
|
||||
- .2 bug - on contacts view, click on "to" & "from" and nothing happens
|
||||
- 40 notifications :
|
||||
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
|
||||
|
||||
- 8 Move to vue-facing-decorator
|
||||
- 01 add my bounding box(es) of interest for searches on Nearby part of Discovery page
|
||||
- .5 search by a bounding box(s) of interest for local projects (see API by clicking on "Nearby")
|
||||
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
|
||||
|
||||
- 01 design ideas for simple gives on the first page
|
||||
- 08 Scan QR code to import into contacts assignee:matthew
|
||||
- SEE: https://github.com/gruhn/vue-qrcode-reader
|
||||
|
||||
- .1 remove commitments from ProjectView UI
|
||||
- 01 add list of 'give' records for a project on ProjectView UI
|
||||
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
|
||||
|
||||
- 02 Discover page - display results (currently in console.log), spin when searching
|
||||
- 08 search by location, endpoint, etc assignee:trent
|
||||
- 01 add a location for a project via map pin :
|
||||
- give attribute to use assignee:trent
|
||||
- 01 remove all the "form" fields (or at least investigate to see if that page refresh is desired)
|
||||
|
||||
- 08 Scan QR code to import into contacts.
|
||||
|
||||
- contacts v1 :
|
||||
- 01 Import contact info a la QR code.
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 move all "identity" references to temporary account access assignee:trent
|
||||
|
||||
- contacts v+ :
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
- 01 parse input more robustly (with CSV lib and not commas)
|
||||
|
||||
- refactor UI :
|
||||
- .5 Alerts show at the top and can be missed, eg. account data download
|
||||
- 01 Change alert popup code a component (to cut down duplicate code; see "in many files")
|
||||
- 01 Change "nav" tabs across the bottom into a component (eliminating duplicate code).
|
||||
- .5 Fix how icons show on top of bottom bar on ContactAmounts page
|
||||
- .2 Hide "Advanced" section in Account page by default
|
||||
|
||||
- show pop-up confirming that settings & contacts have been downloaded
|
||||
|
||||
- Ensure each action sent to the server has a confirmation - registration
|
||||
- 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 https://github.com/konvajs/vue-konva
|
||||
- 01 quick action - send action, maybe choose via canvas tool
|
||||
- SEE: https://github.com/konvajs/vue-konva
|
||||
|
||||
- .5 customize favicon
|
||||
- .2 Hide "Advanced" section in Account page by default
|
||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
||||
- 24 Move to Vite assignee:matthew
|
||||
|
||||
- 24 Move to Vite
|
||||
|
||||
- 40 notifications :
|
||||
- push
|
||||
- .5 include the hash of the latest commit, and maybe a version
|
||||
- .5 add link to further project / people when a project pays ahead
|
||||
- .5 add project ID to the URL, to make a project publicly-accessible
|
||||
- .5 remove edit from project page for projects owned by others
|
||||
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
|
||||
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
|
||||
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
|
||||
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
|
||||
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
|
||||
- Discuss whether the remaining tasks are worthwhile before MVP release.
|
||||
|
||||
- 01 fix images on project page, on discovery page
|
||||
- .2 fix "Rotary" and static icon to the right on project page
|
||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
||||
- .5 change the derivation path, and regenerate test IDs
|
||||
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
|
||||
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
|
||||
- .5 customize favicon assignee-group:ui
|
||||
- .5 Do we want to combine first name & last name?
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
|
||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
|
||||
- .5 Display a more appealing confirmation on the map when erasing the marker assignee-group:ui
|
||||
|
||||
- contacts v+ :
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
- 01 parse input more robustly (with CSV lib and not commas)
|
||||
|
||||
- stats v1 :
|
||||
- 01 show numeric stats
|
||||
- 01 link to world for specific stats
|
||||
- .5 don't load another instance of a bush if it already exists
|
||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
|
||||
- 4-8 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||
|
||||
- Do we want split first name & last name?
|
||||
- remove 'about' page
|
||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||
|
||||
- Release Minimum Viable Product :
|
||||
- 08 thorough testing for errors & edge cases
|
||||
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
|
||||
- Add disclaimers.
|
||||
- Rename DB to TimeSafari.
|
||||
- Switch default server to the public server.
|
||||
- Deploy to a server.
|
||||
- Ensure public server has limits that work for group adoption.
|
||||
- Test PWA features on Android and iOS.
|
||||
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
|
||||
|
||||
- 40 notifications v+ :
|
||||
- pull, w/ scheduled runs
|
||||
|
||||
- linking between projects or plans :
|
||||
- terminology:
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
||||
- show total time given to & from a project
|
||||
- terminology:
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
||||
|
||||
- Stats :
|
||||
- 01 point out user's location on the world
|
||||
@@ -104,11 +93,14 @@ tasks:
|
||||
|
||||
- Peer DID
|
||||
|
||||
|
||||
- DIDComm
|
||||
|
||||
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
|
||||
|
||||
- Do we want split first name & last name?
|
||||
|
||||
- 40 notifications v+ :
|
||||
- pull, w/ scheduled runs
|
||||
|
||||
log:
|
||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
||||
|
||||
185
src/App.vue
185
src/App.vue
@@ -1,5 +1,190 @@
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<!-- https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enter-from="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4"
|
||||
enter-to="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave="transition ease-in duration-500"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
move="transition duration-500"
|
||||
move-delay="delay-300"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="w-full"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
v-if="notification.type === 'toast'"
|
||||
class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md"
|
||||
>
|
||||
<div class="w-full px-4 py-3">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notification.type === 'info'"
|
||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-100 rounded-lg shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
|
||||
>
|
||||
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
|
||||
>
|
||||
<fa icon="xmark" class="fa-fw"></fa>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notification.type === 'success'"
|
||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-emerald-100 rounded-lg shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
|
||||
>
|
||||
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
|
||||
>
|
||||
<fa icon="xmark" class="fa-fw"></fa>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notification.type === 'warning'"
|
||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-amber-100 rounded-lg shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
|
||||
>
|
||||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
|
||||
>
|
||||
<fa icon="xmark" class="fa-fw"></fa>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notification.type === 'danger'"
|
||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-rose-100 rounded-lg shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
|
||||
>
|
||||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
|
||||
>
|
||||
<fa icon="xmark" class="fa-fw"></fa>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Notification>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
|
||||
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
|
||||
leave="transition ease-in duration-500"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
move="transition duration-500"
|
||||
move-delay="delay-300"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="w-full"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
v-if="notification.type === 'notification-permission'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">
|
||||
Would you like to turn on notifications for this app?
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Never
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Notification>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-bind:class="computedAlertClassNames()">
|
||||
<button
|
||||
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
|
||||
class="close-button bg-amber-400 w-8 leading-loose rounded-full absolute top-2 right-2"
|
||||
@click="onClickClose()"
|
||||
>
|
||||
<fa icon="xmark"></fa>
|
||||
@@ -28,7 +28,7 @@ export default class AlertMessage extends Vue {
|
||||
return {
|
||||
hidden: !this.isAlertVisible,
|
||||
"dismissable-alert": true,
|
||||
"bg-slate-100": true,
|
||||
"bg-amber-200": true,
|
||||
"p-5": true,
|
||||
rounded: true,
|
||||
"drop-shadow-lg": true,
|
||||
|
||||
19
src/components/EntityIcon.vue
Normal file
19
src/components/EntityIcon.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div v-html="generateIdenticon()" class="w-fit"></div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { toSvg } from "jdenticon";
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = "";
|
||||
|
||||
generateIdenticon() {
|
||||
const svgString = toSvg(this.entityId, this.iconSize);
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
@@ -1,89 +1,108 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-lg text-center">
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ message }} {{ giver?.name || "somebody not specified" }}
|
||||
</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What was received"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row">
|
||||
<span class="py-4">Hours</span>
|
||||
<div class="flex flex-row mb-6">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center 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="block w-8 rounded border border-slate-400 ml-4 text-center"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="hours"
|
||||
/>
|
||||
<div class="flex flex-col px-1">
|
||||
<div>
|
||||
<fa icon="square-caret-up" size="2xl" @click="increment()" />
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="square-caret-down" size="2xl" @click="decrement()" />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-right">Sign & Send to publish to the world</p>
|
||||
<div class="text-right">
|
||||
<button class="rounded border border-slate-400" @click="confirm">
|
||||
<span class="m-2">Sign & Send</span>
|
||||
</button>
|
||||
|
||||
<button class="rounded border border-slate-400" @click="cancel">
|
||||
<span class="m-2">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ["message"],
|
||||
data() {
|
||||
return {
|
||||
giver: null,
|
||||
description: "",
|
||||
hours: "0",
|
||||
visible: false,
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Emit } from "vue-facing-decorator";
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
@Prop message = "";
|
||||
|
||||
giver = null;
|
||||
description = "";
|
||||
hours = "0";
|
||||
visible = false;
|
||||
|
||||
open(giver) {
|
||||
// giver: GiverInputInfo
|
||||
this.giver = giver;
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
confirm() {
|
||||
const result = {
|
||||
action: "confirm",
|
||||
giver: this.giver,
|
||||
hours: parseFloat(this.hours),
|
||||
description: this.description,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open(giver) {
|
||||
// giver: GiverInputInfo
|
||||
this.giver = giver;
|
||||
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)}`;
|
||||
},
|
||||
confirm() {
|
||||
this.close();
|
||||
this.$emit("dialog-result", {
|
||||
action: "confirm",
|
||||
giver: this.giver,
|
||||
hours: parseFloat(this.hours),
|
||||
description: this.description,
|
||||
});
|
||||
this.description = "";
|
||||
this.giver = null;
|
||||
this.hours = "0";
|
||||
},
|
||||
cancel() {
|
||||
this.close();
|
||||
this.$emit("dialog-result", { action: "cancel" });
|
||||
},
|
||||
},
|
||||
};
|
||||
this.close();
|
||||
this.description = "";
|
||||
this.giver = null;
|
||||
this.hours = "0";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
cancel() {
|
||||
const result = { action: "cancel" };
|
||||
this.close();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -97,12 +116,14 @@ export default {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 50%;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br />
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-cli documentation</a
|
||||
>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>babel</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>pwa</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuex</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>eslint</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>typescript</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
|
||||
>Forum</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
|
||||
>Community Chat</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
|
||||
>Twitter</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-devtools#vue-devtools"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vue-devtools</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-loader</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/awesome-vue"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>awesome-vue</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
msg: String,
|
||||
},
|
||||
})
|
||||
export default class HelloWorld extends Vue {
|
||||
msg!: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@ export default class InfiniteScroll extends Vue {
|
||||
};
|
||||
this.observer = new IntersectionObserver(
|
||||
this.handleIntersection,
|
||||
options
|
||||
options,
|
||||
);
|
||||
this.observer.observe(this.$refs.sentinel as HTMLElement);
|
||||
}
|
||||
|
||||
93
src/components/QuickNav.vue
Normal file
93
src/components/QuickNav.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li
|
||||
:class="{
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Home',
|
||||
'text-slate-500': selected !== 'Home',
|
||||
}"
|
||||
>
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li
|
||||
:class="{
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Discover',
|
||||
'text-slate-500': selected !== 'Discover',
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li
|
||||
:class="{
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Projects',
|
||||
'text-slate-500': selected !== 'Projects',
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li
|
||||
:class="{
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Contacts',
|
||||
'text-slate-500': selected !== 'Contacts',
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li
|
||||
:class="{
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Profile',
|
||||
'text-slate-500': selected !== 'Profile',
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
|
||||
@Component
|
||||
export default class QuickNav extends Vue {
|
||||
@Prop selected = "";
|
||||
}
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@ function createCamera() {
|
||||
35, // fov = Field Of View
|
||||
1, // aspect ratio (dummy value)
|
||||
0.1, // near clipping plane
|
||||
350 // far clipping plane
|
||||
350, // far clipping plane
|
||||
);
|
||||
|
||||
// move the camera back so we can view the scene
|
||||
|
||||
@@ -22,17 +22,16 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "undefined");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const token = await accessToken(identity);
|
||||
|
||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
|
||||
const resp = await axios.get(url, { headers: headers });
|
||||
if (resp.status === 200) {
|
||||
const landmarks = resp.data.data;
|
||||
@@ -63,7 +62,11 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
|
||||
// calculate positions for each claim, especially because some are random
|
||||
const locations = landmarks.map((claim) =>
|
||||
locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
|
||||
locForGive(
|
||||
claim,
|
||||
world.PLATFORM_SIZE,
|
||||
world.PLATFORM_EDGE_FOR_UNKNOWNS,
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
@@ -93,7 +96,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
undefined,
|
||||
function (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// calculate when lights shine on appearing claim area
|
||||
@@ -121,7 +124,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
.onComplete(() => {
|
||||
scene.remove(light);
|
||||
light.dispose();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.start();
|
||||
world.lights = [...world.lights, light];
|
||||
@@ -130,18 +133,18 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
console.error(
|
||||
"Got bad server response status & data of",
|
||||
resp.status,
|
||||
resp.data
|
||||
resp.data,
|
||||
);
|
||||
vue.setAlert(
|
||||
"Error With Server",
|
||||
"There was an error retrieving your claims from the server."
|
||||
"There was an error retrieving your claims from the server.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got exception contacting server:", error);
|
||||
vue.setAlert(
|
||||
"Error With Server",
|
||||
"There was a problem retrieving your claims from the server."
|
||||
"There was a problem retrieving your claims from the server.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
export enum AppString {
|
||||
APP_NAME = "Kick-Start with Time",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://endorser.ch:3000",
|
||||
TEST_ENDORSER_API_SERVER = "https://test.endorser.ch:8000",
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
|
||||
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
|
||||
|
||||
@@ -28,12 +28,12 @@ type NonsensitiveTables = {
|
||||
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
|
||||
*/
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export const accountsDB = new BaseDexie("KickStartAccounts") as SensitiveDexie;
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = Object.assign({}, AccountsSchema);
|
||||
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
export const db = new BaseDexie("KickStart") as NonsensitiveDexie;
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,6 @@ if (localStorage.getItem("secret") == null) {
|
||||
localStorage.setItem("secret", secret);
|
||||
}
|
||||
|
||||
//console.log("IndexedDB Encryption Secret:", secret);
|
||||
encrypted(accountsDB, { secretKey: secret });
|
||||
accountsDB.version(1).stores(SensitiveSchemas);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { HDNode } from "@ethersproject/hdnode";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -20,7 +22,7 @@ export const newIdentifier = (
|
||||
address: string,
|
||||
publicHex: string,
|
||||
privateHex: string,
|
||||
derivationPath: string
|
||||
derivationPath: string,
|
||||
): Omit<IIdentifier, keyof "provider"> => {
|
||||
return {
|
||||
did: DEFAULT_DID_PROVIDER_NAME + ":" + address,
|
||||
@@ -46,18 +48,18 @@ export const newIdentifier = (
|
||||
* @return {*} {[string, string, string, string]}
|
||||
*/
|
||||
export const deriveAddress = (
|
||||
mnemonic: string
|
||||
mnemonic: string,
|
||||
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
||||
): [string, string, string, string] => {
|
||||
const UPORT_ROOT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
||||
mnemonic = mnemonic.trim().toLowerCase();
|
||||
|
||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic);
|
||||
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH);
|
||||
const rootNode: HDNode = hdnode.derivePath(derivationPath);
|
||||
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x'
|
||||
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x'
|
||||
const address = rootNode.address;
|
||||
|
||||
return [address, privateHex, publicHex, UPORT_ROOT_DERIVATION_PATH];
|
||||
return [address, privateHex, publicHex, derivationPath];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,7 +136,7 @@ export function fromJose(signature: string): {
|
||||
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
|
||||
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
|
||||
throw new TypeError(
|
||||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`
|
||||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
|
||||
);
|
||||
}
|
||||
const r = bytesToHex(signatureBytes.slice(0, 32));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IIdentifier } from "@veramo/core";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { Axios, AxiosResponse } from "axios";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
||||
export const SERVICE_ID = "endorser.ch";
|
||||
@@ -81,16 +82,21 @@ export function isHiddenDid(did) {
|
||||
/**
|
||||
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
|
||||
**/
|
||||
export function didInfo(did, identifiers, contacts) {
|
||||
const myId = R.find((i) => i.did === did, identifiers);
|
||||
export function didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
): string {
|
||||
const myId: string | undefined = R.find(R.equals(did), allMyDids, did);
|
||||
if (myId) {
|
||||
return "You";
|
||||
return "You" + (myId !== activeDid ? " (Alt ID)" : "");
|
||||
} else {
|
||||
const contact = R.find((c) => c.did === did, contacts);
|
||||
const contact: Contact | undefined = R.find((c) => c.did === did, contacts);
|
||||
if (contact) {
|
||||
return contact.name || "Someone Unnamed in Contacts";
|
||||
} else if (!did) {
|
||||
return "Unpecified Person";
|
||||
return "Unspecified Person";
|
||||
} else if (isHiddenDid(did)) {
|
||||
return "Someone Not In Network";
|
||||
} else {
|
||||
@@ -100,7 +106,7 @@ export function didInfo(did, identifiers, contacts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* For result, see https://endorser.ch:3000/api-docs/#/claims/post_api_v2_claim
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param fromDid may be null
|
||||
@@ -116,7 +122,7 @@ export async function createAndSubmitGive(
|
||||
toDid: string,
|
||||
description: string,
|
||||
hours: number,
|
||||
fulfillsProjectHandleId?: string
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<AxiosResponse<ClaimResult> | InternalError> {
|
||||
// Make a claim
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
@@ -193,3 +199,53 @@ export function isNumeric(str: string): boolean {
|
||||
export function numberOrZero(str: string): number {
|
||||
return isNumeric(str) ? +str : 0;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RateLimits {
|
||||
doneClaimsThisWeek: string;
|
||||
doneRegistrationsThisMonth: string;
|
||||
maxClaimsPerWeek: string;
|
||||
maxRegistrationsPerMonth: string;
|
||||
nextMonthBeginDateTime: string;
|
||||
nextWeekBeginDateTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents data about a project
|
||||
**/
|
||||
export interface ProjectData {
|
||||
/**
|
||||
* Name of the project
|
||||
**/
|
||||
name: string;
|
||||
/**
|
||||
* Description of the project
|
||||
**/
|
||||
description: string;
|
||||
/**
|
||||
* URL referencing information about the project
|
||||
**/
|
||||
handleId: string;
|
||||
/**
|
||||
* The Identier of the project
|
||||
**/
|
||||
rowid: string;
|
||||
}
|
||||
|
||||
export interface VerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
}
|
||||
|
||||
export interface WorldProperties {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ function didProviderName(netName: string) {
|
||||
const DEFAULT_DID_PROVIDER_NETWORK_NAME = "mainnet";
|
||||
|
||||
export const DEFAULT_DID_PROVIDER_NAME = didProviderName(
|
||||
DEFAULT_DID_PROVIDER_NETWORK_NAME
|
||||
DEFAULT_DID_PROVIDER_NETWORK_NAME,
|
||||
);
|
||||
|
||||
export const HANDY_APP = false;
|
||||
|
||||
18
src/main.ts
18
src/main.ts
@@ -5,20 +5,26 @@ import "./registerServiceWorker";
|
||||
import router from "./router";
|
||||
import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
|
||||
import "./assets/styles/tailwind.css";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
faGift,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
@@ -43,21 +50,27 @@ import {
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
@@ -68,6 +81,7 @@ library.add(
|
||||
faGift,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
@@ -82,9 +96,10 @@ library.add(
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark
|
||||
faXmark,
|
||||
);
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
@@ -94,4 +109,5 @@ createApp(App)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications)
|
||||
.mount("#app");
|
||||
|
||||
@@ -7,7 +7,7 @@ if (process.env.NODE_ENV === "production") {
|
||||
ready() {
|
||||
console.log(
|
||||
"App is being served from cache by a service worker.\n" +
|
||||
"For more details, visit https://goo.gl/AFskqB"
|
||||
"For more details, visit https://goo.gl/AFskqB",
|
||||
);
|
||||
},
|
||||
registered() {
|
||||
@@ -24,7 +24,7 @@ if (process.env.NODE_ENV === "production") {
|
||||
},
|
||||
offline() {
|
||||
console.log(
|
||||
"No internet connection found. App is running in offline mode."
|
||||
"No internet connection found. App is running in offline mode.",
|
||||
);
|
||||
},
|
||||
error(error) {
|
||||
|
||||
@@ -25,12 +25,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
name: "about",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/account",
|
||||
name: "account",
|
||||
@@ -97,6 +91,14 @@ const routes: Array<RouteRecordRaw> = [
|
||||
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/import-derive",
|
||||
name: "import-derive",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-account",
|
||||
name: "new-edit-account",
|
||||
@@ -129,6 +131,14 @@ const routes: Array<RouteRecordRaw> = [
|
||||
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/identity-switcher",
|
||||
name: "identity-switcher",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/project",
|
||||
name: "project",
|
||||
@@ -164,6 +174,14 @@ const routes: Array<RouteRecordRaw> = [
|
||||
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-gives",
|
||||
name: "contact-gives",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "statistics" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {*} */
|
||||
@@ -172,4 +190,13 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const errorHandler = (error, to, from) => {
|
||||
// Handle the error here
|
||||
console.error("Caught in top level error handler:", error, to, from);
|
||||
|
||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||
};
|
||||
|
||||
router.onError(errorHandler); // Assign the error handler to the router instance
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
components: {},
|
||||
})
|
||||
export default class AboutView extends Vue {}
|
||||
</script>
|
||||
@@ -1,59 +1,25 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
|
||||
Your Identity
|
||||
</h1>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span />
|
||||
<span class="whitespace-nowrap">
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
|
||||
>
|
||||
<fa icon="qrcode" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2">
|
||||
<span />
|
||||
<span>
|
||||
@@ -100,14 +66,6 @@
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showDidCopy">Copied!</span>
|
||||
<span class="whitespace-nowrap ml-4">
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md ml-1"
|
||||
>
|
||||
<fa icon="qrcode" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
|
||||
@@ -155,10 +113,24 @@
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'new-edit-account' }"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Edit Identity
|
||||
</router-link>
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="block w-full text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Data</h3>
|
||||
|
||||
@@ -179,133 +151,140 @@
|
||||
|
||||
<!-- QR code popup -->
|
||||
<dialog id="dlgQR" class="backdrop:bg-black/75 rounded-md">
|
||||
<form method="dialog">
|
||||
<div class="text-slate-500 text-center">
|
||||
<b>ID:</b> <code>did:peer:kl45kj41lk451kl3</code>
|
||||
</div>
|
||||
<img src="/img/sample-qr-code.png" class="w-full mb-3" />
|
||||
<div class="text-slate-500 text-center">
|
||||
<b>ID:</b> <code>did:peer:kl45kj41lk451kl3</code>
|
||||
</div>
|
||||
<img src="/img/sample-qr-code.png" class="w-full mb-3" />
|
||||
|
||||
<button
|
||||
value="cancel"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button
|
||||
value="cancel"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
value="cancel"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button
|
||||
value="cancel"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</dialog>
|
||||
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Advanced</h3>
|
||||
|
||||
<label
|
||||
for="toggleShowAmounts"
|
||||
class="flex items-center cursor-pointer mb-6"
|
||||
@click="handleChange"
|
||||
<h3
|
||||
class="text-sm uppercase font-semibold mb-3"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showContactGives"
|
||||
name="showContactGives"
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
</div>
|
||||
<!-- label -->
|
||||
<div class="ml-2">Show amounts given with contacts</div>
|
||||
</label>
|
||||
|
||||
<div class="flex py-2">
|
||||
<button class="text-center text-md text-blue-500" @click="checkLimits()">
|
||||
Check Limits
|
||||
</button>
|
||||
<!-- show spinner if loading limits -->
|
||||
<div v-if="loadingLimits" class="ml-2">
|
||||
Checking... <fa icon="spinner" class="fa-spin"></fa>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
{{ limitsMessage }}
|
||||
</div>
|
||||
<div v-if="!!limits?.nextWeekBeginDateTime" class="px-9">
|
||||
<span class="font-bold">Rate Limits</span>
|
||||
<p>
|
||||
You have done {{ limits.doneClaimsThisWeek }} claims out of
|
||||
{{ limits.maxClaimsPerWeek }} for this week. Your claims counter
|
||||
resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
|
||||
</p>
|
||||
<p>
|
||||
You have done {{ limits.doneRegistrationsThisMonth }} registrations
|
||||
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
|
||||
registrations counter resets at
|
||||
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
Claim Server
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||||
v-model="apiServerInput"
|
||||
/>
|
||||
<button
|
||||
v-if="apiServerInput != apiServer"
|
||||
class="px-4 rounded bg-red-500 border border-slate-400"
|
||||
@click="onClickSaveApiServer()"
|
||||
Advanced
|
||||
</h3>
|
||||
<div v-if="showAdvanced">
|
||||
<label
|
||||
for="toggleShowAmounts"
|
||||
class="flex items-center cursor-pointer mb-6"
|
||||
@click="handleChange"
|
||||
>
|
||||
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.PROD_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Prod
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.TEST_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Test
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.LOCAL_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Local
|
||||
</button>
|
||||
</div>
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showContactGives"
|
||||
name="showContactGives"
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
</div>
|
||||
<!-- label -->
|
||||
<div class="ml-2">Show amounts given with contacts</div>
|
||||
</label>
|
||||
|
||||
<div v-if="numAccounts > 0" class="flex py-2">
|
||||
Switch Identifier
|
||||
<span v-for="accountNum in numAccounts" :key="accountNum">
|
||||
<button class="text-blue-500 px-2" @click="switchAccount(accountNum)">
|
||||
#{{ accountNum }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="text-blue-500">
|
||||
<router-link
|
||||
:to="{ name: 'statistics' }"
|
||||
class="block text-center py-3"
|
||||
<div class="flex py-2">
|
||||
<button
|
||||
class="text-center text-md text-blue-500"
|
||||
@click="checkLimits()"
|
||||
>
|
||||
See Achievements & Statistics
|
||||
Check Limits
|
||||
</button>
|
||||
<!-- show spinner if loading limits -->
|
||||
<div v-if="loadingLimits" class="ml-2">
|
||||
Checking... <fa icon="spinner" class="fa-spin"></fa>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
{{ limitsMessage }}
|
||||
</div>
|
||||
<div v-if="!!limits?.nextWeekBeginDateTime" class="px-9">
|
||||
<span class="font-bold">Rate Limits</span>
|
||||
<p>
|
||||
You have done {{ limits.doneClaimsThisWeek }} claims out of
|
||||
{{ limits.maxClaimsPerWeek }} for this week. Your claims counter
|
||||
resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
|
||||
</p>
|
||||
<p>
|
||||
You have done {{ limits.doneRegistrationsThisMonth }} registrations
|
||||
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
|
||||
registrations counter resets at
|
||||
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
<router-link
|
||||
:to="{ name: 'identity-switcher' }"
|
||||
class="block text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
Switch Identity / No Identity
|
||||
</router-link>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
Claim Server
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||||
v-model="apiServerInput"
|
||||
/>
|
||||
<button
|
||||
v-if="apiServerInput != apiServer"
|
||||
class="px-4 rounded bg-red-500 border border-slate-400"
|
||||
@click="onClickSaveApiServer()"
|
||||
>
|
||||
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.PROD_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Prod
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.TEST_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Test
|
||||
</button>
|
||||
<button
|
||||
class="px-4 rounded bg-slate-200 border border-slate-400"
|
||||
@click="setApiServerInput(Constants.LOCAL_ENDORSER_API_SERVER)"
|
||||
>
|
||||
Use Local
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="text-blue-500">
|
||||
<router-link
|
||||
:to="{ name: 'statistics' }"
|
||||
class="block text-center py-3"
|
||||
>
|
||||
See Achievements & Statistics
|
||||
</router-link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
@@ -316,7 +295,6 @@
|
||||
|
||||
<script lang="ts">
|
||||
import "dexie-export-import";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
@@ -326,20 +304,13 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { AxiosError } from "axios/index";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
interface RateLimits {
|
||||
doneClaimsThisWeek: string;
|
||||
doneRegistrationsThisMonth: string;
|
||||
maxClaimsPerWeek: string;
|
||||
maxRegistrationsPerMonth: string;
|
||||
nextMonthBeginDateTime: string;
|
||||
nextWeekBeginDateTime: string;
|
||||
}
|
||||
|
||||
@Component({ components: { AlertMessage } })
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
export default class AccountViewView extends Vue {
|
||||
Constants = AppString;
|
||||
|
||||
@@ -362,6 +333,29 @@ export default class AccountViewView extends Vue {
|
||||
showB64Copy = false;
|
||||
showPubCopy = false;
|
||||
|
||||
showAdvanced = false;
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||
doCopyTwoSecRedo(text, fn) {
|
||||
fn();
|
||||
@@ -379,7 +373,11 @@ export default class AccountViewView extends Vue {
|
||||
return timeStr.substring(0, timeStr.indexOf("T"));
|
||||
}
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
async created() {
|
||||
// Uncomment this to register this user on the test server.
|
||||
// To manage within the vue devtools browser extension https://devtools.vuejs.org/
|
||||
@@ -397,27 +395,42 @@ export default class AccountViewView extends Vue {
|
||||
this.lastName = settings?.lastName || "";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
this.numAccounts = accounts.length;
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
this.publicHex = identity.keys[0].publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
this.derivationPath = identity.keys[0].meta.derivationPath;
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
this.checkLimits();
|
||||
if (identity) {
|
||||
this.publicHex = identity.keys[0].publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString(
|
||||
"base64",
|
||||
);
|
||||
this.derivationPath = identity.keys[0].meta.derivationPath;
|
||||
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
this.checkLimitsFor(identity);
|
||||
}
|
||||
} catch (err) {
|
||||
this.alertMessage =
|
||||
"Clear your cache and start over (after data backup).";
|
||||
console.error("Telling user to clear cache at page create because:", err);
|
||||
this.alertTitle = "Error Creating Account";
|
||||
if (
|
||||
err.message ===
|
||||
"Attempted to load account records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Creating Account",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,13 +441,19 @@ export default class AccountViewView extends Vue {
|
||||
showContactGivesInline: this.showContactGives,
|
||||
});
|
||||
} catch (err) {
|
||||
this.alertMessage =
|
||||
"Clear your cache and start over (after data backup).";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Contact Setting",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to clear cache after contact setting update because:",
|
||||
err
|
||||
err,
|
||||
);
|
||||
this.alertTitle = "Error Updating Contact Setting";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,66 +469,99 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.alertTitle = "Download Started";
|
||||
this.alertMessage = "See your downloads directory for the backup.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Download Started",
|
||||
text: "See your downloads directory for the backup.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} catch (error) {
|
||||
this.alertTitle = "Export Error";
|
||||
this.alertMessage = "See console logs for more info.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: "See console logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Export Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimits() {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (identity) {
|
||||
this.checkLimitsFor(identity);
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimitsFor(identity: IIdentifier) {
|
||||
this.loadingLimits = true;
|
||||
this.limitsMessage = "";
|
||||
|
||||
const url = this.apiServer + "/api/report/rateLimits";
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const url = this.apiServer + "/api/report/rateLimits";
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
// axios throws an exception on a 400
|
||||
if (resp.status === 200) {
|
||||
this.limits = resp.data;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
if (
|
||||
error.message ===
|
||||
"Attempted to load Give records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Bad response retrieving limits: ", serverError);
|
||||
|
||||
console.error("Bad response retrieving limits: ", serverError);
|
||||
// Anybody know how to access items inside "response.data" without this?
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any = serverError.response?.data;
|
||||
this.limitsMessage = data?.error?.message || "Bad server response.";
|
||||
const data: ErrorResponse | undefined =
|
||||
serverError.response && serverError.response.data;
|
||||
if (data && data.error && data.error.message) {
|
||||
this.limitsMessage = data.error.message;
|
||||
} else {
|
||||
this.limitsMessage = "Bad server response.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.loadingLimits = false;
|
||||
}
|
||||
|
||||
async switchAccount(accountNum: number) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = accounts[accountNum - 1];
|
||||
// 0 means none
|
||||
if (accountNum === 0) {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: undefined,
|
||||
});
|
||||
this.activeDid = "";
|
||||
this.derivationPath = "";
|
||||
this.publicHex = "";
|
||||
this.publicBase64 = "";
|
||||
} else {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = accounts[accountNum - 1];
|
||||
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: account.did,
|
||||
});
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: account.did,
|
||||
});
|
||||
|
||||
this.activeDid = account.did;
|
||||
this.derivationPath = account.derivationPath;
|
||||
this.publicHex = account.publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
this.activeDid = account.did;
|
||||
this.derivationPath = account.derivationPath;
|
||||
this.publicHex = account.publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
public showContactGivesClassNames() {
|
||||
@@ -530,9 +582,5 @@ export default class AccountViewView extends Vue {
|
||||
setApiServerInput(value) {
|
||||
this.apiServerInput = value;
|
||||
}
|
||||
|
||||
// This same popup code is in many files.
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,42 +15,40 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Would you like to add <i>Firstname</i> to your network?
|
||||
</p>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Would you like to add <i>Firstname</i> to your network?
|
||||
</p>
|
||||
|
||||
<!-- Account Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
|
||||
<!-- Account Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
|
||||
|
||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||
<div class="text-sm text-slate-500 mb-1">
|
||||
<span><code>did:peer:kl45kj41lk451kl3</code></span>
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||
<div class="text-sm text-slate-500 mb-1">
|
||||
<span><code>did:peer:kl45kj41lk451kl3</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ConfirmContactView extends Vue {}
|
||||
|
||||
@@ -1,114 +1,95 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<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">
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="users" class="fa-fw"></fa
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<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>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div>
|
||||
<div class="border-b border-slate-300 flex">
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-1/4">from them</div>
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-1/4">to them</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-slate-300 flex"
|
||||
v-for="record in giveRecords"
|
||||
:key="record.id"
|
||||
>
|
||||
<div class="w-1/4">
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" class="tooltip">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Confirmed</span>
|
||||
</span>
|
||||
<button v-else class="tooltip" @click="confirm(record)">
|
||||
<fa icon="circle" class="text-blue-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Unconfirmed</span>
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
{{ record.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/8">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<fa icon="long-arrow-alt-left" class="text-slate-900 fa-fw ml-1" />
|
||||
</span>
|
||||
<span v-else>
|
||||
|
||||
<fa icon="long-arrow-alt-right" class="text-slate-900 fa-fw ml-1" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" class="tooltip">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Confirmed</span>
|
||||
</span>
|
||||
<button v-else class="tooltip" @click="cannotConfirmMessage()">
|
||||
<fa icon="circle" class="text-slate-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Unconfirmed</span>
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
{{ record.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
class="table-auto w-full border-t border-slate-300 text-sm sm:text-base text-center"
|
||||
>
|
||||
<thead class="bg-slate-100">
|
||||
<tr class="border-b border-slate-300">
|
||||
<th></th>
|
||||
<th class="px-1 py-2">From Them</th>
|
||||
<th></th>
|
||||
<th class="px-1 py-2">To Them</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in giveRecords"
|
||||
:key="record.id"
|
||||
class="border-b border-slate-300"
|
||||
>
|
||||
<td class="p-1 text-xs sm:text-sm text-left text-slate-500">
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button v-else @click="confirm(record)" title="Unconfirmed">
|
||||
<fa icon="circle" class="text-blue-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa icon="arrow-right" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click="cannotConfirmMessage()"
|
||||
title="Unconfirmed"
|
||||
>
|
||||
<fa icon="circle" class="text-slate-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
@@ -133,8 +114,9 @@ import {
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError } from "axios";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
@Component({ components: { AlertMessage } })
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
export default class ContactsView extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
@@ -142,8 +124,38 @@ export default class ContactsView extends Vue {
|
||||
giveRecords: Array<GiveServerRecord> = [];
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
@@ -158,38 +170,31 @@ export default class ContactsView extends Vue {
|
||||
this.loadGives(this.activeDid, this.contact);
|
||||
}
|
||||
} catch (err) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
err.userMessage ||
|
||||
"There was an error retrieving the latest sweet, sweet action.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loadGives(activeDid: string, contact: Contact) {
|
||||
// only load the private keys temporarily when needed
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
|
||||
// load all the time I have given to them
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
let result = [];
|
||||
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(identity.did) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const headers = await this.getHeaders(identity);
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
result = resp.data.data;
|
||||
@@ -197,11 +202,17 @@ export default class ContactsView extends Vue {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp.status,
|
||||
resp.data
|
||||
resp.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Got an error retrieving your given time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage =
|
||||
"Got an error retrieving your given time from the server.";
|
||||
}
|
||||
|
||||
const url2 =
|
||||
@@ -210,11 +221,7 @@ export default class ContactsView extends Vue {
|
||||
encodeURIComponent(contact.did) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(identity.did);
|
||||
const token2 = await accessToken(identity);
|
||||
const headers2 = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token2,
|
||||
};
|
||||
const headers2 = await this.getHeaders(identity);
|
||||
const resp2 = await this.axios.get(url2, { headers: headers2 });
|
||||
if (resp2.status === 200) {
|
||||
result = R.concat(result, resp2.data.data);
|
||||
@@ -222,22 +229,35 @@ export default class ContactsView extends Vue {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp2.status,
|
||||
resp2.data
|
||||
resp2.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Got an error retrieving your given time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage =
|
||||
"Got an error retrieving your given time from the server.";
|
||||
}
|
||||
|
||||
const sortedResult: Array<GiveServerRecord> = R.sort(
|
||||
(a, b) =>
|
||||
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
||||
result
|
||||
result,
|
||||
);
|
||||
this.giveRecords = sortedResult;
|
||||
} catch (error) {
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = error as string;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +286,7 @@ export default class ContactsView extends Vue {
|
||||
};
|
||||
|
||||
// Create a signature using private key of identity
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (identity.keys[0].privateKeyHex !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
|
||||
@@ -296,7 +310,6 @@ export default class ContactsView extends Vue {
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
//console.log("Got resp data:", resp.data);
|
||||
if (resp.data?.success) {
|
||||
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
|
||||
}
|
||||
@@ -313,15 +326,29 @@ export default class ContactsView extends Vue {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cannotConfirmMessage() {
|
||||
this.alertTitle = "Not Allowed";
|
||||
this.alertMessage = "Only the recipient can confirm final receipt.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not Allowed",
|
||||
text: "Only the recipient can confirm final receipt.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
311
src/views/ContactGiftingView.vue
Normal file
311
src/views/ContactGiftingView.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<QuickNav selected="Home"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'home' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
|
||||
Give to Contacts
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search -->
|
||||
|
||||
<!-- Initial Loading Animation -->
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="border-t border-slate-300">
|
||||
<li class="border-b border-slate-300 py-3">
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow italic text-slate-500"
|
||||
><EntityIcon
|
||||
entityId="Anonymous"
|
||||
:iconSize="32"
|
||||
class="opacity-50 inline-block align-middle border border-dashed border-slate-400 bg-slate-200 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
Anonymous
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="openDialog()"
|
||||
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
|
||||
>
|
||||
<fa icon="gift" class="fa-fw"></fa>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
class="border-b border-slate-300 py-3"
|
||||
>
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow font-semibold"
|
||||
><EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="openDialog(contact)"
|
||||
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
|
||||
>
|
||||
<fa icon="gift" class="fa-fw"></fa>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
@dialog-result="handleDialogResult"
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { createAndSubmitGive } from "@/libs/endorserServer";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
activeDid = "";
|
||||
allAccounts: Array<Account> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
isHiddenSpinner = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
accounts: AccountsSchema;
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
accountsDB.open();
|
||||
this.accounts = accountsDB.accounts;
|
||||
this.numAccounts = await this.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await accountsDB.open();
|
||||
this.allAccounts = await accountsDB.accounts.toArray();
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
||||
this.updateAllFeed();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
openDialog(giver) {
|
||||
this.$refs.customDialog.open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
// action was "cancel" so do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordGive(giverDid, description, hours) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
);
|
||||
|
||||
if (isGiveCreationError(result)) {
|
||||
const errorMessage = getGiveCreationErrorMessage(result);
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error with give caught:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
getGiveErrorMessage(error) ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setAlert(title, message) {
|
||||
this.alertTitle = title;
|
||||
this.alertMessage = message;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
isGiveCreationError(result) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
getGiveCreationErrorMessage(result) {
|
||||
return result.data?.error?.message;
|
||||
}
|
||||
|
||||
getGiveErrorMessage(error) {
|
||||
return error.userMessage || error.response?.data?.error?.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,52 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
@@ -64,10 +17,6 @@
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -79,7 +28,8 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import * as R from "ramda";
|
||||
import { SimpleSigner } from "@/libs/crypto";
|
||||
import * as didJwt from "did-jwt";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
@@ -87,7 +37,7 @@ const Buffer = require("buffer/").Buffer;
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue3,
|
||||
AlertMessage,
|
||||
QuickNav,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScanShow extends Vue {
|
||||
@@ -95,7 +45,23 @@ export default class ContactQRScanShow extends Vue {
|
||||
apiServer = "";
|
||||
qrValue = "";
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account: Account | undefined = R.find(
|
||||
(acc) => acc.did === activeDid,
|
||||
accounts,
|
||||
);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
@@ -106,13 +72,17 @@ export default class ContactQRScanShow extends Vue {
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
if (!account) {
|
||||
this.alertMessage = "You have no identity yet.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "",
|
||||
text: "You have no identity yet.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const publicKeyHex = identity.keys[0].publicKeyHex;
|
||||
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
||||
const contactInfo = {
|
||||
@@ -137,9 +107,5 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.qrValue = viewPrefix + vcJwt;
|
||||
}
|
||||
}
|
||||
|
||||
// This same popup code is in many files.
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,82 +14,76 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code…</h3>
|
||||
<div class="bg-black rounded overflow-hidden relative mb-4">
|
||||
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code…</h3>
|
||||
<div class="bg-black rounded overflow-hidden relative mb-4">
|
||||
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
|
||||
|
||||
<!-- Darken overlay -->
|
||||
<!-- Top -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Reft -->
|
||||
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Right -->
|
||||
<div
|
||||
class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"
|
||||
></div>
|
||||
<!-- Bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Darken overlay -->
|
||||
<!-- Top -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Reft -->
|
||||
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Right -->
|
||||
<div class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
|
||||
<!-- Reticle overlay -->
|
||||
<!-- Top-left -->
|
||||
<div
|
||||
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Top-right -->
|
||||
<div
|
||||
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-left -->
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-right -->
|
||||
<div
|
||||
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Reticle overlay -->
|
||||
<!-- Top-left -->
|
||||
<div
|
||||
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Top-right -->
|
||||
<div
|
||||
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-left -->
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-right -->
|
||||
<div
|
||||
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">
|
||||
…or Enter Contact Data
|
||||
</h3>
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">…or Enter Contact Data</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public Key (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public Key (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ContactScanView extends Vue {}
|
||||
|
||||
@@ -1,48 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="users" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Contacts"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
@@ -109,18 +66,27 @@
|
||||
: "Unconfirmed"
|
||||
}}
|
||||
</button>
|
||||
<br />
|
||||
(Only hours shown)
|
||||
<br />
|
||||
(Only recent shown)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="">
|
||||
<ul v-if="contacts.length > 0" class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
class="border-b border-slate-300 pt-2.5 pb-4"
|
||||
v-for="contact in contacts"
|
||||
:key="contact.did"
|
||||
>
|
||||
<div class="grow overflow-hidden">
|
||||
<h2 class="text-base font-semibold">
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="24"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||
></EntityIcon>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</h2>
|
||||
<div class="text-sm truncate">{{ contact.did }}</div>
|
||||
@@ -128,70 +94,85 @@
|
||||
Public Key (base 64): {{ contact.publicKeyBase64 }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="contact.seesMe"
|
||||
class="tooltip"
|
||||
@click="setVisibility(contact, false)"
|
||||
>
|
||||
<fa icon="eye" class="text-slate-900 fa-fw ml-1" />
|
||||
<span class="tooltiptext">They can see you</span>
|
||||
</button>
|
||||
<button v-else class="tooltip" @click="setVisibility(contact, true)">
|
||||
<span class="tooltiptext">They cannot see you</span>
|
||||
<fa icon="eye-slash" class="text-slate-900 fa-fw ml-1" />
|
||||
</button>
|
||||
<div id="ContactActions" class="flex gap-1.5 mt-2">
|
||||
<button
|
||||
v-if="contact.seesMe"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, false)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="setVisibility(contact, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button class="tooltip" @click="checkVisibility(contact)">
|
||||
<span class="tooltiptext">Check Visibility</span>
|
||||
<fa icon="rotate" class="text-slate-900 fa-fw ml-1" />
|
||||
</button>
|
||||
<button
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button v-if="contact.registered" class="tooltip">
|
||||
<span class="tooltiptext">Registered</span>
|
||||
<fa icon="person-circle-check" class="text-slate-900 fa-fw ml-1" />
|
||||
</button>
|
||||
<button v-else @click="register(contact)" class="tooltip">
|
||||
<span class="tooltiptext">Registration Unknown</span>
|
||||
<fa
|
||||
icon="person-circle-question"
|
||||
class="text-slate-900 fa-fw ml-1"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="contact.registered"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
title="Registered"
|
||||
>
|
||||
<fa icon="person-circle-check" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="register(contact)"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
title="Registration unknown"
|
||||
>
|
||||
<fa icon="person-circle-question" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button @click="deleteContact(contact)" class="px-9 tooltip">
|
||||
<span class="tooltiptext">Delete!</span>
|
||||
<fa icon="trash-can" class="text-red-600 fa-fw ml-1" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteContact(contact)"
|
||||
class="text-sm uppercase bg-red-600 text-white px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
>
|
||||
<fa icon="trash-can" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<div v-if="showGiveNumbers" class="float-right">
|
||||
<div class="float-right">
|
||||
<div class="tooltip">
|
||||
to:
|
||||
<div
|
||||
v-if="showGiveNumbers && contact.did != activeDid"
|
||||
class="ml-auto flex gap-1.5"
|
||||
>
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-l-md"
|
||||
@click="onClickAddGive(activeDid, contact.did)"
|
||||
title="givenByMeDescriptions[contact.did]"
|
||||
>
|
||||
To:
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
this.showGiveTotals
|
||||
? ((givenByMeConfirmed[contact.did] || 0)
|
||||
+ (givenByMeUnconfirmed[contact.did] || 0))
|
||||
+ (givenByMeUnconfirmed[contact.did] || 0))
|
||||
: this.showGiveConfirmed
|
||||
? (givenByMeConfirmed[contact.did] || 0)
|
||||
: (givenByMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
<span
|
||||
v-if="givenByMeDescriptions[contact.did]"
|
||||
class="tooltiptext-left"
|
||||
>
|
||||
{{ givenByMeDescriptions[contact.did] }}
|
||||
</span>
|
||||
<button
|
||||
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
|
||||
@click="onClickAddGive(activeDid, contact.did)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip px-2">
|
||||
from:
|
||||
<fa icon="plus" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded-r-md -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickAddGive(contact.did, activeDid)"
|
||||
title="givenToMeDescriptions[contact.did]"
|
||||
>
|
||||
From:
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
this.showGiveTotals
|
||||
@@ -202,34 +183,25 @@
|
||||
: (givenToMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
<span
|
||||
v-if="givenToMeDescriptions[contact.did]"
|
||||
class="tooltiptext-left"
|
||||
>
|
||||
{{ givenToMeDescriptions[contact.did] }}
|
||||
</span>
|
||||
<button
|
||||
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
|
||||
@click="onClickAddGive(contact.did, activeDid)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<fa icon="plus" />
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'contact-amounts',
|
||||
query: { contactDid: contact.did },
|
||||
}"
|
||||
class="tooltip"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
title="See all given activity"
|
||||
>
|
||||
<fa icon="file-lines" class="text-slate-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext-left">See All Given Activity</span>
|
||||
<fa icon="file-lines" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>This identity has no contacts.</p>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
@@ -242,24 +214,25 @@ import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import {
|
||||
GiveServerRecord,
|
||||
GiveVerifiableCredential,
|
||||
RegisterVerifiableCredential,
|
||||
SERVICE_ID,
|
||||
} from "@/libs/endorserServer";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage },
|
||||
components: { AlertMessage, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ContactsView extends Vue {
|
||||
activeDid = "";
|
||||
@@ -283,8 +256,9 @@ export default class ContactsView extends Vue {
|
||||
showGiveNumbers = false;
|
||||
showGiveTotals = true;
|
||||
showGiveConfirmed = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
@@ -298,126 +272,140 @@ export default class ContactsView extends Vue {
|
||||
const allContacts = await db.contacts.toArray();
|
||||
this.contacts = R.sort(
|
||||
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
|
||||
allContacts
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
async loadGives() {
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
console.error(
|
||||
"Attempted to load Give records with no identity available."
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async getHeadersAndIdentity(activeDid) {
|
||||
const identity = await this.getIdentity(activeDid);
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
return { headers, identity };
|
||||
}
|
||||
|
||||
async loadGives() {
|
||||
const handleResponse = (
|
||||
resp,
|
||||
descriptions,
|
||||
confirmed,
|
||||
unconfirmed,
|
||||
useRecipient,
|
||||
) => {
|
||||
if (resp.status === 200) {
|
||||
const allData = resp.data.data;
|
||||
for (const give of allData) {
|
||||
const otherDid = useRecipient ? give.recipientDid : give.agentDid;
|
||||
if (give.unit === "HUR") {
|
||||
if (give.amountConfirmed) {
|
||||
const prevAmount = confirmed[otherDid] || 0;
|
||||
confirmed[otherDid] = prevAmount + give.amount;
|
||||
} else {
|
||||
const prevAmount = unconfirmed[otherDid] || 0;
|
||||
unconfirmed[otherDid] = prevAmount + give.amount;
|
||||
}
|
||||
if (!descriptions[otherDid] && give.description) {
|
||||
descriptions[otherDid] = give.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp.status,
|
||||
resp.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text:
|
||||
"Got an error retrieving your " +
|
||||
resp.config.url.includes("recipientDid")
|
||||
? "received"
|
||||
: "given" + " time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// load all the time I have given
|
||||
try {
|
||||
const url =
|
||||
const { headers } = await this.getHeadersAndIdentity(this.activeDid);
|
||||
const givenByUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(identity.did);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
//console.log("All gifts you've given:", resp.data);
|
||||
if (resp.status === 200) {
|
||||
const contactDescriptions: Record<string, string> = {};
|
||||
const contactConfirmed: Record<string, number> = {};
|
||||
const contactUnconfirmed: Record<string, number> = {};
|
||||
const allData: Array<GiveServerRecord> = resp.data.data;
|
||||
for (const give of allData) {
|
||||
if (give.unit == "HUR") {
|
||||
const recipDid: string = give.recipientDid;
|
||||
if (give.amountConfirmed) {
|
||||
const prevAmount = contactConfirmed[recipDid] || 0;
|
||||
contactConfirmed[recipDid] = prevAmount + give.amount;
|
||||
} else {
|
||||
const prevAmount = contactUnconfirmed[recipDid] || 0;
|
||||
contactUnconfirmed[recipDid] = prevAmount + give.amount;
|
||||
}
|
||||
if (!contactDescriptions[recipDid] && give.description) {
|
||||
// Since many make the tooltip too big, we'll just use the latest.
|
||||
contactDescriptions[recipDid] = give.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
//console.log("Done retrieving gives", contactConfirmed);
|
||||
this.givenByMeDescriptions = contactDescriptions;
|
||||
this.givenByMeConfirmed = contactConfirmed;
|
||||
this.givenByMeUnconfirmed = contactUnconfirmed;
|
||||
} else {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp.status,
|
||||
resp.data
|
||||
);
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage =
|
||||
"Got an error retrieving your given time from the server.";
|
||||
}
|
||||
} catch (error) {
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = error as string;
|
||||
}
|
||||
|
||||
// load all the time I have received
|
||||
try {
|
||||
const url =
|
||||
encodeURIComponent(this.activeDid);
|
||||
const givenToUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?recipientDid=" +
|
||||
encodeURIComponent(identity.did);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
//console.log("All gifts you've recieved:", resp.data);
|
||||
if (resp.status === 200) {
|
||||
const contactDescriptions: Record<string, string> = {};
|
||||
const contactConfirmed: Record<string, number> = {};
|
||||
const contactUnconfirmed: Record<string, number> = {};
|
||||
const allData: Array<GiveServerRecord> = resp.data.data;
|
||||
for (const give of allData) {
|
||||
if (give.unit == "HUR") {
|
||||
if (give.amountConfirmed) {
|
||||
const prevAmount = contactConfirmed[give.agentDid] || 0;
|
||||
contactConfirmed[give.agentDid] = prevAmount + give.amount;
|
||||
} else {
|
||||
const prevAmount = contactUnconfirmed[give.agentDid] || 0;
|
||||
contactUnconfirmed[give.agentDid] = prevAmount + give.amount;
|
||||
}
|
||||
if (!contactDescriptions[give.agentDid] && give.description) {
|
||||
// Since many make the tooltip too big, we'll just use the latest.
|
||||
contactDescriptions[give.agentDid] = give.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
//console.log("Done retrieving receipts", contactConfirmed);
|
||||
this.givenToMeDescriptions = contactDescriptions;
|
||||
this.givenToMeConfirmed = contactConfirmed;
|
||||
this.givenToMeUnconfirmed = contactUnconfirmed;
|
||||
} else {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp.status,
|
||||
resp.data
|
||||
);
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage =
|
||||
"Got an error retrieving your received time from the server.";
|
||||
}
|
||||
encodeURIComponent(this.activeDid);
|
||||
|
||||
const [givenByMeResp, givenToMeResp] = await Promise.all([
|
||||
this.axios.get(givenByUrl, { headers }),
|
||||
this.axios.get(givenToUrl, { headers }),
|
||||
]);
|
||||
|
||||
const givenByMeDescriptions = {};
|
||||
const givenByMeConfirmed = {};
|
||||
const givenByMeUnconfirmed = {};
|
||||
handleResponse(
|
||||
givenByMeResp,
|
||||
givenByMeDescriptions,
|
||||
givenByMeConfirmed,
|
||||
givenByMeUnconfirmed,
|
||||
true,
|
||||
);
|
||||
this.givenByMeDescriptions = givenByMeDescriptions;
|
||||
this.givenByMeConfirmed = givenByMeConfirmed;
|
||||
this.givenByMeUnconfirmed = givenByMeUnconfirmed;
|
||||
|
||||
const givenToMeDescriptions = {};
|
||||
const givenToMeConfirmed = {};
|
||||
const givenToMeUnconfirmed = {};
|
||||
handleResponse(
|
||||
givenToMeResp,
|
||||
givenToMeDescriptions,
|
||||
givenToMeConfirmed,
|
||||
givenToMeUnconfirmed,
|
||||
false,
|
||||
);
|
||||
this.givenToMeDescriptions = givenToMeDescriptions;
|
||||
this.givenToMeConfirmed = givenToMeConfirmed;
|
||||
this.givenToMeUnconfirmed = givenToMeUnconfirmed;
|
||||
} catch (error) {
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = error as string;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +432,7 @@ export default class ContactsView extends Vue {
|
||||
const allContacts = this.contacts.concat([newContact]);
|
||||
this.contacts = R.sort(
|
||||
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
|
||||
allContacts
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,7 +443,7 @@ export default class ContactsView extends Vue {
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
" with DID " +
|
||||
contact.did +
|
||||
" ?"
|
||||
" ?",
|
||||
)
|
||||
) {
|
||||
await db.open();
|
||||
@@ -469,18 +457,11 @@ export default class ContactsView extends Vue {
|
||||
confirm(
|
||||
"Are you sure you want to use one of your registrations for " +
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
"?"
|
||||
"?",
|
||||
)
|
||||
) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
// Make a claim
|
||||
const vcClaim: RegisterVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "RegisterAction",
|
||||
@@ -512,28 +493,37 @@ export default class ContactsView extends Vue {
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
//console.log("Got resp data:", resp.data);
|
||||
if (resp.data?.success?.embeddedRecordError) {
|
||||
this.alertTitle = "Registration Still Unknown";
|
||||
let message = "There was some problem with the registration.";
|
||||
if (typeof resp.data.success.embeddedRecordError == "string") {
|
||||
message += " " + resp.data.success.embeddedRecordError;
|
||||
}
|
||||
this.alertMessage = message;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Still Unknown",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else if (resp.data?.success?.handleId) {
|
||||
contact.registered = true;
|
||||
db.contacts.update(contact.did, { registered: true });
|
||||
|
||||
this.alertTitle = "Registration Success";
|
||||
this.alertMessage = contact.name + " has been registered.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Registration Success",
|
||||
text: contact.name + " has been registered.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
@@ -548,8 +538,15 @@ export default class ContactsView extends Vue {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -560,19 +557,8 @@ export default class ContactsView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/report/" +
|
||||
(visibility ? "canSeeMe" : "cannotSeeMe");
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const headers = await this.getHeaders(identity);
|
||||
const payload = JSON.stringify({ did: contact.did });
|
||||
|
||||
try {
|
||||
@@ -581,17 +567,39 @@ export default class ContactsView extends Vue {
|
||||
contact.seesMe = visibility;
|
||||
db.contacts.update(contact.did, { seesMe: visibility });
|
||||
} else {
|
||||
this.alertTitle = "Error With Server";
|
||||
console.error("Bad response setting visibility: ", resp.data);
|
||||
if (resp.data.error?.message) {
|
||||
this.alertMessage = resp.data.error?.message;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: resp.data.error?.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.alertMessage = "Bad server response of " + resp.status;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Bad server response of " + resp.status,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = err as string;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: err as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,19 +608,6 @@ export default class ContactsView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/report/canDidExplicitlySeeMe?did=" +
|
||||
encodeURIComponent(contact.did);
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
@@ -621,23 +616,52 @@ export default class ContactsView extends Vue {
|
||||
contact.seesMe = visibility;
|
||||
db.contacts.update(contact.did, { seesMe: visibility });
|
||||
|
||||
this.alertTitle = "Refreshed";
|
||||
this.alertMessage =
|
||||
this.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Refreshed",
|
||||
text:
|
||||
this.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
this.alertTitle = "Error With Server";
|
||||
if (resp.data.error?.message) {
|
||||
this.alertMessage = resp.data.error?.message;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: resp.data.error?.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.alertMessage = "Bad server response of " + resp.status;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Bad server response of " + resp.status,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = err as string;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: err as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,13 +681,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
async onClickAddGive(fromDid: string, toDid: string): Promise<void> {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
// if they have unconfirmed amounts, ask to confirm those first
|
||||
if (toDid == identity?.did && this.givenToMeUnconfirmed[fromDid] > 0) {
|
||||
@@ -672,7 +690,7 @@ export default class ContactsView extends Vue {
|
||||
"There are " +
|
||||
this.givenToMeUnconfirmed[fromDid] +
|
||||
" unconfirmed hours from them." +
|
||||
" Would you like to confirm some of those hours?"
|
||||
" Would you like to confirm some of those hours?",
|
||||
)
|
||||
) {
|
||||
this.$router.push({
|
||||
@@ -682,15 +700,35 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
if (!this.isNumeric(this.hourInput)) {
|
||||
this.alertTitle = "Input Error";
|
||||
this.alertMessage =
|
||||
"This is not a valid number of hours: " + this.hourInput;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Input Error",
|
||||
text: "This is not a valid number of hours: " + this.hourInput,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else if (!parseFloat(this.hourInput)) {
|
||||
this.alertTitle = "Input Error";
|
||||
this.alertMessage = "Giving 0 hours does nothing.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Input Error",
|
||||
text: "Giving 0 hours does nothing.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else if (!identity) {
|
||||
this.alertTitle = "Status Error";
|
||||
this.alertMessage = "No identity is available.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Status Error",
|
||||
text: "No identity is available.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
// ask to confirm amount
|
||||
let toFrom;
|
||||
@@ -712,7 +750,7 @@ export default class ContactsView extends Vue {
|
||||
" hours " +
|
||||
toFrom +
|
||||
description +
|
||||
"?"
|
||||
"?",
|
||||
)
|
||||
) {
|
||||
this.createAndSubmitGive(
|
||||
@@ -720,7 +758,7 @@ export default class ContactsView extends Vue {
|
||||
fromDid,
|
||||
toDid,
|
||||
parseFloat(this.hourInput),
|
||||
this.hourDescriptionInput
|
||||
this.hourDescriptionInput,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -731,7 +769,7 @@ export default class ContactsView extends Vue {
|
||||
fromDid: string,
|
||||
toDid: string,
|
||||
amount: number,
|
||||
description: string
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
// Make a claim
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
@@ -769,18 +807,20 @@ export default class ContactsView extends Vue {
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
//console.log("Got resp data:", resp.data);
|
||||
if (resp.data?.success?.handleId) {
|
||||
this.alertTitle = "Done";
|
||||
this.alertMessage = "Successfully logged time to the server.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Done",
|
||||
text: "Successfully logged time to the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
|
||||
if (fromDid === identity.did) {
|
||||
const newList = R.clone(this.givenByMeUnconfirmed);
|
||||
@@ -805,8 +845,15 @@ export default class ContactsView extends Vue {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.alertTitle = "Error With Server";
|
||||
this.alertMessage = userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,10 +871,6 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// This same popup code is in many files.
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
|
||||
public showGiveAmountsClassNames() {
|
||||
return {
|
||||
"bg-slate-500": this.showGiveTotals,
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="users" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<QuickNav selected="Discover"></QuickNav>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
@@ -72,87 +30,79 @@
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="inline-block py-3 rounded-t-lg border-b-2 active text-blue-600 border-blue-600 font-semibold"
|
||||
@click="
|
||||
projects = [];
|
||||
searchLocal();
|
||||
"
|
||||
v-bind:class="computedLocalTabClassNames()"
|
||||
>
|
||||
Nearby
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>20+</span
|
||||
>{{ localCount }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="inline-block py-3 rounded-t-lg border-b-2 border-transparent hover:text-slate-600 hover:border-slate-300"
|
||||
v-bind:class="computedRemoteTabClassNames()"
|
||||
@click="
|
||||
projects = [];
|
||||
search();
|
||||
"
|
||||
>
|
||||
Remote
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>13</span
|
||||
>{{ remoteCount }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div
|
||||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isLoading"
|
||||
>
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="">
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=1"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Canyon cleanup</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa> Rotary
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
:key="project.handleId"
|
||||
>
|
||||
<a
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
class="block py-4 flex gap-4"
|
||||
>
|
||||
<div class="w-12">
|
||||
<EntityIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="block border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=2"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Potluck with neighbors</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa> Andrew A.
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">{{ project.name }}</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{
|
||||
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=3"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Historical site</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400 mr-1"></fa>
|
||||
<em>Unknown</em>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
@@ -164,74 +114,267 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import * as R from "ramda";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { didInfo } from "@/libs/endorserServer";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage },
|
||||
components: { AlertMessage, QuickNav, InfiniteScroll, EntityIcon },
|
||||
})
|
||||
export default class DiscoverView extends Vue {
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
searchTerms = "";
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
projects: ProjectData[] = [];
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
localCount = 0;
|
||||
remoteCount = 0;
|
||||
isLoading = false;
|
||||
|
||||
// make this function available to the Vue template
|
||||
didInfo = didInfo;
|
||||
|
||||
// 'mounted' hook runs after initial render
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
this.searchLocal();
|
||||
}
|
||||
|
||||
public async search() {
|
||||
public async buildHeaders() {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, allAccounts);
|
||||
//console.log("about to parse from", this.activeDid, account?.identity);
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
const claimContents =
|
||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
const claimType = "claimType=PlanAction";
|
||||
const queryParams = [claimContents, claimType].join("&");
|
||||
return fetch(this.apiServer + "/api/v2/report/claims?" + queryParams, {
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
throw details;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((results) => {
|
||||
if (results.data) {
|
||||
console.log(results.data);
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Error with feed load:", e);
|
||||
this.alertMessage =
|
||||
e.userMessage || "There was an error retrieving projects.";
|
||||
this.alertTitle = "Error";
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
public async search(beforeId?: string) {
|
||||
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
if (beforeId) {
|
||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||
}
|
||||
|
||||
this.isRemoteActive = true;
|
||||
this.isLocalActive = false;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/plans?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
|
||||
throw details;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
const plans: ProjectData[] = results.data;
|
||||
if (plans) {
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, rowid, issuerDid } = plan;
|
||||
this.projects.push({ name, description, handleId, rowid, issuerDid });
|
||||
}
|
||||
this.remoteCount = this.projects.length;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: e.userMessage || "There was a problem retrieving projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async searchLocal(beforeId?: string) {
|
||||
const claimContents =
|
||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
let queryParams = [
|
||||
claimContents,
|
||||
"minLocLat=40.901000",
|
||||
"maxLocLat=40.904000",
|
||||
"westLocLon=-111.914000",
|
||||
"eastLocLon=-111.909000",
|
||||
].join("&");
|
||||
|
||||
if (beforeId) {
|
||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.isLocalActive = true;
|
||||
this.isRemoteActive = false;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
throw await response.text();
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.data) {
|
||||
if (beforeId) {
|
||||
const plans: ProjectData[] = results.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId = plan.handleId, rowid } = plan;
|
||||
if (beforeId !== plan["rowid"]) {
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.projects = results.data;
|
||||
}
|
||||
this.localCount = this.projects.length;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: e.userMessage || "There was a problem retrieving projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
async loadMoreData(payload: boolean) {
|
||||
if (this.projects.length > 0 && payload) {
|
||||
const latestProject = this.projects[this.projects.length - 1];
|
||||
if (this.isLocalActive) {
|
||||
this.searchLocal(latestProject["rowid"]);
|
||||
} else if (this.isRemoteActive) {
|
||||
this.search(latestProject["rowid"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
**/
|
||||
onClickLoadProject(id: string) {
|
||||
localStorage.setItem("projectId", id);
|
||||
const route = {
|
||||
name: "project",
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
public computedLocalTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
"rounded-t-lg": true,
|
||||
"border-b-2": true,
|
||||
active: this.isLocalActive,
|
||||
"text-blue-600": this.isLocalActive,
|
||||
"border-blue-600": this.isLocalActive,
|
||||
"font-semibold": this.isLocalActive,
|
||||
"border-transparent": !this.isLocalActive,
|
||||
"hover:text-slate-600": !this.isLocalActive,
|
||||
"hover:border-slate-300": !this.isLocalActive,
|
||||
};
|
||||
}
|
||||
|
||||
public computedRemoteTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
"rounded-t-lg": true,
|
||||
"border-b-2": true,
|
||||
active: this.isRemoteActive,
|
||||
"text-blue-600": this.isRemoteActive,
|
||||
"border-blue-600": this.isRemoteActive,
|
||||
"font-semibold": this.isRemoteActive,
|
||||
"border-transparent": !this.isRemoteActive,
|
||||
"hover:text-slate-600": !this.isRemoteActive,
|
||||
"hover:border-slate-300": !this.isRemoteActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-400">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
@@ -175,6 +128,14 @@
|
||||
key.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
||||
<p>
|
||||
Go
|
||||
<router-link to="start" class="text-blue-500">
|
||||
create another identity here.
|
||||
</router-link>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
I know there is a record from someone, so why can't I see that info?
|
||||
</h2>
|
||||
@@ -193,14 +154,6 @@
|
||||
page.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
||||
<p>
|
||||
Go
|
||||
<router-link to="start" class="text-blue-500">
|
||||
create another identity here.
|
||||
</router-link>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
|
||||
<p>
|
||||
See
|
||||
@@ -228,8 +181,9 @@
|
||||
<script lang="ts">
|
||||
import * as Package from "../../package.json";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
@Component
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {
|
||||
package = Package;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="users" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Home"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
@@ -50,22 +7,158 @@
|
||||
</h1>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl">Quick Action</h1>
|
||||
<p>Choose a contact to whom to show appreciation:</p>
|
||||
<!-- similar contact selection code is in multiple places -->
|
||||
<div class="px-4">
|
||||
<button
|
||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
text: 'I\'m a toast. Don\'t mind me.',
|
||||
},
|
||||
5000,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Toast (self-dismiss)
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Information Alert',
|
||||
text: 'Just wanted you to know.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Success Alert',
|
||||
text: 'Congratulations!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'warning',
|
||||
title: 'Warning Alert',
|
||||
text: 'You might wanna look at this.',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Danger Alert',
|
||||
text: 'Something terrible has happened!',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Danger
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notification Permission
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold">Quick Action</h2>
|
||||
<p class="mb-4">Record a gift from a contact:</p>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<EntityIcon
|
||||
:entityId="Anonymous"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Anonymous
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
{{ contact.name }}
|
||||
</button>
|
||||
<span v-if="allContacts.length > 0"> or </span>
|
||||
<button @click="openDialog()" class="text-blue-500">
|
||||
someone not specified
|
||||
</button>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length > 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
|
||||
<!-- If there are no contacts, show this instead: -->
|
||||
<div
|
||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||
v-if="allContacts.length === 0"
|
||||
>
|
||||
(No contacts to show.)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,62 +169,59 @@
|
||||
>
|
||||
</GiftedDialog>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl">Latest Activity</h1>
|
||||
<span :class="{ hidden: isHiddenSpinner }">
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
Loading…
|
||||
</span>
|
||||
<ul>
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
||||
<div :class="{ hidden: isHiddenSpinner }">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
||||
</p>
|
||||
</div>
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300 py-2"
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 py-2 mb-2 font-bold uppercase text-sm"
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||
v-if="record.jwtId == feedLastViewedId"
|
||||
>
|
||||
You've seen all claims below:
|
||||
</div>
|
||||
<div class="flex">
|
||||
<fa
|
||||
icon="gift"
|
||||
class="fa-fw flex-none pt-1 pr-2 text-slate-500"
|
||||
></fa>
|
||||
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
|
||||
<!-- icon values: "coins" = money; "clock" = time; "gift" = others -->
|
||||
<span class="">{{ this.giveDescription(record) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as R from "ramda";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
@Options({
|
||||
components: { GiftedDialog, AlertMessage },
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
activeDid = "";
|
||||
allAccounts: Array<Account> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
feedAllLoaded = false;
|
||||
feedData = [];
|
||||
@@ -140,12 +230,44 @@ export default class HomeView extends Vue {
|
||||
isHiddenSpinner = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
try {
|
||||
await accountsDB.open();
|
||||
this.allAccounts = await accountsDB.accounts.toArray();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
@@ -154,21 +276,48 @@ export default class HomeView extends Vue {
|
||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
||||
this.updateAllFeed();
|
||||
} catch (err) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
err.userMessage ||
|
||||
"There was an error retrieving the latest sweet, sweet action.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateAllFeed = async () => {
|
||||
this.isHiddenSpinner = false;
|
||||
public async buildHeaders() {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async updateAllFeed() {
|
||||
this.isHiddenSpinner = false;
|
||||
await this.retrieveClaims(this.apiServer, null, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
this.feedData = this.feedData.concat(results.data);
|
||||
//console.log("Feed data:", this.feedData);
|
||||
this.feedAllLoaded = results.hitLimit;
|
||||
this.feedPreviousOldestId =
|
||||
results.data[results.data.length - 1].jwtId;
|
||||
@@ -176,7 +325,6 @@ export default class HomeView extends Vue {
|
||||
this.feedLastViewedId == null ||
|
||||
this.feedLastViewedId < results.data[0].jwtId
|
||||
) {
|
||||
// save it to storage
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: results.data[0].jwtId,
|
||||
@@ -187,70 +335,69 @@ export default class HomeView extends Vue {
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Error with feed load:", e);
|
||||
this.alertMessage =
|
||||
e.userMessage || "There was an error retrieving feed data.";
|
||||
this.alertTitle = "Error";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: e.userMessage || "There was an error retrieving feed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
this.isHiddenSpinner = true;
|
||||
};
|
||||
}
|
||||
|
||||
retrieveClaims = async (endorserApiServer, identifier, beforeId) => {
|
||||
//const afterQuery = afterId == null ? "" : "&afterId=" + afterId;
|
||||
public async retrieveClaims(endorserApiServer, identifier, beforeId) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (this.activeDid) {
|
||||
const account = R.find(
|
||||
(acc) => acc.did === this.activeDid,
|
||||
this.allAccounts
|
||||
);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
const response = await fetch(
|
||||
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw await response.text();
|
||||
}
|
||||
return fetch(this.apiServer + "/api/v2/report/gives?" + beforeQuery, {
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
throw details;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((results) => {
|
||||
if (results.data) {
|
||||
return results;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.data) {
|
||||
return results;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
}
|
||||
|
||||
giveDescription(giveRecord) {
|
||||
let claim = giveRecord.fullClaim;
|
||||
if (claim.claim) {
|
||||
// it's probably a Verified Credential
|
||||
claim = claim.claim;
|
||||
}
|
||||
|
||||
// agent.did is for legacy data, before March 2023
|
||||
const giver =
|
||||
claim.agent?.identifier || claim.agent?.did || giveRecord.issuer;
|
||||
const giverInfo = didInfo(giver, this.allAccounts, this.allContacts);
|
||||
const giverDid = claim.agent?.identifier || claim.agent?.did;
|
||||
const giverInfo = didInfo(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
const gaveAmount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: claim.description || "something unknown";
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
|
||||
const gaveRecipientInfo = gaveRecipientId
|
||||
? " to " + didInfo(gaveRecipientId, this.allAccounts, this.allContacts)
|
||||
? " to " +
|
||||
didInfo(
|
||||
gaveRecipientId,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
)
|
||||
: "";
|
||||
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
|
||||
}
|
||||
@@ -266,10 +413,11 @@ export default class HomeView extends Vue {
|
||||
openDialog(giver) {
|
||||
this.$refs.customDialog.open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
||||
this.recordGive(result.giver?.did, result.description, result.hours);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
@@ -283,59 +431,101 @@ export default class HomeView extends Vue {
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
recordGive(giverDid, description, hours) {
|
||||
if (this.activeDid == null) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
"You must select an identity before you can record a give.";
|
||||
public async recordGive(giverDid, description, hours) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !hours) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
"You must enter a description or some number of hours.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const account = R.find(
|
||||
(acc) => acc.did === this.activeDid,
|
||||
this.allAccounts
|
||||
);
|
||||
//console.log("about to parse from", this.activeDid, account?.identity);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
);
|
||||
|
||||
if (this.isGiveCreationError(result)) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error with give caught:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
this.getGiveErrorMessage(error) ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.status != 201 || result.data?.error) {
|
||||
console.log("Error with give result:", result);
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
result.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
} else {
|
||||
this.alertTitle = "Success";
|
||||
this.alertMessage = "That gift was recorded.";
|
||||
//this.updateAllFeed(); // full update is overkill but we should show something
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// axios throws errors on 400 responses
|
||||
console.log("Error with give caught:", e);
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
e.userMessage ||
|
||||
e.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
});
|
||||
}
|
||||
|
||||
private setAlert(title, message) {
|
||||
this.alertTitle = title;
|
||||
this.alertMessage = message;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
isGiveCreationError(result) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
getGiveCreationErrorMessage(result) {
|
||||
return result.data?.error?.message;
|
||||
}
|
||||
|
||||
getGiveErrorMessage(error) {
|
||||
return error.userMessage || error.response?.data?.error?.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
175
src/views/IdentitySwitcherView.vue
Normal file
175
src/views/IdentitySwitcherView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></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">
|
||||
<!-- Cancel -->
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
|
||||
Switch Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Identity List -->
|
||||
|
||||
<!-- Current Identity - Display First! -->
|
||||
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4">
|
||||
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
|
||||
<span class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold mb-0">
|
||||
{{ firstName }} {{ lastName }}
|
||||
</h2>
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ activeDid }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other Identity/ies -->
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="ident in otherIdentities"
|
||||
:key="ident.did"
|
||||
@click="switchAccount(ident.did)"
|
||||
>
|
||||
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa>
|
||||
<span class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold mb-0"></h2>
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ ident.did }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Actions -->
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Add Another Identity…
|
||||
</router-link>
|
||||
<a
|
||||
href="#"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
|
||||
@click="switchAccount('0')"
|
||||
>
|
||||
No Identity
|
||||
</a>
|
||||
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
export default class IdentitySwitcherView extends Vue {
|
||||
Constants = AppString;
|
||||
public accounts: AccountsSchema;
|
||||
public activeDid;
|
||||
public firstName;
|
||||
public lastName;
|
||||
public alertTitle;
|
||||
public alertMessage;
|
||||
public otherIdentities = [];
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.firstName = settings?.firstName || "No";
|
||||
this.lastName = settings?.lastName || "Name";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
if (identity) {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
}
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
for (let n = 0; n < accounts.length; n++) {
|
||||
const did = JSON.parse(accounts[n].identity)["did"];
|
||||
if (did && this.activeDid !== did) {
|
||||
this.otherIdentities.push({ did: did });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err.message ===
|
||||
"Attempted to load account records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Creating Account",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async switchAccount(did: string) {
|
||||
// 0 means none
|
||||
if (did === "0") {
|
||||
did = undefined;
|
||||
}
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: did,
|
||||
});
|
||||
this.activeDid = did;
|
||||
this.otherIdentities = [];
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
for (let n = 0; n < accounts.length; n++) {
|
||||
const did = JSON.parse(accounts[n].identity)["did"];
|
||||
if (did && this.activeDid !== did) {
|
||||
this.otherIdentities.push({ did: did });
|
||||
}
|
||||
}
|
||||
this.$router.push({ name: "account" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -24,6 +24,24 @@
|
||||
v-model="mnemonic"
|
||||
/>
|
||||
{{ mnemonic }}
|
||||
<h3
|
||||
class="text-sm uppercase font-semibold mb-3"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
Advanced
|
||||
</h3>
|
||||
<div v-if="showAdvanced">
|
||||
Enter a custom derivation path
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="derivationPath"
|
||||
/>
|
||||
For previous uPort or Endorser users,
|
||||
<a @click="derivationPath = UPORT_DERIVATION_PATH" class="text-blue-500">
|
||||
click here to use that value.
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="from_mnemonic()"
|
||||
@@ -43,20 +61,27 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
||||
|
||||
mnemonic = "";
|
||||
address = "";
|
||||
privateHex = "";
|
||||
publicHex = "";
|
||||
derivationPath = "";
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
showAdvanced = false;
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
@@ -65,14 +90,16 @@ export default class ImportAccountView extends Vue {
|
||||
public async from_mnemonic() {
|
||||
const mne: string = this.mnemonic.trim().toLowerCase();
|
||||
if (this.mnemonic.trim().length > 0) {
|
||||
[this.address, this.privateHex, this.publicHex, this.derivationPath] =
|
||||
deriveAddress(mne);
|
||||
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
||||
mne,
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(
|
||||
this.address,
|
||||
this.publicHex,
|
||||
this.privateHex,
|
||||
this.derivationPath
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
163
src/views/ImportDerivedAccountView.vue
Normal file
163
src/views/ImportDerivedAccountView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left"></fa>
|
||||
</button>
|
||||
Derive from Existing Identity
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Import Account Form -->
|
||||
|
||||
<div>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Will increment the maximum derivation path from the existing seed.
|
||||
</p>
|
||||
|
||||
<p v-if="didArrays.length > 1">
|
||||
Choose existing DIDs from same seed phrase to compute derivation.
|
||||
</p>
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="dids in didArrays"
|
||||
:key="dids[0]"
|
||||
@click="switchAccount(dids[0])"
|
||||
>
|
||||
<fa
|
||||
v-if="dids[0] == selectedArrayFirstDid"
|
||||
icon="circle"
|
||||
class="fa-fw text-blue-400 text-xl mr-3"
|
||||
></fa>
|
||||
<fa
|
||||
v-else
|
||||
icon="circle"
|
||||
class="fa-fw text-slate-400 text-xl mr-3"
|
||||
></fa>
|
||||
<span class="overflow-hidden">
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<code>{{ dids.join(",") }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="incrementDerivation()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Increment and Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
didArrays: Array<Array<string>> = [];
|
||||
selectedArrayFirstDid = "";
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const seedDids = {};
|
||||
accounts.forEach((account) => {
|
||||
const prevDids = seedDids[account.mnemonic] || [];
|
||||
seedDids[account.mnemonic] = prevDids.concat([account.did]);
|
||||
});
|
||||
this.didArrays = Object.values(seedDids);
|
||||
this.selectedArrayFirstDid = this.didArrays[0][0];
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
public switchAccount(did: string) {
|
||||
this.selectedArrayFirstDid = did;
|
||||
}
|
||||
|
||||
public async incrementDerivation() {
|
||||
await accountsDB.open();
|
||||
// find the maximum derivation path for the selected DIDs
|
||||
const selectedArray: Array<string> = this.didArrays.find(
|
||||
(dids) => dids[0] === this.selectedArrayFirstDid,
|
||||
);
|
||||
const allMatchingAccounts = await accountsDB.accounts
|
||||
.where("did")
|
||||
.anyOf(...selectedArray)
|
||||
.toArray();
|
||||
const accountWithMaxDeriv = allMatchingAccounts[0];
|
||||
allMatchingAccounts.slice(1).forEach((account) => {
|
||||
if (account.derivationPath > accountWithMaxDeriv.derivationPath) {
|
||||
accountWithMaxDeriv.derivationPath = account.derivationPath;
|
||||
}
|
||||
});
|
||||
// increment the last number in that max derivation path
|
||||
let lastStr = accountWithMaxDeriv.derivationPath.split("/").slice(-1)[0];
|
||||
if (lastStr.endsWith("'")) {
|
||||
lastStr = lastStr.slice(0, -1);
|
||||
}
|
||||
const lastNum = parseInt(lastStr, 10);
|
||||
const newLastNum = lastNum + 1;
|
||||
const newDerivPath = accountWithMaxDeriv.derivationPath
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.concat([newLastNum.toString() + "'"])
|
||||
.join("/");
|
||||
|
||||
const mne: string = accountWithMaxDeriv.mnemonic;
|
||||
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
|
||||
|
||||
try {
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: newDerivPath,
|
||||
did: newId.did,
|
||||
identity: JSON.stringify(newId),
|
||||
mnemonic: mne,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (err) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -13,47 +13,46 @@
|
||||
[New/Edit] Identity
|
||||
</h1>
|
||||
</div>
|
||||
<form>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="firstName"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="lastName"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
type="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="onClickSaveChanges()"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="firstName"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="lastName"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
type="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="onClickSaveChanges()"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class NewEditAccountView extends Vue {
|
||||
|
||||
@@ -16,54 +16,51 @@
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<form>
|
||||
<select
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
|
||||
<select class="block w-full rounded border border-slate-400 mb-4 px-3 py-2">
|
||||
<option disabled>Choose a commitment type…</option>
|
||||
<option selected>Time</option>
|
||||
<option>Cryptocurrency</option>
|
||||
<option>Money</option>
|
||||
</select>
|
||||
|
||||
<!-- Time amount -->
|
||||
<div class="mb-4 flex items-stretch">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0.0"
|
||||
class="block w-full rounded-l border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<span
|
||||
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>hours</span
|
||||
>
|
||||
<option disabled>Choose a commitment type…</option>
|
||||
<option selected>Time</option>
|
||||
<option>Cryptocurrency</option>
|
||||
<option>Money</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Time amount -->
|
||||
<div class="mb-4 flex items-stretch">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0.0"
|
||||
class="block w-full rounded-l border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<span
|
||||
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>hours</span
|
||||
>
|
||||
</div>
|
||||
<!-- Crypto amount -->
|
||||
|
||||
<!-- Crypto amount -->
|
||||
<!-- Money amount -->
|
||||
|
||||
<!-- Money amount -->
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Commit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Commit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class NewEditCommitmentView extends Vue {}
|
||||
|
||||
@@ -39,6 +39,40 @@
|
||||
{{ description.length }}/500 max. characters
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
v-model="includeLocation"
|
||||
@change="includeLocation = true"
|
||||
/>
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
||||
<l-map
|
||||
ref="map"
|
||||
v-model:zoom="zoom"
|
||||
:center="[0, 0]"
|
||||
@click="
|
||||
(event) => {
|
||||
latitude = event.latlng.lat;
|
||||
longitude = event.latlng.lng;
|
||||
}
|
||||
"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="latitude || longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="maybeEraseLatLong()"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
:disabled="isHiddenSave"
|
||||
@@ -71,10 +105,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
@@ -83,29 +118,57 @@ import { useAppStore } from "@/store/app";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
|
||||
interface VerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage },
|
||||
components: { AlertMessage, LMap, LMarker, LTileLayer },
|
||||
})
|
||||
export default class NewEditProjectView extends Vue {
|
||||
activeDid = "";
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
apiServer = "";
|
||||
projectName = "";
|
||||
description = "";
|
||||
errorMessage = "";
|
||||
includeLocation = false;
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
numAccounts = 0;
|
||||
projectName = "";
|
||||
zoom = 2;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
projectId = localStorage.getItem("projectId") || "";
|
||||
isHiddenSave = false;
|
||||
isHiddenSpinner = true;
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
@@ -113,16 +176,14 @@ export default class NewEditProjectView extends Vue {
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
if (this.projectId) {
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts === 0) {
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: no account was found.");
|
||||
} else {
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
this.LoadProject(identity);
|
||||
}
|
||||
@@ -164,6 +225,15 @@ export default class NewEditProjectView extends Vue {
|
||||
if (this.projectId) {
|
||||
vcClaim.identifier = this.projectId;
|
||||
}
|
||||
if (this.includeLocation) {
|
||||
vcClaim.location = {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: this.latitude,
|
||||
longitude: this.longitude,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
@@ -197,15 +267,15 @@ export default class NewEditProjectView extends Vue {
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://endorser.ch:3000/api-docs/
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
|
||||
this.errorMessage = "";
|
||||
this.alertTitle = "";
|
||||
this.alertMessage = "";
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://endorser.ch:3000/api-docs/
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
useAppStore().setProjectId(
|
||||
resp.data.success.handleId || resp.data.success.fullIri
|
||||
resp.data.success.handleId || resp.data.success.fullIri,
|
||||
);
|
||||
setTimeout(
|
||||
function (that: Vue) {
|
||||
@@ -215,7 +285,7 @@ export default class NewEditProjectView extends Vue {
|
||||
that.$router.push(route);
|
||||
},
|
||||
2000,
|
||||
this
|
||||
this,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -224,20 +294,41 @@ export default class NewEditProjectView extends Vue {
|
||||
if (serverError) {
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
console.log(serverError);
|
||||
this.alertTitle = "User Message";
|
||||
userMessage = serverError.response.data.error.message; // This is info for the user.
|
||||
this.alertMessage = userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "User Message",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.alertTitle = "Server Message";
|
||||
this.alertMessage = JSON.stringify(serverError.toJSON());
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Server Message",
|
||||
text: JSON.stringify(serverError.toJSON()),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Here's the full error trying to save the claim:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Claim Error",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.alertTitle = "Claim Error";
|
||||
this.alertMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.errorMessage = userMessage;
|
||||
@@ -248,27 +339,25 @@ export default class NewEditProjectView extends Vue {
|
||||
public async onSaveProjectClick() {
|
||||
this.isHiddenSave = true;
|
||||
this.isHiddenSpinner = false;
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts === 0) {
|
||||
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: there is no account.");
|
||||
} else {
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
this.SaveProject(identity);
|
||||
}
|
||||
}
|
||||
|
||||
public maybeEraseLatLong() {
|
||||
if (window.confirm("Are you sure you don't want to mark a location?")) {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.includeLocation = false;
|
||||
}
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
// This same popup code is in many files.
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
@@ -90,13 +43,13 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
@Component
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class AccountViewView extends Vue {
|
||||
loading = true;
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
const mnemonic = generateSeed();
|
||||
// address is 0x... ETH address, without "did:eth:"
|
||||
const [address, privateHex, publicHex, derivationPath] =
|
||||
@@ -104,6 +57,8 @@ export default class AccountViewView extends Vue {
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
||||
const identity = JSON.stringify(newId);
|
||||
|
||||
await accountsDB.open();
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: derivationPath,
|
||||
|
||||
@@ -1,48 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="hand" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
@@ -55,31 +12,45 @@
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
<!-- Context Menu -->
|
||||
<a
|
||||
href=""
|
||||
class="text-lg text-center px-2 py-1 absolute -right-2 -top-1"
|
||||
><fa icon="ellipsis-vertical" class="fa-fw"></fa
|
||||
></a>
|
||||
|
||||
View Plan
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
||||
<div class="flex justify-between gap-4 text-sm mb-3">
|
||||
<span><fa icon="user" class="fa-fw text-slate-400"></fa> Rotary</span>
|
||||
<span
|
||||
><fa icon="calendar" class="fa-fw text-slate-400"></fa
|
||||
>{{ timeSince }}
|
||||
</span>
|
||||
<div class="block pb-4 flex gap-4">
|
||||
<div class="flex-none w-16 pt-1">
|
||||
<EntityIcon
|
||||
:entityId="projectId"
|
||||
:iconSize="64"
|
||||
class="block border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
||||
<div class="text-sm mb-3">
|
||||
<div class="truncate">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ issuer }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ timeSince }}
|
||||
</div>
|
||||
<div v-if="latitude || longitude">
|
||||
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||
<a
|
||||
:href="getOpenStreetMapUrl()"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>
|
||||
Map View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-slate-500">
|
||||
@@ -100,6 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="issuer == activeDid"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onEditClick()"
|
||||
@@ -108,29 +80,117 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openDialog({ name: 'you', did: activeDid })"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
|
||||
>
|
||||
I gave...
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<p>... or choose a contact who gave:</p>
|
||||
<!-- similar contact selection code is in multiple places -->
|
||||
<div class="px-4">
|
||||
<div v-if="activeDid" class="text-center">
|
||||
<button
|
||||
@click="openDialog({ name: 'you', did: activeDid })"
|
||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
I gave…
|
||||
</button>
|
||||
<p class="mt-2 mb-4 text-center">Or, record a gift from:</p>
|
||||
</div>
|
||||
<p v-if="!activeDid" class="mt-2 mb-4">Record a gift from:</p>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<EntityIcon
|
||||
:entityId="Anonymous"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Anonymous
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
class="text-blue-500"
|
||||
>
|
||||
{{ contact.name }},
|
||||
</button>
|
||||
<span v-if="allContacts.length > 0"> or </span>
|
||||
<button @click="openDialog()" class="text-blue-500">
|
||||
someone not specified
|
||||
</button>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length > 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Gifts to & from this -->
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-2 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
|
||||
</h3>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="give in givesToThis"
|
||||
:key="give.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<span v-if="give.amount"
|
||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.description }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
…and from this Project
|
||||
</h3>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="give in givesByThis"
|
||||
:key="give.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<span v-if="give.amount"
|
||||
><fa icon="coins" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.description }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,45 +200,6 @@
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
|
||||
<!-- Commit -->
|
||||
<!--
|
||||
<router-link
|
||||
:to="{ name: 'new-edit-commitment' }"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
|
||||
>Make Commitment</router-link
|
||||
>
|
||||
-->
|
||||
|
||||
<!-- Commitments -->
|
||||
<!--
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Commitments</h3>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>5 hours <fa icon="spinner" class="fa-fw text-slate-400"></fa
|
||||
></span>
|
||||
</li>
|
||||
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>US$ 20.00 <fa icon="circle-check" class="fa-fw text-lime-500"></fa
|
||||
></span>
|
||||
</li>
|
||||
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>0.1 BTC <fa icon="spinner" class="fa-fw text-slate-400"></fa
|
||||
></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
@@ -189,33 +210,86 @@
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import * as moment from "moment";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { createAndSubmitGive } from "@/libs/endorserServer";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiveServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage },
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ProjectViewView extends Vue {
|
||||
activeDid = "";
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
expanded = false;
|
||||
name = "";
|
||||
description = "";
|
||||
expanded = false;
|
||||
givesToThis: Array<GiveServerRecord> = [];
|
||||
givesByThis: Array<GiveServerRecord> = [];
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
name = "";
|
||||
issuer = "";
|
||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||
timeSince = "";
|
||||
truncatedDesc = "";
|
||||
truncateLength = 40;
|
||||
timeSince = "";
|
||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||
errorMessage = "";
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
const account = accountsArr?.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
this.LoadProject(identity);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
onEditClick() {
|
||||
localStorage.setItem("projectId", this.projectId as string);
|
||||
@@ -225,6 +299,11 @@ export default class ProjectViewView extends Vue {
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(did, activeDid, dids, contacts) {
|
||||
return didInfo(did, activeDid, dids, contacts);
|
||||
}
|
||||
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
}
|
||||
@@ -238,11 +317,13 @@ export default class ProjectViewView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/claim/byHandle/" +
|
||||
encodeURIComponent(this.projectId);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
@@ -253,52 +334,141 @@ export default class ProjectViewView extends Vue {
|
||||
const now = moment.now();
|
||||
this.timeSince = moment.utc(now).to(eventDate);
|
||||
}
|
||||
this.issuer = resp.data.issuer;
|
||||
this.name = resp.data.claim?.name || "(no name)";
|
||||
this.description = resp.data.claim?.description || "(no description)";
|
||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
||||
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
||||
} else if (resp.status === 404) {
|
||||
// actually, axios throws an error so we never get here
|
||||
this.errorMessage = "That project does not exist.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "That project does not exist.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.response?.status === 404) {
|
||||
this.errorMessage = "That project does not exist.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "That project does not exist.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.errorMessage =
|
||||
"Something went wrong retrieving that project." +
|
||||
" See logs for more info.";
|
||||
console.error("Error retrieving project:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving that project. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Error retrieving project:", serverError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts === 0) {
|
||||
console.error("Problem! Should have a profile!");
|
||||
} else {
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
const givesInUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/givesForPlans?planIds=" +
|
||||
encodeURIComponent(JSON.stringify([this.projectId]));
|
||||
try {
|
||||
const resp = await this.axios.get(givesInUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.givesToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve gives to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
this.LoadProject(identity);
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving gives to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving gives to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const givesOutUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/givesProvidedBy?providerId=" +
|
||||
encodeURIComponent(this.projectId);
|
||||
try {
|
||||
const resp = await this.axios.get(givesOutUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.givesByThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve gives 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 gives by project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving gives by this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(contact) {
|
||||
this.$refs.customDialog.open(contact);
|
||||
}
|
||||
|
||||
getOpenStreetMapUrl() {
|
||||
// Google URL is https://maps.google.com/?q=LAT,LONG
|
||||
return (
|
||||
"https://www.openstreetmap.org/?mlat=" +
|
||||
this.latitude +
|
||||
"&mlon=" +
|
||||
this.longitude +
|
||||
"#map=15/" +
|
||||
this.latitude +
|
||||
"/" +
|
||||
this.longitude
|
||||
);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
@@ -317,60 +487,84 @@ export default class ProjectViewView extends Vue {
|
||||
* @param hours may be 0
|
||||
*/
|
||||
async recordGive(giverDid, description, hours) {
|
||||
if (this.activeDid == null) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
"You must select an identity before you can record a give.";
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!description && !hours) {
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
"You must enter a description or some number of hours.";
|
||||
return;
|
||||
}
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
this.projectId
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.status != 201 || result.data?.error) {
|
||||
console.log("Error with give result:", result);
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
result.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
} else {
|
||||
this.alertTitle = "Success";
|
||||
this.alertMessage = "That gift was recorded.";
|
||||
//this.updateAllFeed(); // full update is overkill but we should show something
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// axios throws errors on 400 responses
|
||||
console.log("Error with give caught:", e);
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage =
|
||||
e.userMessage ||
|
||||
e.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
});
|
||||
}
|
||||
|
||||
// This same popup code is in many files.
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
if (!description && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
description,
|
||||
hours,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (result.status !== 201 || result.data?.error) {
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
result.data?.error?.message ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That gift was recorded.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error with give caught:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
e.userMessage ||
|
||||
e.response?.data?.error?.message ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
|
||||
><fa icon="house-chimney" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="magnifying-glass" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="folder-open" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="users" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
><fa icon="circle-user" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
@@ -49,7 +7,8 @@
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<form id="QuickSearch" class="mb-4 flex">
|
||||
|
||||
<div id="QuickSearch" class="mb-4 flex">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
@@ -60,7 +19,7 @@
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New Project -->
|
||||
<button
|
||||
@@ -80,7 +39,7 @@
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
@@ -91,10 +50,11 @@
|
||||
class="block py-4 flex gap-4"
|
||||
>
|
||||
<div class="flex-none w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=1"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
<EntityIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-hidden">
|
||||
@@ -115,7 +75,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
@@ -123,31 +82,11 @@ import { accessToken } from "@/libs/crypto";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
|
||||
/**
|
||||
* Represents data about a project
|
||||
**/
|
||||
interface ProjectData {
|
||||
/**
|
||||
* Name of the project
|
||||
**/
|
||||
name: string;
|
||||
/**
|
||||
* Description of the project
|
||||
**/
|
||||
description: string;
|
||||
/**
|
||||
* URL referencing information about the project
|
||||
**/
|
||||
handleId: string;
|
||||
/**
|
||||
* The Identier of the project
|
||||
**/
|
||||
rowid: string;
|
||||
}
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
|
||||
@Component({
|
||||
components: { InfiniteScroll, AlertMessage },
|
||||
components: { InfiniteScroll, AlertMessage, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ProjectsView extends Vue {
|
||||
apiServer = "";
|
||||
@@ -156,6 +95,12 @@ export default class ProjectsView extends Vue {
|
||||
isLoading = false;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core project data loader
|
||||
@@ -171,19 +116,27 @@ export default class ProjectsView extends Vue {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
if (resp.status === 200 || !resp.data.data) {
|
||||
const plans: ProjectData[] = resp.data.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId = plan.fullIri, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
} else {
|
||||
console.log(resp.status);
|
||||
console.log("Bad server response & data:", resp.status, resp.data);
|
||||
throw Error("Failed to get projects from the server.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got error loading projects:", error.message);
|
||||
this.alertTitle = "Error";
|
||||
this.alertMessage = "Got an error loading projects:" + error.message;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Got an error loading projects: " + error.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
@@ -224,6 +177,22 @@ export default class ProjectsView extends Vue {
|
||||
await this.dataLoader(url, token);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'created' hook runs when the Vue instance is first created
|
||||
**/
|
||||
@@ -234,26 +203,33 @@ export default class ProjectsView extends Vue {
|
||||
const activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts === 0) {
|
||||
console.error("Problem! You need a profile!");
|
||||
this.alertTitle = "Error!";
|
||||
this.alertMessage = "Problem! You need a profile!";
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("No accounts found.");
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You need an identity to load your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (!identity) {
|
||||
throw new Error("No identity found.");
|
||||
}
|
||||
const identity = await this.getIdentity(activeDid);
|
||||
this.current = identity;
|
||||
this.LoadProjects(identity);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
this.alertTitle = "Error!";
|
||||
this.alertMessage = "Problem! You need a profile!";
|
||||
console.log("Error initializing:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong loading your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
@@ -67,22 +20,34 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeAccount">
|
||||
<p>
|
||||
BEWARE: Anyone who gets hold of this mnemonic seed phrase will be able
|
||||
impersonate you and take over any digital holdings based on it. So only
|
||||
reveal it when you are in a private place out of sight of other eyes,
|
||||
and only record it in something private -- don't take a screenshot or
|
||||
send it to any online service.
|
||||
<p class="text-center mb-4">
|
||||
<b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will
|
||||
be able impersonate you and take over any digital holdings based on it.
|
||||
Reveal it when you are somewhere only you can see your screen, and
|
||||
record it somewhere only you have access.
|
||||
<i>Don't take a screenshot or send it to any online service.</i>
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="showSeedPhrase"
|
||||
>
|
||||
Click here when you're ready to see it.
|
||||
</button>
|
||||
<p v-if="numAccounts > 1">
|
||||
<b class="text-orange-600">Note:</b> You have more than one identity
|
||||
stored in this browser. If they are all based on the same seed as the
|
||||
current identity, this one backup is sufficient; however, if you have
|
||||
different seeds for other identities, you will have to back them up
|
||||
separately.
|
||||
</p>
|
||||
|
||||
<p v-if="showSeed">{{ activeAccount.mnemonic }}</p>
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="showSeedPhrase"
|
||||
>
|
||||
Reveal my Seed Phrase
|
||||
</button>
|
||||
|
||||
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
|
||||
{{ activeAccount.mnemonic }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>You do not have an active identity.</div>
|
||||
<AlertMessage
|
||||
@@ -98,10 +63,12 @@ import { accountsDB, db } from "@/db";
|
||||
import * as R from "ramda";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
@Component({ components: { AlertMessage } })
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
export default class SeedBackupView extends Vue {
|
||||
activeAccount = null;
|
||||
numAccounts = 0;
|
||||
showSeed = false;
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
@@ -115,11 +82,19 @@ export default class SeedBackupView extends Vue {
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
this.numAccounts = accounts.length;
|
||||
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
|
||||
} catch (err) {
|
||||
console.error("Got an error loading an identity:", err);
|
||||
this.alertTitle = "Error Loading Account";
|
||||
this.alertMessage = "Got an error loading your seed data.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Account",
|
||||
text: "Got an error loading your seed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,30 +10,46 @@
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Do you already have an identity to import?
|
||||
Do you have an identity to import?
|
||||
</p>
|
||||
<a
|
||||
@click="onClickYes()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
class="block w-full text-center text-lg uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
No
|
||||
</a>
|
||||
<a
|
||||
@click="onClickNo()"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>Yes</a
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
Yes
|
||||
</a>
|
||||
<a
|
||||
v-if="numAccounts > 0"
|
||||
@click="onClickDerive()"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
Derive New Address from Seed Imported Previously
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB } from "@/db";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class StartView extends Vue {
|
||||
numAccounts = 0;
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public onClickYes() {
|
||||
this.$router.push({ name: "new-identifier" });
|
||||
}
|
||||
@@ -41,5 +57,9 @@ export default class StartView extends Vue {
|
||||
public onClickNo() {
|
||||
this.$router.push({ name: "import-account" });
|
||||
}
|
||||
|
||||
public onClickDerive() {
|
||||
this.$router.push({ name: "import-derive" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Heading -->
|
||||
@@ -87,95 +40,41 @@
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { World } from "@/components/World/World.js";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
|
||||
interface WorldProperties {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
@Component({ components: { AlertMessage, World } })
|
||||
@Component({ components: { AlertMessage, World, QuickNav } })
|
||||
export default class StatisticsView extends Vue {
|
||||
world: World;
|
||||
worldProperties: WorldProperties = {};
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
|
||||
// 'mounted' hook runs after initial render
|
||||
mounted() {
|
||||
try {
|
||||
const container = document.querySelector("#scene-container");
|
||||
console.log(container);
|
||||
const newWorld = new World(container, this);
|
||||
newWorld.start();
|
||||
this.world = newWorld;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Mounting Error",
|
||||
text: err.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public captureGraphics() {
|
||||
/**
|
||||
// from https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#examples
|
||||
// Adds a blank image
|
||||
const dataBlob = document
|
||||
.querySelector("#scene-container")
|
||||
.firstChild.toBlob((blob) => {
|
||||
const newImg = document.createElement("img");
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
newImg.onload = () => {
|
||||
// no longer need to read the blob so it's revoked
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
newImg.src = url;
|
||||
document.body.appendChild(newImg);
|
||||
});
|
||||
**/
|
||||
|
||||
/**
|
||||
// Yields a blank page with the iframe below
|
||||
const dataUrl = document
|
||||
.querySelector("#scene-container")
|
||||
.firstChild.toDataURL("image/png");
|
||||
**/
|
||||
|
||||
/**
|
||||
// Yields a blank page with the iframe below
|
||||
const dataUrl = this.world.renderer.domElement.toDataURL("image/png");
|
||||
**/
|
||||
|
||||
/**
|
||||
// Show the image in a new tab
|
||||
const iframe = `
|
||||
<iframe
|
||||
src="${dataUrl}"
|
||||
frameborder="0"
|
||||
style="border:0; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%;"
|
||||
allowfullscreen>
|
||||
</iframe>`;
|
||||
const win = window.open();
|
||||
win.document.open();
|
||||
win.document.write(iframe);
|
||||
win.document.close();
|
||||
**/
|
||||
|
||||
// from https://stackoverflow.com/a/17407392/845494
|
||||
// This yields a file with funny formatting.
|
||||
//const image = const dataUrl.replace("image/png", "image/octet-stream");
|
||||
|
||||
/**
|
||||
// Yields a blank image at the bottom of the page
|
||||
// from https://discourse.threejs.org/t/save-screenshot-on-server/39900/3
|
||||
const img = new Image();
|
||||
img.src = this.world.renderer.domElement.toDataURL();
|
||||
document.body.appendChild(img);
|
||||
**/
|
||||
|
||||
/**
|
||||
* This yields an SVG that only shows white and black highlights
|
||||
// from https://stackoverflow.com/questions/27632621/exporting-from-three-js-scene-to-svg-or-other-vector-format
|
||||
@@ -183,16 +82,12 @@ export default class StatisticsView extends Vue {
|
||||
const rendererSVG = new SVGRenderer();
|
||||
rendererSVG.setSize(window.innerWidth, window.innerHeight);
|
||||
rendererSVG.render(this.world.scene, this.world.camera);
|
||||
//document.body.appendChild(rendererSVG.domElement);
|
||||
ExportToSVG(rendererSVG, "test.svg");
|
||||
}
|
||||
|
||||
public setWorldProperty(propertyName, propertyValue) {
|
||||
this.worldProperties[propertyName] = propertyValue;
|
||||
}
|
||||
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
}
|
||||
|
||||
function ExportToSVG(rendererSVG, filename) {
|
||||
|
||||
360
web-push.md
Normal file
360
web-push.md
Normal file
@@ -0,0 +1,360 @@
|
||||
Web Push notifications is a web browser messaging protocol defined by the W3C.
|
||||
|
||||
Discussions of this interesting technology are clouded because of a
|
||||
terminological morass.
|
||||
|
||||
To understand how Web Push operates, we need to observe that are three (and
|
||||
potentially four) parties involved. These are:
|
||||
|
||||
1) The user's web browser. Let's call that BROWSER
|
||||
2) The Web Push Service Provider which is operated by the organization
|
||||
controlling the web browser's source code. Here named PROVIDER. An example of a
|
||||
PROVIDER is FCM (Firebase Cloud Messaging) which is owned by Google.
|
||||
3) The Web Application that a user is visiting from their web browser. Let's
|
||||
call this the SERVICE (short for Web Push application service)
|
||||
4) A Custom Web Push Intermediary Service, either third party or self-hosted.
|
||||
Called INTERMEDIARY here. FCM also may fit in this category if the SERVICE
|
||||
has an API key from FCM.]
|
||||
|
||||
The workflow works like this:
|
||||
|
||||
BROWSER visits a website which hosts a SERVICE.
|
||||
|
||||
The SERVICE asks BROWSER for its permission to subscribe to messages coming
|
||||
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
|
||||
what the notifications are for and why they are useful. This may help reduce the
|
||||
possibility of users clicking "don't allow".
|
||||
|
||||
Now, to explain what happens in Typescript, we can activate a browser's
|
||||
permission dialogue in this manner:
|
||||
|
||||
```
|
||||
function askPermission(): Promise<NotificationPermission> {
|
||||
return new Promise(function(resolve, reject) {
|
||||
const permissionResult = Notification.requestPermission(function(result) {
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
if (permissionResult) {
|
||||
permissionResult.then(resolve, reject);
|
||||
}
|
||||
}).then(function(permissionResult) {
|
||||
if (permissionResult !== 'granted') {
|
||||
throw new Error("We weren't granted permission.");
|
||||
}
|
||||
return permissionResult;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The Notification.permission property indicates the permission level for the
|
||||
current session and returns one of the following string values:
|
||||
|
||||
'granted': The user has granted permission for notifications.
|
||||
'denied': The user has denied permission for notifications.
|
||||
'default': The user has not made a choice yet.
|
||||
|
||||
Once the user has granted permission, the client application registers a service
|
||||
worker using the `ServiceWorkerRegistration` API.
|
||||
|
||||
The `ServiceWorkerRegistration` API is accessible via the browser's `navigator`
|
||||
object and the `navigator.serviceWorker` child object and ultimately directly
|
||||
accessible via the navigator.serviceWorker.register method which also creates
|
||||
the service worker or the navigator.serviceWorker.getRegistration method.
|
||||
|
||||
Once you have a `ServiceWorkerRegistration` object, that object will provide a
|
||||
child object named `pushManager` through which subscription and management of
|
||||
subscriptions may be done.
|
||||
|
||||
Let's go through the `register` method first:
|
||||
|
||||
```
|
||||
navigator.serviceWorker.register('sw.js', { scope: '/' })
|
||||
.then(function(registration) {
|
||||
console.log('Service worker registered successfully:', registration);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('Service worker registration failed:', error);
|
||||
});
|
||||
```
|
||||
|
||||
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
|
||||
intercept.
|
||||
|
||||
The Vue project already has its own service worker but it is possible to
|
||||
create multiple service worker files by registering them on different scopes.
|
||||
|
||||
It is useful architecturally to specify a separate server worker file.
|
||||
|
||||
In the case of web push, the path of the scope only has reference to the domain
|
||||
of the service worker and no relationship to the pathing for the web push
|
||||
server. In order to specify more than one server workers each needs to be on
|
||||
different scope paths!
|
||||
|
||||
Here's a version which can be used for testing locally. Note there can be
|
||||
caching issues in your browser! Incognito is highly recommended.
|
||||
|
||||
sw-dev.ts
|
||||
```
|
||||
self.addEventListener('push', function(event: PushEvent) {
|
||||
console.log('Received a push message', event);
|
||||
|
||||
const title = 'Push message';
|
||||
const body = 'The message body';
|
||||
const icon = '/images/icon-192x192.png';
|
||||
const tag = 'simple-push-demo-notification-tag';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: body,
|
||||
icon: icon,
|
||||
tag: tag
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
vue.config.js
|
||||
```
|
||||
module.exports = {
|
||||
pwa: {
|
||||
workboxOptions: {
|
||||
importScripts: ['sw-dev.ts']
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Once we have the service worker registered and the ServiceWorkerRegistration is
|
||||
returned, we then have access to a `pushManager` property object. This property
|
||||
allows us to continue with the web push work flow.
|
||||
|
||||
In the next step, BROWSER requests a data structure from SERVICE called a VAPID
|
||||
(Voluntary Application Server Identification) which is the public key from a
|
||||
key-pair.
|
||||
|
||||
The VAPID is a specification used to identify the application server (i.e. the
|
||||
SERVICE server) that is sending push messages through a push PROVIDER. It's an
|
||||
authentication mechanism that allows the server to demonstrate its identity to
|
||||
the push PROVIDER, by use of a public and private key pair. These keys are used
|
||||
by the SERVICE in encrypting messages being sent to the BROWSER, as well as
|
||||
being used by the BROWSER in decrypting the messages coming from the SERVICE.
|
||||
|
||||
The VAPID (Voluntary Application Server Identification) key provides more
|
||||
security and authenticity for web push notifications in the following ways:
|
||||
|
||||
Identifying the Application Server:
|
||||
|
||||
The VAPID key is used to identify the application server that is sending
|
||||
the push notifications. This ensures that the push notifications are
|
||||
authentic and not sent by a malicious third party.
|
||||
|
||||
Encrypting the Messages:
|
||||
|
||||
The VAPID key is used to sign the push notifications sent by the
|
||||
application server, ensuring that they are not tampered with during
|
||||
transmission. This provides an additional layer of security and
|
||||
authenticity for the push notifications.
|
||||
|
||||
Adding Contact Information:
|
||||
|
||||
The VAPID key allows a web application to add contact information to
|
||||
the push messages sent to the browser push service. This enables the
|
||||
push service to contact the application server in case of need or
|
||||
provide additional debug information about the push messages.
|
||||
|
||||
Improving Delivery Rates:
|
||||
|
||||
Using the VAPID key can help improve the overall performance of web push
|
||||
notifications, specifically improving delivery rates. By streamlining the
|
||||
delivery process, the chance of delivery errors along the way is lessened.
|
||||
|
||||
If the BROWSER accepts and grants permission to subscribe to receiving from the
|
||||
SERVICE Web Push messages, then the BROWSER makes a subscription request to
|
||||
PROVIDER which creates and stores a special URL for that BROWSER.
|
||||
|
||||
Here's a bit of code describing the above process:
|
||||
|
||||
```
|
||||
// b64 is the VAPID
|
||||
b64 = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
|
||||
const applicationServerKey = urlBase64ToUint8Array(b64);
|
||||
const options: PushSubscriptionOptions = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
};
|
||||
|
||||
registration.pushManager.subscribe(options)
|
||||
.then(function(subscription) {
|
||||
console.log('Push subscription successful:', subscription);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Push subscription failed:', error);
|
||||
});
|
||||
```
|
||||
|
||||
In this example, the `applicationServerKey` variable contains the VAPID public
|
||||
key, which is converted to a `Uint8Array` using a function such as this:
|
||||
|
||||
```
|
||||
export function toUint8Array(base64String: string, atobFn: typeof atob): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = atobFn(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
```
|
||||
|
||||
The options object is of type `PushSubscriptionOptions`, which includes the
|
||||
`userVisibleOnly` and `applicationServerKey` (ie VAPID public key) properties.
|
||||
|
||||
options: An object that contains the options used for creating the
|
||||
subscription. This object itself has the following sub-properties:
|
||||
|
||||
applicationServerKey: A public key your push service uses for application
|
||||
server identification. This is normally a Uint8Array.
|
||||
|
||||
userVisibleOnly: A boolean value indicating that the push messages that
|
||||
are sent should be made visible to the user through a notification.
|
||||
This is often set to true.
|
||||
|
||||
The subscribe() method returns a `Promise` that resolves to a `PushSubscription`
|
||||
object containing details of the subscription, such as the endpoint URL and the
|
||||
public key. The returned data would have a form like this:
|
||||
|
||||
{
|
||||
"endpoint": "https://some.pushservice.com/some/unique/identifier",
|
||||
"expirationTime": null,
|
||||
"keys": {
|
||||
"p256dh": "some_base64_encoded_string",
|
||||
"auth": "some_other_base64_encoded_string"
|
||||
}
|
||||
}
|
||||
|
||||
endpoint: A string representing the endpoint URL for the push service. This
|
||||
URL is essentially the push service address to which the push message would
|
||||
be sent for this particular subscription.
|
||||
|
||||
expirationTime: A DOMHighResTimeStamp (which is basically a number or null)
|
||||
representing the subscription's expiration time in milliseconds since
|
||||
01 January, 1970 UTC. This can be null if the subscription never expires.
|
||||
|
||||
The BROWSER will, internally, then use that URL to check for incoming messages
|
||||
by way of the service worker we described earlier. The BROWSER also sends this
|
||||
URL back to SERVICE which will use that URL to send messages to the BROWSER via
|
||||
the PROVIDER.
|
||||
|
||||
Ultimately, the actual internal process of receiving messages varies from BROWSER
|
||||
to BROWSER. Approaches vary from long-polling HTTP connections to WebSockets. A
|
||||
lot of handwaving and voodoo magic. The bottom line is that the BROWSER itself
|
||||
manages the connection to the PROVIDER whilst the SERVICE must send messages
|
||||
via the PROVIDER so that they reach the BROWSER service worker.
|
||||
|
||||
Just to remind us that in our service worker our code for receiving messages
|
||||
will look something like this:
|
||||
|
||||
```
|
||||
self.addEventListener('push', function(event: PushEvent) {
|
||||
console.log('Received a push message', event);
|
||||
|
||||
const title = 'Push message';
|
||||
const body = 'The message body';
|
||||
const icon = '/images/icon-192x192.png';
|
||||
const tag = 'simple-push-demo-notification-tag';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: body,
|
||||
icon: icon,
|
||||
tag: tag
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
Now to address the issue of receiving notification messages on mobile devices.
|
||||
It should be noted that Web Push messages are only received when BROWSER is
|
||||
open, except in the cases of Chrome and Firefox mobile BROWSERS. In iOS, the
|
||||
mobile application (in our case a PWA) must be added to the Home Screen and
|
||||
permissions must be explicitly granted that allow the application to receive
|
||||
push notifications. Further, with an iOS device the user must enable wake on
|
||||
notification to have their device light-up when it receives a notification
|
||||
(https://support.apple.com/enus/HT208081).
|
||||
|
||||
So what about #4? - The INTERMEDIARY. Well, It is possible under very special
|
||||
circumstances to create your own Web Push PROVIDER. The only case I've found so
|
||||
far relates to making an Android Custom ROM. (An Android Custom ROM is a
|
||||
customized version of the Android Operating System.) There are open source
|
||||
IMTERMEDIARY products such as UnifiedPush (https://unifiedpush.org/) which can
|
||||
fulfill this role. If you are using iOS you are not permitted to make or use
|
||||
your own custom Web Push PROVIDER. Apple will never allow anyone to do that.
|
||||
Apple has none of its own.
|
||||
|
||||
It is, however, possible to have a sort of proxy working between your SERVICE
|
||||
and FCM (or iOS). Services that mash up various Push notification services (like
|
||||
OneSignal) can perform in the role of such proxies.
|
||||
|
||||
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
|
||||
time on.
|
||||
|
||||
A BROWSER may also remove a subscription. In order to remove a subscription,
|
||||
the registration record must be retrieved from the serviceWorker using
|
||||
`navigator.serviceWorker.ready`. Within the `ready` property is the
|
||||
`pushManager` which has a `getSubscription` method. Once you have the
|
||||
subscription object, you may call the `unsubscribe` method. `unsubscribe` is
|
||||
asynchronnous and returns a boolean true if it is successful in removing the
|
||||
subscription and false if not.
|
||||
|
||||
|
||||
```
|
||||
async function unsubscribeFromPush() {
|
||||
// Check if the browser supports service workers
|
||||
if ("serviceWorker" in navigator) {
|
||||
// Get the registration object for the service worker
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Get the existing subscription
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
// Unsubscribe
|
||||
const successful = await subscription.unsubscribe();
|
||||
if (successful) {
|
||||
console.log("Successfully unsubscribed from push notifications.");
|
||||
// You can also inform your server to remove this subscription
|
||||
} else {
|
||||
console.log("Failed to unsubscribe from push notifications.");
|
||||
}
|
||||
} else {
|
||||
console.log("No subscription was found.");
|
||||
}
|
||||
} else {
|
||||
console.log("Service workers are not supported by this browser.");
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
unsubscribeFromPush().catch((err) => {
|
||||
console.error("An error occurred while unsubscribing from push notifications", 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.
|
||||
Reference in New Issue
Block a user