From da0621c09a3a473e07372530d21f21fb286203b5 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Wed, 9 Jul 2025 08:59:42 -0600 Subject: [PATCH 1/9] feat: Start the ability to star/bookmark a project. - Currently toggles & stores correctly locally on project. Does not show on other screens. --- package-lock.json | 12 ++++ package.json | 1 + src/db-sql/migration.ts | 6 ++ src/db/databaseUtil.ts | 15 +++-- src/db/tables/settings.ts | 5 ++ src/libs/fontawesome.ts | 8 ++- src/views/AccountViewView.vue | 5 +- src/views/ProjectViewView.vue | 114 +++++++++++++++++++++++++++++++--- 8 files changed, 145 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 693dac6f..b46584fc 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", @@ -6786,6 +6787,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", diff --git a/package.json b/package.json index 9ca11deb..03290fe0 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,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", diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 67944b75..66227b61 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -124,6 +124,12 @@ 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; + `, + }, ]; /** 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..784612aa 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -60,6 +60,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 IDs, which are recommended to be 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 +73,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/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..bfcd3000 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -1059,8 +1059,8 @@ export default class AccountViewView extends Vue { this.hideRegisterPromptOnNewContact = !!settings.hideRegisterPromptOnNewContact; this.isRegistered = !!settings?.isRegistered; - this.isSearchAreasSet = !!settings.searchBoxes; - this.searchBox = settings.searchBoxes?.[0] || null; + this.isSearchAreasSet = + !!settings.searchBoxes && settings.searchBoxes.length > 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/ProjectViewView.vue b/src/views/ProjectViewView.vue index 361c822f..99bb62ab 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 }} = []; imageUrl = ""; + /** Whether this project is starred by the user */ + isStarred = false; /** Project issuer DID */ issuer = ""; /** Cached issuer information */ @@ -805,6 +822,15 @@ export default class ProjectViewView extends Vue { } this.loadProject(this.projectId, this.activeDid); this.loadTotals(); + + // Check if this project is starred when settings are loaded + if (this.projectId && settings.starredProjectIds) { + const starredIds: string[] = databaseUtil.parseJsonField( + settings.starredProjectIds, + [], + ); + this.isStarred = starredIds.includes(this.projectId); + } } /** @@ -1470,5 +1496,73 @@ export default class ProjectViewView extends Vue { this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0 ); } + + /** + * Toggle the starred status of the current project + */ + async toggleStar() { + if (!this.projectId) return; + + try { + if (!this.isStarred) { + // Add to starred projects + const settings = await databaseUtil.retrieveSettingsForActiveAccount(); + const starredIds: string[] = databaseUtil.parseJsonField( + settings.starredProjectIds, + [], + ); + + if (!starredIds.includes(this.projectId)) { + const newStarredIds = [...starredIds, this.projectId]; + const newIdsParam = JSON.stringify(newStarredIds); + const result = await databaseUtil.updateDidSpecificSettings( + this.activeDid, + // @ts-expect-error until we use SettingsWithJsonString properly + { starredProjectIds: newIdsParam }, + ); + if (!result) { + // eslint-disable-next-line no-console + console.log( + "Still getting a bad result from SQL update to star a project.", + ); + } + } + this.isStarred = true; + } else { + // Remove from starred projects + const settings = await databaseUtil.retrieveSettingsForActiveAccount(); + const starredIds: string[] = databaseUtil.parseJsonField( + settings.starredProjectIds, + [], + ); + + const updatedIds = starredIds.filter((id) => id !== this.projectId); + const newIdsParam = JSON.stringify(updatedIds); + const result = await databaseUtil.updateDidSpecificSettings( + this.activeDid, + // @ts-expect-error until we use SettingsWithJsonString properly + { starredProjectIds: newIdsParam }, + ); + if (!result) { + // eslint-disable-next-line no-console + console.log( + "Still getting a bad result from SQL update to unstar a project.", + ); + } + this.isStarred = false; + } + } catch (error) { + logger.error("Error toggling star status:", error); + this.$notify( + { + group: "alert", + type: "danger", + title: "Error", + text: "Failed to update starred status. Please try again.", + }, + 3000, + ); + } + } } -- 2.30.2 From 24a7cf5eb681e30d94aabb39ef161ea88da472b2 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Fri, 29 Aug 2025 17:31:00 -0600 Subject: [PATCH 2/9] feat: add a notification for changes to starred projects --- src/db/tables/settings.ts | 3 +- src/interfaces/records.ts | 6 ++ src/libs/endorserServer.ts | 49 +++++++++++- src/views/HomeView.vue | 66 ++++++++++++++++ src/views/NewActivityView.vue | 142 ++++++++++++++++++++++++++++++++++ src/views/ProjectViewView.vue | 5 ++ 6 files changed, 267 insertions(+), 4 deletions(-) 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 -- 2.30.2 From 475f4d5ce583ccfd0715b97b8dc67e9b28c93940 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 30 Aug 2025 17:18:56 -0600 Subject: [PATCH 3/9] feat: add changed details for plans with recent changes (not all are accurate yet) --- src/db-sql/migration.ts | 1 + src/interfaces/claims.ts | 4 + src/interfaces/records.ts | 6 +- src/views/NewActivityView.vue | 399 +++++++++++++++++++++++++++++++++- test-playwright/testUtils.ts | 5 +- 5 files changed, 398 insertions(+), 17 deletions(-) diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 66227b61..2e00a93d 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -128,6 +128,7 @@ const MIGRATIONS = [ 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/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 e5e72c55..6c2cf3cf 100644 --- a/src/interfaces/records.ts +++ b/src/interfaces/records.ts @@ -1,5 +1,5 @@ -import { GiveActionClaim, OfferClaim } from "./claims"; -import { ClaimObject } from "./common"; +import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; +import { GenericCredWrapper } from "./common"; // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { @@ -64,7 +64,7 @@ export interface PlanSummaryRecord { export interface PlanSummaryAndPreviousClaim { plan: PlanSummaryRecord; - wrappedClaimBefore: ClaimObject; + wrappedClaimBefore: GenericCredWrapper; } /** diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index 43b90ab0..934c70d0 100644 --- a/src/views/NewActivityView.vue +++ b/src/views/NewActivityView.vue @@ -48,7 +48,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }}
offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -70,7 +70,7 @@ @click="markOffersAsReadStartingWith(offer.jwtId)" > - Click to keep all above as new offers + Click to keep all above as unread offers @@ -115,7 +115,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }} offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -139,7 +139,7 @@ @click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)" > - Click to keep all above as new offers + Click to keep all above as unread offers @@ -182,31 +182,90 @@ {{ projectChange.plan.name || "Unnamed Project" }} - + - {{ projectChange.plan.description }} + +
+
Changes
+
+ + + + + + + + + + + + + + + +
+ Previous + + Current +
+ {{ getDisplayFieldName(field) }} + + {{ formatFieldValue(difference.old) }} + + {{ formatFieldValue(difference.new) }} +
+
+
@@ -227,6 +286,7 @@ import { OfferSummaryRecord, OfferToPlanSummaryRecord, PlanSummaryAndPreviousClaim, + PlanSummaryRecord, } from "../interfaces/records"; import { didInfo, @@ -240,6 +300,9 @@ import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import * as databaseUtil from "../db/databaseUtil"; +import * as R from "ramda"; +import { PlanActionClaim } from "../interfaces/claims"; +import { GenericCredWrapper } from "@/interfaces"; @Component({ components: { GiftedDialog, QuickNav, EntityIcon }, @@ -264,6 +327,10 @@ export default class NewActivityView extends Vue { newStarredProjectChanges: Array = []; newStarredProjectChangesHitLimit = false; starredProjectIds: Array = []; + planDifferences: Record< + string, + Record + > = {}; showOffersDetails = false; showOffersToUserProjectsDetails = false; @@ -323,6 +390,9 @@ export default class NewActivityView extends Vue { this.newStarredProjectChanges = starredProjectChangesData.data; this.newStarredProjectChangesHitLimit = starredProjectChangesData.hitLimit; + + // Analyze differences between current plans and previous claims + this.analyzePlanDifferences(this.newStarredProjectChanges); } catch (error) { logger.warn("Failed to load starred project changes:", error); this.newStarredProjectChanges = []; @@ -349,7 +419,7 @@ export default class NewActivityView extends Vue { // note that we don't update this.lastAckedOfferToUserJwtId in case they // later choose the last one to keep the offers as new this.notify.info( - "The offers are marked as viewed. Click in the list to keep them as new.", + "The offers are marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -387,7 +457,7 @@ export default class NewActivityView extends Vue { // note that we don't update this.lastAckedOfferToUserProjectsJwtId in case // they later choose the last one to keep the offers as new this.notify.info( - "The offers are now marked as viewed. Click in the list to keep them as new.", + "The offers are now marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -428,7 +498,7 @@ export default class NewActivityView extends Vue { 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.", + "The starred project changes are now marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -456,5 +526,310 @@ export default class NewActivityView extends Vue { TIMEOUTS.STANDARD, ); } + + /** + * Normalizes values for comparison - treats null, undefined, and empty string as equivalent + * + * @param value The value to normalize + * @returns The normalized value (null for null/undefined/empty, otherwise the original value) + */ + normalizeValueForComparison(value: unknown): unknown { + if (value === null || value === undefined || value === "") { + return null; + } + return value; + } + + /** + * Analyzes differences between current plans and their previous claims + * + * Walks through a list of PlanSummaryAndPreviousClaim items and stores the + * differences between the previous claim and the current plan. This method + * extracts the claim from the wrappedClaimBefore object and compares relevant + * fields with the current plan. + * + * @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze + */ + analyzePlanDifferences(planChanges: Array) { + this.planDifferences = {}; + + for (const planChange of planChanges) { + console.log("planChange", planChange); + const currentPlan: PlanSummaryRecord = planChange.plan; + const wrappedClaim: GenericCredWrapper = + planChange.wrappedClaimBefore; + + // Extract the actual claim from the wrapped claim + let previousClaim: PlanActionClaim; + + const embeddedClaim: string = wrappedClaim.claim; + if ( + embeddedClaim && + typeof embeddedClaim === "object" && + "credentialSubject" in embeddedClaim + ) { + // It's a Verifiable Credential + previousClaim = + (embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim; + } else { + // It's a direct claim + previousClaim = embeddedClaim; + } + + if (!previousClaim || !currentPlan.handleId) { + continue; + } + + const differences: Record = {}; + + // Compare name + const normalizedOldName = this.normalizeValueForComparison( + previousClaim.name, + ); + const normalizedNewName = this.normalizeValueForComparison( + currentPlan.name, + ); + if (!R.equals(normalizedOldName, normalizedNewName)) { + differences.name = { + old: previousClaim.name, + new: currentPlan.name, + }; + } + + // Compare description + const normalizedOldDescription = this.normalizeValueForComparison( + previousClaim.description, + ); + const normalizedNewDescription = this.normalizeValueForComparison( + currentPlan.description, + ); + if (!R.equals(normalizedOldDescription, normalizedNewDescription)) { + differences.description = { + old: previousClaim.description, + new: currentPlan.description, + }; + } + + // Compare location (combine latitude and longitude into one row) + const oldLat = previousClaim.location?.geo?.latitude; + const oldLon = previousClaim.location?.geo?.longitude; + const newLat = currentPlan.locLat; + const newLon = currentPlan.locLon; + + if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) { + differences.location = { + old: this.formatLocationValue(oldLat, oldLon, true), + new: this.formatLocationValue(newLat, newLon, false), + }; + } + + // Compare agent (issuer) + const oldAgent = previousClaim.agent?.identifier; + const newAgent = currentPlan.agentDid; + const normalizedOldAgent = this.normalizeValueForComparison(oldAgent); + const normalizedNewAgent = this.normalizeValueForComparison(newAgent); + if (!R.equals(normalizedOldAgent, normalizedNewAgent)) { + differences.agent = { + old: oldAgent, + new: newAgent, + }; + } + + // Compare start time + const oldStartTime = previousClaim.startTime; + const newStartTime = currentPlan.startTime; + const normalizedOldStartTime = + this.normalizeValueForComparison(oldStartTime); + const normalizedNewStartTime = + this.normalizeValueForComparison(newStartTime); + if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) { + differences.startTime = { + old: oldStartTime, + new: newStartTime, + }; + } + + // Compare end time + const oldEndTime = previousClaim.endTime; + const newEndTime = currentPlan.endTime; + const normalizedOldEndTime = this.normalizeValueForComparison(oldEndTime); + const normalizedNewEndTime = this.normalizeValueForComparison(newEndTime); + if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) { + differences.endTime = { + old: oldEndTime, + new: newEndTime, + }; + } + + // Compare image + const oldImage = previousClaim.image; + const newImage = currentPlan.image; + const normalizedOldImage = this.normalizeValueForComparison(oldImage); + const normalizedNewImage = this.normalizeValueForComparison(newImage); + if (!R.equals(normalizedOldImage, normalizedNewImage)) { + differences.image = { + old: oldImage, + new: newImage, + }; + } + + // Compare url + const oldUrl = previousClaim.url; + const newUrl = currentPlan.url; + const normalizedOldUrl = this.normalizeValueForComparison(oldUrl); + const normalizedNewUrl = this.normalizeValueForComparison(newUrl); + if (!R.equals(normalizedOldUrl, normalizedNewUrl)) { + differences.url = { + old: oldUrl, + new: newUrl, + }; + } + + // Store differences if any were found + if (!R.isEmpty(differences)) { + this.planDifferences[currentPlan.handleId] = differences; + logger.debug( + "[NewActivityView] Plan differences found for", + currentPlan.handleId, + differences, + ); + } + } + + logger.debug( + "[NewActivityView] Analyzed", + planChanges.length, + "plan changes, found differences in", + Object.keys(this.planDifferences).length, + "plans", + ); + } + + /** + * Gets the differences for a specific plan by handle ID + * + * @param handleId The handle ID of the plan to get differences for + * @returns The differences object or null if no differences found + */ + getPlanDifferences( + handleId: string, + ): Record | null { + return this.planDifferences[handleId] || null; + } + + /** + * Formats a field value for display in the UI + * + * @param value The value to format + * @returns A human-readable string representation + */ + formatFieldValue(value: unknown): string { + if (value === null || value === undefined) { + return "Not set"; + } + if (typeof value === "string") { + const stringValue = value || "Empty"; + + // Check if it's a date/time string + if (this.isDateTimeString(stringValue)) { + return this.formatDateTime(stringValue); + } + + // Check if it's a URL + if (this.isUrl(stringValue)) { + return stringValue; // Keep URLs as-is for now + } + + return stringValue; + } + if (typeof value === "number") { + return value.toString(); + } + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + // For complex objects, stringify + const stringified = JSON.stringify(value); + return stringified; + } + + /** + * Checks if a string appears to be a date/time string + */ + isDateTimeString(value: string): boolean { + if (!value) return false; + // Check for ISO 8601 format or other common date formats + const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/; + return dateRegex.test(value) || !isNaN(Date.parse(value)); + } + + /** + * Checks if a string is a URL + */ + isUrl(value: string): boolean { + if (!value) return false; + try { + new URL(value); + return true; + } catch { + return false; + } + } + + /** + * Formats a date/time string for display + */ + formatDateTime(value: string): string { + try { + const date = new Date(value); + return date.toLocaleString(); + } catch { + return value; // Return original if parsing fails + } + } + + /** + * Gets a human-readable field name for display + * + * @param fieldName The internal field name + * @returns A formatted field name for display + */ + getDisplayFieldName(fieldName: string): string { + const fieldNameMap: Record = { + name: "Name", + description: "Description", + location: "Location", + agent: "Agent", + startTime: "Start Time", + endTime: "End Time", + image: "Image", + url: "URL", + }; + return fieldNameMap[fieldName] || fieldName; + } + + /** + * Formats location values for display + * + * @param latitude The latitude value + * @param longitude The longitude value + * @param isOldValue Whether this is the old value (true) or new value (false) + * @returns A formatted location string + */ + formatLocationValue( + latitude: number | undefined, + longitude: number | undefined, + isOldValue: boolean = false, + ): string { + if (latitude === undefined && longitude === undefined) { + return "Not set"; + } + // If there's any location data, show generic labels instead of coordinates + if (isOldValue) { + return "A Location"; + } else { + return "New Location"; + } + } } 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); -- 2.30.2 From 7da6f722f5605448b9ca4555c481263b5e613761 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 30 Aug 2025 21:30:03 -0600 Subject: [PATCH 4/9] fix: fix remaining problems with recent plan changes --- src/libs/endorserServer.ts | 16 +++++ src/views/NewActivityView.vue | 108 ++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 378373a6..5e4815bc 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -367,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 */ diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index 934c70d0..af5c89be 100644 --- a/src/views/NewActivityView.vue +++ b/src/views/NewActivityView.vue @@ -255,6 +255,7 @@ +
The changes did not affect essential project data.