diff --git a/package-lock.json b/package-lock.json index 2ab5803d..fba20670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@pvermeer/dexie-encrypted-addon": "^3.0.0", "@tweenjs/tween.js": "^21.0.0", "@types/js-yaml": "^4.0.9", + "@types/luxon": "^3.4.2", "@veramo/core": "^5.4.1", "@veramo/credential-w3c": "^5.4.1", "@veramo/data-store": "^5.4.1", @@ -41,7 +42,7 @@ "js-generate-password": "^0.1.9", "js-yaml": "^4.1.0", "localstorage-slim": "^2.5.0", - "luxon": "^3.4.3", + "luxon": "^3.4.4", "merkletreejs": "^0.3.11", "moment": "^2.29.4", "notiwind": "^2.0.2", @@ -9170,6 +9171,11 @@ "@types/geojson": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/mime": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz", @@ -20251,9 +20257,9 @@ } }, "node_modules/luxon": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", - "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", "engines": { "node": ">=12" } diff --git a/package.json b/package.json index cfab56ec..ee91c068 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@pvermeer/dexie-encrypted-addon": "^3.0.0", "@tweenjs/tween.js": "^21.0.0", "@types/js-yaml": "^4.0.9", + "@types/luxon": "^3.4.2", "@veramo/core": "^5.4.1", "@veramo/credential-w3c": "^5.4.1", "@veramo/data-store": "^5.4.1", @@ -41,7 +42,7 @@ "js-generate-password": "^0.1.9", "js-yaml": "^4.1.0", "localstorage-slim": "^2.5.0", - "luxon": "^3.4.3", + "luxon": "^3.4.4", "merkletreejs": "^0.3.11", "moment": "^2.29.4", "notiwind": "^2.0.2", diff --git a/project.task.yaml b/project.task.yaml index a63e09b7..324ccf6a 100644 --- a/project.task.yaml +++ b/project.task.yaml @@ -2,43 +2,40 @@ tasks : - .2 fix give dialog from "more contacts" off home page to allow giving to this user -- 01 in the feed, group by project or contact or topic or time/$ (via BC) -- .2 anchor hash into BTC - -- .1 when gave to a project, say "gave to project" - .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page -- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"? +- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window) +- .2 don't show a warning on a totally new project when the authorized agent is set +- .2 anchor hash into BTC +- .2 list the "show more" contacts alphabetically -- 01 page for BVC +- 32 image on give : + - Show a camera to take a picture + - Scale the image to a reasonable size + - Upload to a public readable place + - check the rate limits + - use CID (hash?) + - put the image URL in the claim + - Rates - images erased? + - image not associated with JWT ULID since that's assigned later - 24 compelling UI for credential presentations - discover who in my network has activity on a project + - 24 compelling UI for statistics (eg. World?) -- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window) +- 01 in the feed, group by project or contact or topic or time/$ (via BC) +- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"? - .2 add links between projects -- 32 image on give : - - Show a camera to take a picture - - Scale the image to a reasonable size - - Upload to a public readable place - - check the rate limits - - use CID (hash?) - - put the image URL in the claim - - Rates - images erased? - - image not associated with JWT ULID since that's assigned later - - 24 make the contact browsing on the front page something that invites more action -- .2 list the "show more" contacts alphabetically - .5 change server plan & project endpoints to use jwtId as identifier rather than rowid - 16 edit offers & gives, or revoke allowing re-creation - .1 When available in the server, give message for 'nonAmountGiven' on offers on ProjectsView page. - .1 Add help instructions for "Encryption key has changed" error. (It is a problem if localStorage is cleared, but the contacts & settings remain and they have to restore their seeds.) -- .5 add more detail on TimeSafari.org - .1 show better error when user with no ID goes to the "My Project" page -- 08 add button to front page to prompt for ideas for gratitude : - - show previous on "Your" screen - - checkboxes - randomize vs show in order, show non-person-oriented messages, show only contacts, show only projects +- 01 in front page prompt for ideas for gratitude : + - randomize (not show in order) + - checkboxes - show non-person-oriented messages, show only contacts, show only projects - 08 allow user to add a time when they want their daily notification diff --git a/src/App.vue b/src/App.vue index 0fee98a8..484d0153 100644 --- a/src/App.vue +++ b/src/App.vue @@ -582,7 +582,7 @@ export default class App extends Vue { } }) .catch((error) => { - console.log("Push provider server communication failed:", error); + console.error("Push provider server communication failed:", error); return false; }); @@ -597,7 +597,7 @@ export default class App extends Vue { return response.ok; }) .catch((error) => { - console.log("Push server communication failed:", error); + console.error("Push server communication failed:", error); return false; }); diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 5353f9e4..4d5da377 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -77,7 +77,6 @@ import { import * as libsUtil from "@/libs/util"; import { accountsDB, db } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; -import { Account } from "@/db/tables/accounts"; import { Contact } from "@/db/tables/contacts"; @Component @@ -206,22 +205,6 @@ export default class GiftedDialog extends Vue { }); } - public async getIdentity(activeDid: string) { - await accountsDB.open(); - const account = (await accountsDB.accounts - .where("did") - .equals(activeDid) - .first()) as Account; - const identity = JSON.parse(account?.identity || "null"); - - if (!identity) { - throw new Error( - "Attempted to load Give records for DID ${activeDid} but no identifier was found", - ); - } - return identity; - } - /** * * @param giverDid may be null @@ -262,7 +245,7 @@ export default class GiftedDialog extends Vue { } try { - const identity = await this.getIdentity(this.activeDid); + const identity = await libsUtil.getIdentity(this.activeDid); const result = await createAndSubmitGive( this.axios, this.apiServer, diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index c0f2b69b..6cce99dd 100644 --- a/src/components/OfferDialog.vue +++ b/src/components/OfferDialog.vue @@ -72,9 +72,8 @@ import { Vue, Component, Prop } from "vue-facing-decorator"; import { NotificationIface } from "@/constants/app"; import { createAndSubmitOffer } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; -import { accountsDB, db } from "@/db/index"; +import { db } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; -import { Account } from "@/db/tables/accounts"; @Component export default class OfferDialog extends Vue { @@ -102,7 +101,7 @@ export default class OfferDialog extends Vue { this.activeDid = settings?.activeDid || ""; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { - console.log("Error retrieving settings from database:", err); + console.error("Error retrieving settings from database:", err); this.$notify( { group: "alert", @@ -173,22 +172,6 @@ export default class OfferDialog extends Vue { }); } - public async getIdentity(activeDid: string) { - await accountsDB.open(); - const account = (await accountsDB.accounts - .where("did") - .equals(activeDid) - .first()) as Account; - const identity = JSON.parse(account?.identity || "null"); - - if (!identity) { - throw new Error( - `Attempted to load Offer records for DID ${activeDid} but no identifier was found`, - ); - } - return identity; - } - /** * * @param description may be an empty string @@ -228,7 +211,7 @@ export default class OfferDialog extends Vue { } try { - const identity = await this.getIdentity(this.activeDid); + const identity = await libsUtil.getIdentity(this.activeDid); const result = await createAndSubmitOffer( this.axios, this.apiServer, @@ -245,7 +228,7 @@ export default class OfferDialog extends Vue { this.isOfferCreationError(result.response) ) { const errorMessage = this.getOfferCreationErrorMessage(result); - console.log("Error with offer creation result:", result); + console.error("Error with offer creation result:", result); this.$notify( { group: "alert", @@ -268,7 +251,7 @@ export default class OfferDialog extends Vue { } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { - console.log("Error with offer recordation caught:", error); + console.error("Error with offer recordation caught:", error); const message = error.userMessage || error.response?.data?.error?.message || diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 4b3ad1e5..f95b3839 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -31,6 +31,7 @@ export type Settings = { }>; showContactGivesInline?: boolean; // Display contact inline or not + showShortcutBvc?: boolean; // Show shortcut for BVC actions vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push warnIfProdServer?: boolean; // Warn if using a production server warnIfTestServer?: boolean; // Warn if using a testing server diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 55756e6c..48856114 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -22,7 +22,7 @@ export interface AgreeVerifiableCredential { "@type": string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any - object: Record; + object: Record; } export interface GiverInputInfo { @@ -46,21 +46,25 @@ export interface ClaimResult { export interface GenericVerifiableCredential { "@context": string; "@type": string; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } export interface GenericServerRecord extends GenericVerifiableCredential { handleId?: string; - id?: string; - issuedAt?: string; - issuer?: string; + id: string; + issuedAt: string; + issuer: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any - claim: Record; + claim: Record; claimType?: string; } export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = { "@context": SCHEMA_ORG_CONTEXT, "@type": "", claim: {}, + id: "", + issuedAt: "", + issuer: "", }; export interface GiveServerRecord { @@ -226,16 +230,16 @@ export interface ErrorResponse { }; } -export interface ErrorResult { - type: "error"; - error: InternalError; -} - export interface InternalError { error: string; // for system logging userMessage?: string; // for user display } +export interface ErrorResult extends ResultWithType { + type: "error"; + error: InternalError; +} + export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; // This is used to check for hidden info. @@ -327,7 +331,7 @@ export function addLastClaimOrHandleAsIdIfMissing( } // return clone of object without any nested *VisibleToDids keys -// similar logic is found in endorser-mobile +// similar code is also contained in endorser-mobile // eslint-disable-next-line @typescript-eslint/no-explicit-any export function removeVisibleToDids(input: any): any { if (input instanceof Object) { @@ -337,7 +341,6 @@ export function removeVisibleToDids(input: any): any { const result: Record = {}; for (const key in input) { if (!key.endsWith("VisibleToDids")) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any result[key] = removeVisibleToDids(R.clone(input[key])); } } @@ -346,7 +349,6 @@ export function removeVisibleToDids(input: any): any { // it's an array return R.map(removeVisibleToDids, input); } - return false; } else { return input; } @@ -515,6 +517,28 @@ export async function createAndSubmitOffer( ); } +// similar logic is found in endorser-mobile +export const createAndSubmitConfirmation = async ( + identifier: IIdentifier, + claim: GenericVerifiableCredential, + lastClaimId: string, // used to set the lastClaimId + handleId: string | undefined, + apiServer: string, + axios: Axios, +) => { + const goodClaim = removeSchemaContext( + removeVisibleToDids( + addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId), + ), + ); + const confirmationClaim: GenericVerifiableCredential = { + "@context": "https://schema.org", + "@type": "AgreeAction", + object: goodClaim, + }; + return createAndSubmitClaim(confirmationClaim, identifier, apiServer, axios); +}; + export async function createAndSubmitClaim( vcClaim: GenericVerifiableCredential, identity: IIdentifier, @@ -579,12 +603,199 @@ export async function createAndSubmitClaim( } } -// from https://stackoverflow.com/a/175787/845494 -// -export function isNumeric(str: string): boolean { - return !isNaN(+str); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isAccept = (claim: Record) => { + return ( + claim && + claim["@context"] === SCHEMA_ORG_CONTEXT && + claim["@type"] === "AcceptAction" + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isOffer = (claim: Record) => { + return ( + claim && + claim["@context"] === SCHEMA_ORG_CONTEXT && + claim["@type"] === "Offer" + ); +}; + +export function currencyShortWordForCode(unitCode: string, single: boolean) { + return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; } -export function numberOrZero(str: string): number { - return isNumeric(str) ? +str : 0; +export function displayAmount(code: string, amt: number) { + return "" + amt + " " + currencyShortWordForCode(code, amt === 1); } + +// insert a space before any capital letters except the initial letter +// (and capitalize initial letter, just in case) +export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => { + return !text + ? "" + : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); +}; + +/** + return readable summary of claim, or something generic + + similar code is also contained in endorser-mobile + **/ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const claimSummary = (claim: Record) => { + if (!claim) { + // to differentiate from "something" above + return "something"; + } + if (claim.claim) { + // probably a Verified Credential + // eslint-disable-next-line @typescript-eslint/no-explicit-any + claim = claim.claim as Record; + } + if (Array.isArray(claim)) { + if (claim.length === 1) { + claim = claim[0]; + } else { + return "multiple claims"; + } + } + const type = claim["@type"]; + if (!type) { + return "a claim"; + } else { + let typeExpl = capitalizeAndInsertSpacesBeforeCaps(type); + if (typeExpl === "Person") { + typeExpl += " claim"; + } + return "a " + typeExpl; + } +}; + +/** + return readable description of claim if possible, as a past-tense action + + identifiers is a list of objects with a 'did' field, each representing the user + contacts is a list of objects with a 'did' field for others and a 'name' field for their name + + similar code is also contained in endorser-mobile + **/ +export const claimSpecialDescription = ( + record: GenericServerRecord, + activeDid: string, + identifiers: Array, + contacts: Array, +) => { + let claim = record.claim; + if (claim.claim) { + // it's probably a Verified Credential + claim = claim.claim; + } + + const issuer = didInfo(record.issuer, activeDid, identifiers, contacts); + const type = claim["@type"] || "UnknownType"; + + if (type === "AgreeAction") { + return issuer + " agreed with " + claimSummary(claim.object); + } else if (isAccept(claim)) { + return issuer + " accepted " + claimSummary(claim.object); + } else if (type === "GiveAction") { + // agent.did is for legacy data, before March 2023 + const giver = claim.agent?.identifier || claim.agent?.did; + const giverInfo = didInfo(giver, activeDid, identifiers, contacts); + let gaveAmount = claim.object?.amountOfThisGood + ? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) + : ""; + if (claim.description) { + if (gaveAmount) { + gaveAmount = gaveAmount + ", and also: "; + } + gaveAmount = gaveAmount + claim.description; + } + if (!gaveAmount) { + gaveAmount = "something not described"; + } + // recipient.did is for legacy data, before March 2023 + const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did; + const gaveRecipientInfo = gaveRecipientId + ? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts) + : ""; + return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount; + } else if (type === "JoinAction") { + // agent.did is for legacy data, before March 2023 + const agent = claim.agent?.identifier || claim.agent?.did; + const contactInfo = didInfo(agent, activeDid, identifiers, contacts); + + let eventOrganizer = + claim.event && claim.event.organizer && claim.event.organizer.name; + eventOrganizer = eventOrganizer || ""; + let eventName = claim.event && claim.event.name; + eventName = eventName ? " " + eventName : ""; + let fullEvent = eventOrganizer + eventName; + fullEvent = fullEvent ? " attended the " + fullEvent : ""; + + let eventDate = claim.event && claim.event.startTime; + eventDate = eventDate ? " at " + eventDate : ""; + return contactInfo + fullEvent + eventDate; + } else if (isOffer(claim)) { + const offerer = claim.offeredBy?.identifier; + const contactInfo = didInfo(offerer, activeDid, identifiers, contacts); + let offering = ""; + if (claim.includesObject) { + offering += + " " + + displayAmount( + claim.includesObject.unitCode, + claim.includesObject.amountOfThisGood, + ); + } + if (claim.itemOffered?.description) { + offering += ", saying: " + claim.itemOffered?.description; + } + // recipient.did is for legacy data, before March 2023 + const offerRecipientId = + claim.recipient?.identifier || claim.recipient?.did; + const offerRecipientInfo = offerRecipientId + ? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts) + : ""; + return contactInfo + " offered" + offering + offerRecipientInfo; + } else if (type === "PlanAction") { + const claimer = claim.agent?.identifier || record.issuer; + const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts); + return claimerInfo + " announced a project: " + claim.name; + } else if (type === "Tenure") { + // party.did is for legacy data, before March 2023 + const claimer = claim.party?.identifier || claim.party?.did; + const contactInfo = didInfo(claimer, activeDid, identifiers, contacts); + const polygon = claim.spatialUnit?.geo?.polygon || ""; + return ( + contactInfo + + " possesses [" + + polygon.substring(0, polygon.indexOf(" ")) + + "...]" + ); + } else { + return issuer + " declared " + claimSummary(claim as GenericServerRecord); + } +}; + +export const BVC_MEETUPS_PROJECT_CLAIM_ID = + //"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H"; + "https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; + +export const bvcMeetingJoinClaim = (did: string, startTime: string) => { + return { + "@context": SCHEMA_ORG_CONTEXT, + "@type": "JoinAction", + agent: { + identifier: did, + }, + event: { + organizer: { + name: "Bountiful Voluntaryist Community", + }, + name: "Saturday Morning Meeting", + startTime: startTime, + }, + }; +}; diff --git a/src/libs/util.ts b/src/libs/util.ts index 822cdaab..99c51ec1 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -1,14 +1,16 @@ // many of these are also found in endorser-mobile utility.ts import axios, { AxiosResponse } from "axios"; +import { IIdentifier } from "@veramo/core"; +import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; +import { Account } from "@/db/tables/accounts"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; import { GenericServerRecord, containsHiddenDid } from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer"; -import { useClipboard } from "@vueuse/core"; // eslint-disable-next-line @typescript-eslint/no-var-requires const Buffer = require("buffer/").Buffer; @@ -55,6 +57,16 @@ export function iconForUnitCode(unitCode: string) { return UNIT_CODES[unitCode]?.faIcon || "question"; } +// 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; +} + export const isGlobalUri = (uri: string) => { return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); }; @@ -180,6 +192,22 @@ export function findAllVisibleToDids( * **/ +export const getIdentity = async (activeDid: string): Promise => { + await accountsDB.open(); + const account = (await accountsDB.accounts + .where("did") + .equals(activeDid) + .first()) as Account; + const identity = JSON.parse(account?.identity || "null"); + + if (!identity) { + throw new Error( + `Attempted to load Offer records for DID ${activeDid} but no identifier was found`, + ); + } + return identity; +}; + /** * Generates a new identity, saves it to the database, and sets it as the active identity. * @return {Promise} with the DID of the new identity diff --git a/src/router/index.ts b/src/router/index.ts index 68da028f..b3c078df 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -165,6 +165,30 @@ const routes: Array = [ import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"), beforeEnter: enterOrStart, }, + { + path: "/quick-action-bvc", + name: "quick-action-bvc", + component: () => + import( + /* webpackChunkName: "quick-action-bvc" */ "../views/QuickActionBvcView.vue" + ), + }, + { + path: "/quick-action-bvc-begin", + name: "quick-action-bvc-begin", + component: () => + import( + /* webpackChunkName: "quick-action-bvc-begin" */ "../views/QuickActionBvcBeginView.vue" + ), + }, + { + path: "/quick-action-bvc-end", + name: "quick-action-bvc-end", + component: () => + import( + /* webpackChunkName: "quick-action-bvc-end" */ "../views/QuickActionBvcEndView.vue" + ), + }, { path: "/scan-contact", name: "scan-contact", diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 7f2a4473..343871b6 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -299,7 +299,7 @@