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 54205d9..9c96dcd 100644 --- a/project.task.yaml +++ b/project.task.yaml @@ -4,8 +4,9 @@ - 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 @@ -30,9 +31,11 @@ - 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? @@ -40,14 +43,29 @@ - 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 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 2afdb9e..d973694 100644 --- a/src/components/InfiniteScroll.vue +++ b/src/components/InfiniteScroll.vue @@ -14,6 +14,7 @@ export default class InfiniteScroll extends Vue { readonly distance!: number; private observer!: IntersectionObserver; + // 'mounted' hook runs after initial render mounted() { const options = { root: this.$refs.scrollContainer as HTMLElement, @@ -24,6 +25,7 @@ export default class InfiniteScroll extends Vue { this.observer.observe(this.$refs.sentinel as HTMLElement); } + // 'beforeUnmount' hook runs before unmounting the component beforeUnmount() { 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 9595c5b..860f91b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,8 @@ import { faRotate, faShareNodes, faSpinner, + faSquareCaretDown, + faSquareCaretUp, faTrashCan, faUser, faUsers, @@ -71,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 302f00b..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", @@ -130,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 b86e3d2..17e6e2d 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -298,10 +298,10 @@
-
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index ade22ae..ca176bd 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,15 +1,360 @@ diff --git a/src/views/NewEditProjectView.vue b/src/views/NewEditProjectView.vue index 0644c9a..72079a8 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; @@ -254,11 +258,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/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 f9dee84..b497eb2 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -167,7 +167,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"); + const identity = JSON.parse(account?.identity || "null"); + if (!identity) { + throw new Error("No identity found."); + } this.LoadProjects(identity); } } 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);