diff --git a/package-lock.json b/package-lock.json index 693dac6f..04d2b408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@ethersproject/hdnode": "^5.7.0", "@ethersproject/wallet": "^5.8.0", "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.6", "@jlongster/sql.js": "^1.6.7", @@ -90,6 +91,7 @@ "vue": "3.5.13", "vue-axios": "^3.5.2", "vue-facing-decorator": "3.0.4", + "vue-markdown-render": "^2.2.1", "vue-picture-cropper": "^0.7.0", "vue-qrcode-reader": "^5.5.3", "vue-router": "^4.5.0", @@ -106,6 +108,7 @@ "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.8", "@types/luxon": "^3.4.2", + "@types/markdown-it": "^14.1.2", "@types/node": "^20.14.11", "@types/node-fetch": "^2.6.12", "@types/ramda": "^0.29.11", @@ -6786,6 +6789,17 @@ "node": ">=6" } }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz", + "integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@fortawesome/free-solid-svg-icons": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", @@ -10147,6 +10161,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, "node_modules/@types/luxon": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", @@ -10154,6 +10174,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -32883,6 +32919,61 @@ "vue": "^3.0.0" } }, + "node_modules/vue-markdown-render": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.2.1.tgz", + "integrity": "sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==", + "dependencies": { + "markdown-it": "^13.0.2" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, + "node_modules/vue-markdown-render/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/vue-markdown-render/node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/vue-markdown-render/node_modules/markdown-it": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz", + "integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/vue-markdown-render/node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, + "node_modules/vue-markdown-render/node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "node_modules/vue-picture-cropper": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz", diff --git a/package.json b/package.json index 247a8044..a9587886 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,6 @@ "*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true", "*.{md,markdown,mdc}": "markdownlint-cli2 --fix" }, - "dependencies": { "@capacitor-community/electron": "^5.0.1", "@capacitor-community/sqlite": "6.0.2", @@ -157,6 +156,7 @@ "@ethersproject/hdnode": "^5.7.0", "@ethersproject/wallet": "^5.8.0", "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.6", "@jlongster/sql.js": "^1.6.7", @@ -220,6 +220,7 @@ "vue": "3.5.13", "vue-axios": "^3.5.2", "vue-facing-decorator": "3.0.4", + "vue-markdown-render": "^2.2.1", "vue-picture-cropper": "^0.7.0", "vue-qrcode-reader": "^5.5.3", "vue-router": "^4.5.0", @@ -236,6 +237,7 @@ "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.8", "@types/luxon": "^3.4.2", + "@types/markdown-it": "^14.1.2", "@types/node": "^20.14.11", "@types/node-fetch": "^2.6.12", "@types/ramda": "^0.29.11", diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css index f6457ff3..7785f095 100644 --- a/src/assets/styles/tailwind.css +++ b/src/assets/styles/tailwind.css @@ -22,4 +22,24 @@ .dialog { @apply bg-white p-4 rounded-lg w-full max-w-lg; } + + /* Markdown content styling to restore list elements */ + .markdown-content ul { + @apply list-disc list-inside ml-4; + } + + .markdown-content ol { + @apply list-decimal list-inside ml-4; + } + + .markdown-content li { + @apply mb-1; + } + + .markdown-content ul ul, + .markdown-content ol ol, + .markdown-content ul ol, + .markdown-content ol ul { + @apply ml-4 mt-1; + } } \ No newline at end of file diff --git a/src/components/ActivityListItem.vue b/src/components/ActivityListItem.vue index 39dfcffa..33b50d02 100644 --- a/src/components/ActivityListItem.vue +++ b/src/components/ActivityListItem.vue @@ -80,7 +80,10 @@

- {{ description }} +

