diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts
index 784612aa..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;
@@ -61,7 +62,7 @@ export type Settings = {
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 IDs, which are recommended to be handleIds
+ // List of starred project handleIds
starredProjectIds?: Array;
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts
index 7a884f0c..e5e72c55 100644
--- a/src/interfaces/records.ts
+++ b/src/interfaces/records.ts
@@ -1,4 +1,5 @@
import { GiveActionClaim, OfferClaim } from "./claims";
+import { ClaimObject } 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: ClaimObject;
+}
+
/**
* Represents data about a project
*
diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts
index 667083bf..378373a6 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";
@@ -730,7 +735,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 +757,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 +771,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/views/HomeView.vue b/src/views/HomeView.vue
index 45a5d5bb..ea45fdfa 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -201,6 +201,22 @@ Raymer * @version 1.0.0 */
projects
+
+
+ {{ numNewStarredProjectChanges
+ }}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
+
+
+ starred 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..43b90ab0 100644
--- a/src/views/NewActivityView.vue
+++ b/src/views/NewActivityView.vue
@@ -144,6 +144,73 @@
+
+
+
+
+ {{ newStarredProjectChanges.length
+ }}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
+ Starred Project{{
+ newStarredProjectChanges.length === 1 ? "" : "s"
+ }}
+ With Changes
+
+
+
+
+
@@ -159,17 +226,20 @@ import { Router } from "vue-router";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
+ PlanSummaryAndPreviousClaim,
} from "../interfaces/records";
import {
didInfo,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
+ getStarredProjectsWithChanges,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
+import * as databaseUtil from "../db/databaseUtil";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
@@ -186,13 +256,18 @@ export default class NewActivityView extends Vue {
apiServer = "";
lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = "";
+ lastAckedStarredProjectChangesJwtId = "";
newOffersToUser: Array = [];
newOffersToUserHitLimit = false;
newOffersToUserProjects: Array = [];
newOffersToUserProjectsHitLimit = false;
+ newStarredProjectChanges: Array = [];
+ newStarredProjectChangesHitLimit = false;
+ starredProjectIds: Array = [];
showOffersDetails = false;
showOffersToUserProjectsDetails = false;
+ showStarredProjectChangesDetails = false;
didInfo = didInfo;
displayAmount = displayAmount;
@@ -206,6 +281,12 @@ export default class NewActivityView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
+ this.lastAckedStarredProjectChangesJwtId =
+ settings.lastAckedStarredProjectChangesJwtId || "";
+ this.starredProjectIds = databaseUtil.parseJsonField(
+ settings.starredProjectIds,
+ [],
+ );
this.allContacts = await this.$getAllContacts();
@@ -229,6 +310,26 @@ export default class NewActivityView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
+ // Load starred project changes if user has starred projects
+ if (this.starredProjectIds.length > 0) {
+ try {
+ const starredProjectChangesData = await getStarredProjectsWithChanges(
+ this.axios,
+ this.apiServer,
+ this.activeDid,
+ this.starredProjectIds,
+ this.lastAckedStarredProjectChangesJwtId,
+ );
+ this.newStarredProjectChanges = starredProjectChangesData.data;
+ this.newStarredProjectChangesHitLimit =
+ starredProjectChangesData.hitLimit;
+ } catch (error) {
+ logger.warn("Failed to load starred project changes:", error);
+ this.newStarredProjectChanges = [];
+ this.newStarredProjectChangesHitLimit = false;
+ }
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings & contacts:", err);
@@ -314,5 +415,46 @@ export default class NewActivityView extends Vue {
TIMEOUTS.STANDARD,
);
}
+
+ async expandStarredProjectChangesAndMarkRead() {
+ this.showStarredProjectChangesDetails =
+ !this.showStarredProjectChangesDetails;
+ if (
+ this.showStarredProjectChangesDetails &&
+ this.newStarredProjectChanges.length > 0
+ ) {
+ await this.$updateSettings({
+ lastAckedStarredProjectChangesJwtId:
+ this.newStarredProjectChanges[0].plan.jwtId,
+ });
+ this.notify.info(
+ "The starred project changes are now marked as viewed. Click in the list to keep them as new.",
+ TIMEOUTS.LONG,
+ );
+ }
+ }
+
+ async markStarredProjectChangesAsReadStartingWith(jwtId: string) {
+ const index = this.newStarredProjectChanges.findIndex(
+ (change) => change.plan.jwtId === jwtId,
+ );
+ if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
+ // Set to the next change's jwtId
+ await this.$updateSettings({
+ lastAckedStarredProjectChangesJwtId:
+ this.newStarredProjectChanges[index + 1].plan.jwtId,
+ });
+ } else {
+ // it's the last entry (or not found), so just keep it the same
+ await this.$updateSettings({
+ lastAckedStarredProjectChangesJwtId:
+ this.lastAckedStarredProjectChangesJwtId,
+ });
+ }
+ this.notify.info(
+ "All starred project changes above that line are marked as unread.",
+ TIMEOUTS.STANDARD,
+ );
+ }
}
diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue
index 99bb62ab..e68c4d08 100644
--- a/src/views/ProjectViewView.vue
+++ b/src/views/ProjectViewView.vue
@@ -1527,6 +1527,11 @@ export default class ProjectViewView extends Vue {
);
}
}
+ if (!settings.lastAckedStarredProjectChangesJwtId) {
+ await databaseUtil.updateDidSpecificSettings(this.activeDid, {
+ lastAckedStarredProjectChangesJwtId: settings.lastViewedClaimId,
+ });
+ }
this.isStarred = true;
} else {
// Remove from starred projects