diff --git a/README.md b/README.md index 7648ce3..da752e5 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ export const createAndStoreIdentifier = async (mnemonicPassword) => { ## Kudos +Gifts make the world go 'round! + * [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80) -* [Many libraries]() such as Veramo.io, Vuejs.org, threejs +* [Many tools & libraries]() such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org * [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439) +* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg) diff --git a/project.task.yaml b/project.task.yaml index 5e8936f..9c96dcd 100644 --- a/project.task.yaml +++ b/project.task.yaml @@ -4,8 +4,11 @@ - add infinite scroll assignee:matthew blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time -- allow type annotations in World.js & landmarks.js (since we get this error: "Types are not supported by current JavaScript version") +- allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version") - 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) + +- 8 Move to vue-facing-decorator - stats v1 : - 01 show numeric stats @@ -28,21 +31,41 @@ - show pop-up confirming that settings & contacts have been downloaded -- Ensure each action sent to the server has a confirmation. +- Ensure each action sent to the server has a confirmation - registration -- discover screen +- 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 - .5 customize favicon - .5 make advanced features harder to access; advanced build? +- 04 allow user to download claims, mine + ones I can see about me from others + +- 24 Move to Vite + +- 40 notifications : + - push + +- 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 + +- Do we want split first name & last name? +- remove 'about' page - Release Minimum Viable Product : - 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. +- 40 notifications v+ : + - pull, w/ scheduled runs + - Stats : - 01 point out user's location on the world - 01 present a credential selected from the stats @@ -66,6 +89,7 @@ - Peer DID + - DIDComm - Write to or read from a different ledger (eg. private ACDC, attest.sh) diff --git a/public/img/textures/forest-floor.png b/public/img/textures/forest-floor.png deleted file mode 100644 index 5a7a58e..0000000 Binary files a/public/img/textures/forest-floor.png and /dev/null differ diff --git a/public/img/textures/leafy-autumn-forest-floor.jpg b/public/img/textures/leafy-autumn-forest-floor.jpg new file mode 100644 index 0000000..cadd5a6 Binary files /dev/null and b/public/img/textures/leafy-autumn-forest-floor.jpg differ diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue new file mode 100644 index 0000000..c48ce3c --- /dev/null +++ b/src/components/GiftedDialog.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/components/InfiniteScroll.vue b/src/components/InfiniteScroll.vue index 5a497ea..3baf32a 100644 --- a/src/components/InfiniteScroll.vue +++ b/src/components/InfiniteScroll.vue @@ -31,6 +31,7 @@ export default class InfiniteScroll extends Vue { } } + // 'beforeUnmount' hook runs before unmounting the component beforeUnmount() { if (this.observer) { this.observer.disconnect(); diff --git a/src/components/World/components/objects/terrain.js b/src/components/World/components/objects/terrain.js index b7d1f54..0abb80d 100644 --- a/src/components/World/components/objects/terrain.js +++ b/src/components/World/components/objects/terrain.js @@ -2,7 +2,7 @@ import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three"; export function createTerrain(props) { const loader = new TextureLoader(); - const height = loader.load("img/textures/forest-floor.png"); + const height = loader.load("img/textures/leafy-autumn-forest-floor.jpg"); // w h const geometry = new PlaneGeometry(props.width, props.height, 64, 64); diff --git a/src/db/index.ts b/src/db/index.ts index bcb3806..58bd231 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -41,7 +41,12 @@ const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema); * https://stackoverflow.com/questions/72474803/error-the-top-level-await-experiment-is-not-enabled-set-experiments-toplevelaw */ -// create password and place password in localStorage +/** + * Create password and place password in localStorage. + * + * It's good practice to keep the data encrypted at rest, so we'll do that even + * if the secret is stored right next to the app. + */ const secret = localStorage.getItem("secret") || Encryption.createRandomEncryptionKey(); diff --git a/src/db/tables/accounts.ts b/src/db/tables/accounts.ts index b42e55a..d31f160 100644 --- a/src/db/tables/accounts.ts +++ b/src/db/tables/accounts.ts @@ -3,6 +3,8 @@ export type Account = { dateCreated: string; derivationPath: string; did: string; + // stringified JSON containing underlying key material of type IIdentifier + // https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts identity: string; publicKeyHex: string; mnemonic: string; diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index a7671c2..02f7ba7 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -1,10 +1,12 @@ // a singleton export type Settings = { id: number; // there's only one entry: MASTER_SETTINGS_KEY + activeDid?: string; apiServer?: string; firstName?: string; lastName?: string; + lastViewedClaimId?: string; showContactGivesInline?: boolean; }; diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 227577f..6766ecd 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1,21 +1,32 @@ +import * as R from "ramda"; +import { IIdentifier } from "@veramo/core"; +import { accessToken, SimpleSigner } from "@/libs/crypto"; +import * as didJwt from "did-jwt"; +import { Axios, AxiosResponse } from "axios"; + export const SCHEMA_ORG_CONTEXT = "https://schema.org"; export const SERVICE_ID = "endorser.ch"; -export interface GenericClaim { +export interface AgreeVerifiableCredential { "@context": string; "@type": string; - issuedAt: string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any - claim: Record; + object: Record; } -export interface AgreeVerifiableCredential { +export interface ClaimResult { + success: { claimId: string; handleId: string }; + error: { code: string; message: string }; +} + +export interface GenericClaim { "@context": string; "@type": string; + issuedAt: string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any - object: Record; + claim: Record; } export interface GiveServerRecord { @@ -33,10 +44,10 @@ export interface GiveServerRecord { export interface GiveVerifiableCredential { "@context"?: string; // optional when embedded, eg. in an Agree "@type": string; - agent: { identifier: string }; + agent?: { identifier: string }; description?: string; identifier?: string; - object: { amountOfThisGood: number; unitCode: string }; + object?: { amountOfThisGood: number; unitCode: string }; recipient: { identifier: string }; } @@ -47,3 +58,125 @@ export interface RegisterVerifiableCredential { object: string; recipient: { identifier: string }; } + +export interface InternalError { + error: string; // for system logging + userMessage?: string; // for user display +} + +// This is used to check for hidden info. +// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 +const HIDDEN_DID = "did:none:HIDDEN"; + +export function isHiddenDid(did) { + return did === HIDDEN_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); + if (myId) { + return "You"; + } else { + const contact = R.find((c) => c.did === did, contacts); + if (contact) { + return contact.name || "Someone Unnamed in Contacts"; + } else if (!did) { + return "Unpecified Person"; + } else if (isHiddenDid(did)) { + return "Someone Not In Network"; + } else { + return "Someone Not In Contacts"; + } + } +} + +/** + * For result, see https://endorser.ch:3000/api-docs/#/claims/post_api_v2_claim + + * @param identity + * @param fromDid may be null + * @param toDid + * @param description may be null; should have this or hours + * @param hours may be null; should have this or description + */ +export async function createAndSubmitGive( + axios: Axios, + apiServer: string, + identity: IIdentifier, + fromDid: string, + toDid: string, + description: string, + hours: number +): Promise | InternalError> { + // Make a claim + const vcClaim: GiveVerifiableCredential = { + "@context": "https://schema.org", + "@type": "GiveAction", + recipient: { identifier: toDid }, + }; + if (fromDid) { + vcClaim.agent = { identifier: fromDid }; + } + if (description) { + vcClaim.description = description; + } + if (hours) { + vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" }; + } + // Make a payload for the claim + const vcPayload = { + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + credentialSubject: vcClaim, + }, + }; + // Create a signature using private key of identity + if (identity.keys[0].privateKeyHex == null) { + return new Promise((resolve, reject) => { + reject({ + error: "No private key", + message: + "Your identifier " + + identity.did + + " is not configured correctly. Use a different identifier.", + }); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const privateKeyHex: string = identity.keys[0].privateKeyHex!; + const signer = await SimpleSigner(privateKeyHex); + const alg = undefined; + // Create a JWT for the request + const vcJwt: string = await didJwt.createJWT(vcPayload, { + alg: alg, + issuer: identity.did, + signer: signer, + }); + + // Make the xhr request payload + + const payload = JSON.stringify({ jwtEncoded: vcJwt }); + const url = apiServer + "/api/v2/claim"; + const token = await accessToken(identity); + const headers = { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }; + + return axios.post(url, payload, { headers }); +} + +// from https://stackoverflow.com/a/175787/845494 +// +export function isNumeric(str: string): boolean { + return !isNaN(+str); +} + +export function numberOrZero(str: string): number { + return isNumeric(str) ? +str : 0; +} diff --git a/src/main.ts b/src/main.ts index c3bec57..860f91b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import "./assets/styles/tailwind.css"; import { library } from "@fortawesome/fontawesome-svg-core"; import { + faBurst, faCalendar, faChevronLeft, faCircle, @@ -36,6 +37,8 @@ import { faRotate, faShareNodes, faSpinner, + faSquareCaretDown, + faSquareCaretUp, faTrashCan, faUser, faUsers, @@ -43,6 +46,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; library.add( + faBurst, faCalendar, faChevronLeft, faCircle, @@ -69,6 +73,8 @@ library.add( faRotate, faShareNodes, faSpinner, + faSquareCaretDown, + faSquareCaretUp, faTrashCan, faUser, faUsers, diff --git a/src/router/index.ts b/src/router/index.ts index d7fd253..5ae7a16 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,21 +1,29 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; import { accountsDB } from "@/db"; +/** + * + * @param to :RouteLocationNormalized + * @param from :RouteLocationNormalized + * @param next :NavigationGuardNext + */ +const enterOrStart = async (to, from, next) => { + await accountsDB.open(); + const num_accounts = await accountsDB.accounts.count(); + if (num_accounts > 0) { + next(); + } else { + next({ name: "start" }); + } +}; + const routes: Array = [ { path: "/", name: "home", component: () => - import(/* webpackChunkName: "start" */ "../views/DiscoverView.vue"), - beforeEnter: async (to, from, next) => { - await accountsDB.open(); - const num_accounts = await accountsDB.accounts.count(); - if (num_accounts > 0) { - next(); - } else { - next({ name: "start" }); - } - }, + import(/* webpackChunkName: "home" */ "../views/HomeView.vue"), + beforeEnter: enterOrStart, }, { path: "/about", @@ -28,6 +36,7 @@ const routes: Array = [ name: "account", component: () => import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"), + beforeEnter: enterOrStart, }, { path: "/confirm-contact", @@ -58,6 +67,7 @@ const routes: Array = [ name: "contacts", component: () => import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"), + beforeEnter: enterOrStart, }, { path: "/scan-contact", @@ -111,6 +121,14 @@ const routes: Array = [ /* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue" ), }, + { + path: "/new-identifier", + name: "new-identifier", + component: () => + import( + /* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue" + ), + }, { path: "/project", name: "project", @@ -122,6 +140,7 @@ const routes: Array = [ name: "projects", component: () => import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"), + beforeEnter: enterOrStart, }, { path: "/seed-backup", diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index dd4c412..17e6e2d 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -66,20 +66,22 @@ - + +

- Important: before you can create a new project or commit time to - one, you need a friend to register you. + Note: Before you can publicly announce a new project or time + commitment, a friend needs to register you.

- + Share Your Info +
@@ -181,7 +183,7 @@
ID: did:peer:kl45kj41lk451kl3
- + + +
+ Checking... +
+
+ {{ limitsMessage }} +
Rate Limits

@@ -254,10 +263,10 @@ />

- + or + +
+ + + + + +
+

Latest Activity

+ + + Loading… + +
    +
  • +
    + You've seen all claims below. +
    + {{ this.giveDescription(record) }} +
  • +
+
+ + + +
+ +

{{ alertTitle }}

+

{{ alertMessage }}

+
diff --git a/src/views/NewEditProjectView.vue b/src/views/NewEditProjectView.vue index 7b28ea8..cee4e50 100644 --- a/src/views/NewEditProjectView.vue +++ b/src/views/NewEditProjectView.vue @@ -49,8 +49,9 @@ Save Project - + + + Saving… @@ -122,11 +123,14 @@ export default class NewEditProjectView extends Vue { await accountsDB.open(); const num_accounts = await accountsDB.accounts.count(); if (num_accounts === 0) { - console.error("Problem! Should have a profile!"); + 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 || "undefined"); + const identity = JSON.parse(account?.identity || "null"); + if (!identity) { + throw new Error("No identity found."); + } this.LoadProject(identity); } } @@ -222,7 +226,7 @@ export default class NewEditProjectView extends Vue { ); } } catch (error) { - let userMessage = "There was an error. See logs for more info."; + let userMessage = "There was an error saving the project."; const serverError = error as AxiosError; if (serverError) { this.isAlertVisible = true; @@ -255,11 +259,14 @@ export default class NewEditProjectView extends Vue { await accountsDB.open(); const num_accounts = await accountsDB.accounts.count(); if (num_accounts === 0) { - console.error("Problem! Should have a profile!"); + 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 || "undefined"); + const identity = JSON.parse(account?.identity || "null"); + if (!identity) { + throw new Error("No identity found."); + } this.SaveProject(identity); } } diff --git a/src/views/NewIdentifierView.vue b/src/views/NewIdentifierView.vue new file mode 100644 index 0000000..1b363b6 --- /dev/null +++ b/src/views/NewIdentifierView.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index 5af2fdf..37fd059 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -240,7 +240,10 @@ export default class ProjectViewView extends Vue { } else { const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === activeDid, accounts); - const identity = JSON.parse(account?.identity || "undefined"); + const identity = JSON.parse(account?.identity || "null"); + if (!identity) { + throw new Error("No identity found."); + } this.LoadProject(identity); } } diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index 6db6ece..7645c3a 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -222,8 +222,10 @@ export default class ProjectsView extends Vue { } else { const accounts = await accountsDB.accounts.toArray(); const account = R.find((acc) => acc.did === activeDid, accounts); - const identity = JSON.parse(account?.identity || "undefined"); - this.current = identity; + const identity = JSON.parse(account?.identity || "null"); + if (!identity) { + throw new Error("No identity found."); + } this.LoadProjects(identity); } } diff --git a/src/views/StartView.vue b/src/views/StartView.vue index 8c3417d..69f5821 100644 --- a/src/views/StartView.vue +++ b/src/views/StartView.vue @@ -35,7 +35,7 @@ import { Options, Vue } from "vue-class-component"; }) export default class StartView extends Vue { public onClickYes() { - this.$router.push({ name: "account" }); + this.$router.push({ name: "new-identifier" }); } public onClickNo() { diff --git a/src/views/StatisticsView.vue b/src/views/StatisticsView.vue index 1b32a14..2913b20 100644 --- a/src/views/StatisticsView.vue +++ b/src/views/StatisticsView.vue @@ -61,7 +61,7 @@
  • Each will show at their time of appearance relative to all others.
  • Note that the ones on the left and right edges are randomized - because not all their positional data is visible to you. + because their data isn't all visible to you.
  • @@ -75,7 +75,7 @@ {{ worldProperties.endTime }}
    - + {{ worldProperties.animationDurationSeconds }} seconds
    @@ -113,6 +113,7 @@ export default class StatisticsView extends Vue { world: World; worldProperties: WorldProperties = {}; + // 'mounted' hook runs after initial render mounted() { const container = document.querySelector("#scene-container"); const newWorld = new World(container, this);