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 9ca11deb..5ed49dbe 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 67944b75..2e00a93d 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -124,6 +124,13 @@ const MIGRATIONS = [ ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; `, }, + { + name: "003_add_starredProjectIds_to_settings", + sql: ` + ALTER TABLE settings ADD COLUMN starredProjectIds TEXT; + ALTER TABLE settings ADD COLUMN lastAckedStarredProjectChangesJwtId TEXT; + `, + }, ]; /** diff --git a/src/db/databaseUtil.ts b/src/db/databaseUtil.ts index 9b96475d..458d4e88 100644 --- a/src/db/databaseUtil.ts +++ b/src/db/databaseUtil.ts @@ -157,10 +157,8 @@ 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.starredProjectIds = parseJsonField(settings.starredProjectIds, []); return settings; } } @@ -226,10 +224,11 @@ export async function retrieveSettingsForActiveAccount(): Promise { ); } - // Handle searchBoxes parsing - if (settings.searchBoxes) { - settings.searchBoxes = parseJsonField(settings.searchBoxes, []); - } + settings.searchBoxes = parseJsonField(settings.searchBoxes, []); + settings.starredProjectIds = parseJsonField( + settings.starredProjectIds, + [], + ); return settings; } catch (error) { diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 0b86e355..c81b6786 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -36,6 +36,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 + lastAckedStarredProjectChangesJwtId?: string; // the last JWT ID for starred project 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; @@ -60,6 +61,10 @@ 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 + + // List of starred project handleIds + starredProjectIds?: Array; + 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 @@ -69,6 +74,7 @@ export type Settings = { // type of settings where the searchBoxes are JSON strings instead of objects export type SettingsWithJsonStrings = Settings & { searchBoxes: string; + starredProjectIds: string; }; export function checkIsAnyFeedFilterOn(settings: Settings): boolean { 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..6c2cf3cf 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 * diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 667083bf..5e4815bc 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 starredProjectIds - array of starred project handle IDs + * @param afterId - JWT ID to check for changes after (from lastAckedStarredProjectChangesJwtId) + * @returns { data: Array, hitLimit: boolean } + */ +export async function getStarredProjectsWithChanges( + axios: Axios, + apiServer: string, + activeDid: string, + starredProjectIds: string[], + afterId?: string, +): Promise<{ data: Array; hitLimit: boolean }> { + if (!starredProjectIds || starredProjectIds.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: starredProjectIds, + 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/views/AccountViewView.vue b/src/views/AccountViewView.vue index e68efca2..cb8860f0 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -150,8 +150,6 @@ - -
Saving...
+ + 0; this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; this.notifyingReminder = !!settings.notifyingReminderTime; @@ -1074,6 +1074,7 @@ export default class AccountViewView extends Vue { this.passkeyExpirationMinutes = settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES; this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes; + this.searchBox = settings.searchBoxes?.[0] || null; this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.showShortcutBvc = !!settings.showShortcutBvc; this.warnIfProdServer = !!settings.warnIfProdServer; diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index f594dc9b..756468c4 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -79,7 +79,10 @@
- {{ claimDescription }} +
@@ -515,8 +518,10 @@ import { AxiosError } from "axios"; import * as yaml from "js-yaml"; import * as R from "ramda"; import { Component, Vue } from "vue-facing-decorator"; +import VueMarkdown from "vue-markdown-render"; import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { useClipboard } from "@vueuse/core"; + import { GenericVerifiableCredential } from "../interfaces"; import GiftedDialog from "../components/GiftedDialog.vue"; import QuickNav from "../components/QuickNav.vue"; @@ -535,7 +540,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { APP_SERVER } from "@/constants/app"; @Component({ - components: { GiftedDialog, QuickNav }, + components: { GiftedDialog, QuickNav, VueMarkdown }, mixins: [PlatformServiceMixin], }) export default class ClaimView extends Vue { diff --git a/src/views/DiscoverView.vue b/src/views/DiscoverView.vue index 2a8879e0..31ab4e51 100644 --- a/src/views/DiscoverView.vue +++ b/src/views/DiscoverView.vue @@ -51,6 +51,23 @@ @@ -329,8 +356,14 @@ import { import { Contact } from "../db/tables/contacts"; import { BoundingBox } from "../db/tables/settings"; import { PlanData } from "../interfaces"; -import { didInfo, errorStringForLog, getHeaders } from "../libs/endorserServer"; +import { + didInfo, + errorStringForLog, + getHeaders, + getPlanFromCache, +} from "../libs/endorserServer"; import { OnboardPage, retrieveAccountDids } from "../libs/util"; +import { parseJsonField } from "../db/databaseUtil"; import { logger } from "../utils/logger"; import { UserProfile } from "@/libs/partnerServer"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; @@ -386,6 +419,7 @@ export default class DiscoverView extends Vue { isLocalActive = false; isMappedActive = false; isAnywhereActive = true; + isStarredActive = false; isProjectsActive = true; isPeopleActive = false; isSearchVisible = true; @@ -470,6 +504,8 @@ export default class DiscoverView extends Vue { leafletObject: L.Map; }; this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation + } else if (this.isStarredActive) { + await this.loadStarred(); } else { await this.searchAll(); } @@ -540,6 +576,75 @@ export default class DiscoverView extends Vue { } } + public async loadStarred() { + this.resetCounts(); + + // Clear any previous results + this.projects = []; + this.userProfiles = []; + + try { + this.isLoading = true; + + // Get starred project IDs from settings + const settings = await this.$accountSettings(); + const starredIds: string[] = parseJsonField( + settings.starredProjectIds, + [], + ); + + if (starredIds.length === 0) { + // No starred projects + return; + } + + // Load each starred project using getPlanFromCache + const projectPromises = starredIds.map(async (handleId) => { + try { + const project = await getPlanFromCache( + handleId, + this.axios, + this.apiServer, + this.activeDid, + ); + if (project) { + // Convert PlanSummaryRecord to PlanData + return { + description: project.description, + handleId: project.handleId, + image: project.image, + issuerDid: project.issuerDid, + name: project.name || UNNAMED_PROJECT, + rowId: project.jwtId, + } as PlanData; + } + return null; + } catch (error) { + logger.warn(`Failed to load starred project ${handleId}:`, error); + return null; + } + }); + + const projects = await Promise.all(projectPromises); + + // Filter out null results and add to projects array + const validProjects = projects.filter( + (project): project is PlanData => + project !== null && project !== undefined, + ); + + this.projects = validProjects; + } catch (error: unknown) { + logger.error("Error loading starred projects:", error); + this.notify.error( + "Failed to load starred projects. Please try again.", + TIMEOUTS.LONG, + ); + } finally { + this.isLoading = false; + } + } + public async searchLocal(beforeId?: string) { this.resetCounts(); @@ -636,6 +741,7 @@ export default class DiscoverView extends Vue { } else if (this.isAnywhereActive) { this.searchAll(latestProject.rowId); } + // Note: Starred tab doesn't support pagination since we load all starred projects at once } else if (this.isPeopleActive && this.userProfiles.length > 0) { const latestProfile = this.userProfiles[this.userProfiles.length - 1]; if (this.isLocalActive || this.isMappedActive) { @@ -829,6 +935,24 @@ export default class DiscoverView extends Vue { }; } + public computedStarredTabStyleClassNames() { + return { + "inline-block": true, + "py-3": true, + "rounded-t-lg": true, + "border-b-2": true, + + active: this.isStarredActive, + "text-black": this.isStarredActive, + "border-black": this.isStarredActive, + "font-semibold": this.isStarredActive, + + "text-blue-600": !this.isStarredActive, + "border-transparent": !this.isStarredActive, + "hover:border-slate-400": !this.isStarredActive, + }; + } + public computedProjectsTabStyleClassNames() { return { "inline-block": true, diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 45a5d5bb..262437bb 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -170,10 +170,10 @@ Raymer * @version 1.0.0 */ class="border-t p-2 border-slate-300" @click="goToActivityToUserPage()" > -
+
+
+ + {{ numNewStarredProjectChanges + }}{{ newStarredProjectChangesHitLimit ? "+" : "" }} + +

+ favorite project{{ numNewStarredProjectChanges === 1 ? "" : "s" }} + with changes +

+
@@ -268,6 +284,7 @@ import { getHeaders, getNewOffersToUser, getNewOffersToUserProjects, + getStarredProjectsWithChanges, getPlanFromCache, } from "../libs/endorserServer"; import { @@ -283,6 +300,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications"; import * as Package from "../../package.json"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; +import * as databaseUtil from "../db/databaseUtil"; // consolidate this with GiveActionClaim in src/interfaces/claims.ts interface Claim { @@ -395,10 +413,14 @@ export default class HomeView extends Vue { isRegistered = false; 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 + lastAckedStarredProjectChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing newOffersToUserHitLimit: boolean = false; newOffersToUserProjectsHitLimit: boolean = false; + newStarredProjectChangesHitLimit: boolean = false; numNewOffersToUser: number = 0; // number of new offers-to-user numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects + numNewStarredProjectChanges: number = 0; // number of new starred project changes + starredProjectIds: Array = []; // list of starred project IDs searchBoxes: Array<{ name: string; bbox: BoundingBox; @@ -438,6 +460,7 @@ export default class HomeView extends Vue { // Registration check already handled in initializeIdentity() await this.loadFeedData(); await this.loadNewOffers(); + await this.loadNewStarredProjectChanges(); await this.checkOnboarding(); } catch (err: unknown) { this.handleError(err); @@ -542,8 +565,14 @@ export default class HomeView extends Vue { this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId; this.lastAckedOfferToUserProjectsJwtId = settings.lastAckedOfferToUserProjectsJwtId; + this.lastAckedStarredProjectChangesJwtId = + settings.lastAckedStarredProjectChangesJwtId; this.searchBoxes = settings.searchBoxes || []; this.showShortcutBvc = !!settings.showShortcutBvc; + this.starredProjectIds = databaseUtil.parseJsonField( + settings.starredProjectIds, + [], + ); this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings); // Check onboarding status @@ -675,6 +704,43 @@ export default class HomeView extends Vue { } } + /** + * Loads new changes for starred projects + * Updates: + * - Number of new starred project changes + * - Rate limit status for starred project changes + * + * @internal + * Called by mounted() and initializeIdentity() + * @requires Active DID + */ + private async loadNewStarredProjectChanges() { + if (this.activeDid && this.starredProjectIds.length > 0) { + try { + const starredProjectChanges = await getStarredProjectsWithChanges( + this.axios, + this.apiServer, + this.activeDid, + this.starredProjectIds, + this.lastAckedStarredProjectChangesJwtId, + ); + this.numNewStarredProjectChanges = starredProjectChanges.data.length; + this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit; + } catch (error) { + // Don't show errors for starred project changes as it's a secondary feature + logger.warn( + "[HomeView] Failed to load starred project changes:", + error, + ); + this.numNewStarredProjectChanges = 0; + this.newStarredProjectChangesHitLimit = false; + } + } else { + this.numNewStarredProjectChanges = 0; + this.newStarredProjectChangesHitLimit = false; + } + } + /** * Checks if user needs onboarding using ultra-concise mixin utilities * Opens onboarding dialog if not completed diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index fbcd7423..459bc4aa 100644 --- a/src/views/NewActivityView.vue +++ b/src/views/NewActivityView.vue @@ -29,7 +29,7 @@ v-if="newOffersToUser.length > 0" :icon="showOffersDetails ? 'chevron-down' : 'chevron-right'" class="cursor-pointer ml-4 mr-4 text-lg" - @click="expandOffersToUserAndMarkRead()" + @click.prevent="expandOffersToUserAndMarkRead()" />
@@ -48,7 +48,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }} offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -67,10 +67,10 @@ @@ -96,7 +96,7 @@ showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right' " class="cursor-pointer ml-4 mr-4 text-lg" - @click="expandOffersToUserProjectsAndMarkRead()" + @click.prevent="expandOffersToUserProjectsAndMarkRead()" />
@@ -115,7 +115,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }} offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -136,10 +136,153 @@ + + +
+ + +
+
+ {{ newStarredProjectChanges.length + }}{{ newStarredProjectChangesHitLimit ? "+" : "" }} + Favorite Project{{ + newStarredProjectChanges.length === 1 ? "" : "s" + }} + With Changes + +
+
+ +
+
    +
  • +
    +
    + {{ + projectChange.plan.name || "Unnamed Project" + }} + + {{ projectChange.plan.description }} + +
    + + + +
    + +
    +
    Changes
    +
    + + + + + + + + + + + + + + + +
    + Previous + + Current +
    + {{ getDisplayFieldName(field) }} + + + {{ formatFieldValue(difference.old) }} + + + {{ formatFieldValue(difference.new) }} +
    +
    +
    +
    The changes did not affect essential project data.
    + +
@@ -149,6 +292,7 @@ diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index 361c822f..afb483f5 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -33,6 +33,20 @@ class="text-sm text-slate-500 ml-2 mb-1" /> +
@@ -58,13 +72,13 @@ icon="user" class="fa-fw text-slate-400" > - + {{ issuerInfoObject?.displayName }}
- {{ description }} + - Read Less @@ -592,7 +610,10 @@ diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index c82be500..59053b2d 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -169,9 +169,10 @@ export async function generateAndRegisterEthrUser(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); } @@ -180,7 +181,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);