@@ -258,11 +261,13 @@ import { NOTIFY_UNKNOWN_PERSON, } from "@/constants/notifications"; import { TIMEOUTS } from "@/utils/notify"; +import VueMarkdown from "vue-markdown-render"; @Component({ components: { EntityIcon, ProjectIcon, + VueMarkdown, }, }) export default class ActivityListItem extends Vue { @@ -303,6 +308,14 @@ export default class ActivityListItem extends Vue { return `${claim?.description || ""}`; } + get truncatedDescription(): string { + const desc = this.description; + if (desc.length <= 300) { + return desc; + } + return desc.substring(0, 300) + "..."; + } + private displayAmount(code: string, amt: number) { return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`; } diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index f686d155..ca5dad14 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -192,6 +192,13 @@ const MIGRATIONS = [ name: "004_active_identity_management", sql: MIG_004_SQL, }, + { + name: "005_add_starredPlanHandleIds_to_settings", + sql: ` + ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string + ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT; + `, + }, ]; /** diff --git a/src/db/databaseUtil.ts b/src/db/databaseUtil.ts index 85b7192f..18e7952b 100644 --- a/src/db/databaseUtil.ts +++ b/src/db/databaseUtil.ts @@ -157,10 +157,11 @@ export async function retrieveSettingsForDefaultAccount(): Promise { result.columns, result.values, )[0] as Settings; - if (settings.searchBoxes) { - // @ts-expect-error - the searchBoxes field is a string in the DB - settings.searchBoxes = JSON.parse(settings.searchBoxes); - } + settings.searchBoxes = parseJsonField(settings.searchBoxes, []); + settings.starredPlanHandleIds = parseJsonField( + settings.starredPlanHandleIds, + [], + ); return settings; } } @@ -226,10 +227,11 @@ export async function retrieveSettingsForActiveAccount(): Promise { ); } - // Handle searchBoxes parsing - if (settings.searchBoxes) { - settings.searchBoxes = parseJsonField(settings.searchBoxes, []); - } + settings.searchBoxes = parseJsonField(settings.searchBoxes, []); + settings.starredPlanHandleIds = parseJsonField( + settings.starredPlanHandleIds, + [], + ); return settings; } catch (error) { diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 4c00b46e..493e4596 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -43,6 +43,7 @@ export type Settings = { lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing + lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing // The claim list has a most recent one used in notifications that's separate from the last viewed lastNotifiedClaimId?: string; @@ -67,15 +68,18 @@ export type Settings = { showContactGivesInline?: boolean; // Display contact inline or not showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions + + starredPlanHandleIds?: string[]; // Array of starred plan handle IDs 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 webPushServer?: string; // Web Push server URL }; -// type of settings where the searchBoxes are JSON strings instead of objects +// type of settings where the values are JSON strings instead of objects export type SettingsWithJsonStrings = Settings & { searchBoxes: string; + starredPlanHandleIds: string; }; export function checkIsAnyFeedFilterOn(settings: Settings): boolean { @@ -92,6 +96,11 @@ export const SettingsSchema = { /** * Constants. */ + +/** + * This is deprecated. + * It only remains for those with a PWA who have not migrated, but we'll soon remove it. + */ export const MASTER_SETTINGS_KEY = "1"; export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; diff --git a/src/interfaces/claims.ts b/src/interfaces/claims.ts index c028858b..1fc03529 100644 --- a/src/interfaces/claims.ts +++ b/src/interfaces/claims.ts @@ -72,11 +72,15 @@ export interface PlanActionClaim extends ClaimObject { name: string; agent?: { identifier: string }; description?: string; + endTime?: string; identifier?: string; + image?: string; lastClaimId?: string; location?: { geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; }; + startTime?: string; + url?: string; } // AKA Registration & RegisterAction diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts index 7a884f0c..ca82624c 100644 --- a/src/interfaces/records.ts +++ b/src/interfaces/records.ts @@ -1,4 +1,5 @@ -import { GiveActionClaim, OfferClaim } from "./claims"; +import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; +import { GenericCredWrapper } from "./common"; // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { @@ -61,6 +62,11 @@ export interface PlanSummaryRecord { jwtId?: string; } +export interface PlanSummaryAndPreviousClaim { + plan: PlanSummaryRecord; + wrappedClaimBefore: GenericCredWrapper; +} + /** * Represents data about a project * @@ -87,7 +93,10 @@ export interface PlanData { name: string; /** * The identifier of the project record -- different from jwtId - * (Maybe we should use the jwtId to iterate through the records instead.) + * + * This has been used to iterate through plan records, because jwtId ordering doesn't match + * chronological create ordering, though it does match most recent edit order (in reverse order). + * (It may be worthwhile to order by jwtId instead. It is an indexed field.) **/ rowId?: string; } diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 30bb7316..320a6363 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -56,7 +56,12 @@ import { KeyMetaWithPrivate, KeyMetaMaybeWithPrivate, } from "../interfaces/common"; -import { PlanSummaryRecord } from "../interfaces/records"; +import { + OfferSummaryRecord, + OfferToPlanSummaryRecord, + PlanSummaryAndPreviousClaim, + PlanSummaryRecord, +} from "../interfaces/records"; import { logger } from "../utils/logger"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { APP_SERVER } from "@/constants/app"; @@ -362,6 +367,22 @@ export function didInfo( return didInfoForContact(did, activeDid, contact, allMyDids).displayName; } +/** + * In some contexts (eg. agent), a blank really is nobody. + */ +export function didInfoOrNobody( + did: string | undefined, + activeDid: string | undefined, + allMyDids: string[], + contacts: Contact[], +): string { + if (did == null) { + return "Nobody"; + } else { + return didInfo(did, activeDid, allMyDids, contacts); + } +} + /** * return text description without any references to "you" as user */ @@ -730,7 +751,7 @@ export async function getNewOffersToUser( activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string, -) { +): Promise<{ data: Array; hitLimit: boolean }> { let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`; if (afterOfferJwtId) { url += "&afterId=" + afterOfferJwtId; @@ -752,7 +773,7 @@ export async function getNewOffersToUserProjects( activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string, -) { +): Promise<{ data: Array; hitLimit: boolean }> { let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`; if (afterOfferJwtId) { url += "?afterId=" + afterOfferJwtId; @@ -766,6 +787,44 @@ export async function getNewOffersToUserProjects( return response.data; } +/** + * Get starred projects that have been updated since the last check + * + * @param axios - axios instance + * @param apiServer - endorser API server URL + * @param activeDid - user's DID for authentication + * @param starredPlanHandleIds - array of starred project handle IDs + * @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId) + * @returns { data: Array, hitLimit: boolean } + */ +export async function getStarredProjectsWithChanges( + axios: Axios, + apiServer: string, + activeDid: string, + starredPlanHandleIds: string[], + afterId?: string, +): Promise<{ data: Array; hitLimit: boolean }> { + if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) { + return { data: [], hitLimit: false }; + } + + if (!afterId) { + return { data: [], hitLimit: false }; + } + + // Use POST method for larger lists of project IDs + const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`; + const headers = await getHeaders(activeDid); + + const requestBody = { + planIds: starredPlanHandleIds, + afterId: afterId, + }; + + const response = await axios.post(url, requestBody, { headers }); + return response.data; +} + /** * Construct GiveAction VC for submission to server * diff --git a/src/libs/fontawesome.ts b/src/libs/fontawesome.ts index 30c745c7..efd8ff03 100644 --- a/src/libs/fontawesome.ts +++ b/src/libs/fontawesome.ts @@ -86,6 +86,7 @@ import { faSquareCaretDown, faSquareCaretUp, faSquarePlus, + faStar, faThumbtack, faTrashCan, faTriangleExclamation, @@ -94,6 +95,9 @@ import { faXmark, } from "@fortawesome/free-solid-svg-icons"; +// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue +import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons"; + // Initialize Font Awesome library with all required icons library.add( faArrowDown, @@ -168,14 +172,16 @@ library.add( faPlus, faQrcode, faQuestion, - faRotate, faRightFromBracket, + faRotate, faShareNodes, faSpinner, faSquare, faSquareCaretDown, faSquareCaretUp, faSquarePlus, + faStar, + faStarRegular, faThumbtack, faTrashCan, faTriangleExclamation, diff --git a/src/test/PlatformServiceMixinTest.vue b/src/test/PlatformServiceMixinTest.vue index 219c72cf..98f5325c 100644 --- a/src/test/PlatformServiceMixinTest.vue +++ b/src/test/PlatformServiceMixinTest.vue @@ -85,7 +85,6 @@ diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index 13aef08c..c54067be 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -27,10 +27,18 @@ > - @@ -58,13 +66,13 @@ icon="user" class="fa-fw text-slate-400" > - + {{ issuerInfoObject?.displayName }}
- {{ truncatedDesc }} + ... Read More
- {{ description }} + - Read Less @@ -592,7 +604,10 @@ diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index 9c5779b3..67923ba1 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -145,9 +145,10 @@ export async function generateNewEthrUser(page: Page): Promise { } // Function to generate a random string of specified length +// Note that this only generates up to 10 characters export async function generateRandomString(length: number): Promise { return Math.random() - .toString(36) + .toString(36) // base 36 only generates up to 10 characters .substring(2, 2 + length); } @@ -156,7 +157,7 @@ export async function createUniqueStringsArray( count: number ): Promise { const stringsArray: string[] = []; - const stringLength = 16; + const stringLength = 5; // max of 10; see generateRandomString for (let i = 0; i < count; i++) { let randomString = await generateRandomString(stringLength);