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 + +
+
+ +
+
    +
  • + {{ + projectChange.plan.name || "Unnamed Project" + }} + + - {{ projectChange.plan.description }} + + + + + + +
  • +
+
@@ -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