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 @@
-
-
+
+
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
+
+
View All New Activity For You
@@ -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 @@
- Click to keep all above as new offers
+ Click to keep all above as unread offers
@@ -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 @@
- Click to keep all above as new offers
+ Click to keep all above as unread offers
+
+
+
+
+
+
+
+
+ {{ newStarredProjectChanges.length
+ }}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
+ Favorite Project{{
+ newStarredProjectChanges.length === 1 ? "" : "s"
+ }}
+ With Changes
+
+
+
+
+
+
@@ -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);