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
+
+
+
+
+
@@ -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) }}
+ |
+
+
+
+
+
- Click to keep all above as new changes
+ Click to keep all above as unread changes
@@ -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.
0
) {
- await this.$updateSettings({
+ await this.$saveUserSettings(this.activeDid, {
lastAckedStarredProjectChangesJwtId:
this.newStarredProjectChanges[0].plan.jwtId,
});
@@ -510,13 +512,13 @@ export default class NewActivityView extends Vue {
);
if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
// Set to the next change's jwtId
- await this.$updateSettings({
+ await this.$saveUserSettings(this.activeDid, {
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({
+ await this.$saveUserSettings(this.activeDid, {
lastAckedStarredProjectChangesJwtId:
this.lastAckedStarredProjectChangesJwtId,
});
@@ -527,19 +529,6 @@ export default class NewActivityView extends Vue {
);
}
- /**
- * 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
*
@@ -554,7 +543,6 @@ export default class NewActivityView extends Vue {
this.planDifferences = {};
for (const planChange of planChanges) {
- console.log("planChange", planChange);
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper
=
planChange.wrappedClaimBefore;
@@ -562,7 +550,7 @@ export default class NewActivityView extends Vue {
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
- const embeddedClaim: string = wrappedClaim.claim;
+ const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -611,10 +599,14 @@ export default class NewActivityView extends Vue {
}
// 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;
+ const oldLat = this.normalizeValueForComparison(
+ previousClaim.location?.geo?.latitude,
+ );
+ const oldLon = this.normalizeValueForComparison(
+ previousClaim.location?.geo?.longitude,
+ );
+ const newLat = this.normalizeValueForComparison(currentPlan.locLat);
+ const newLon = this.normalizeValueForComparison(currentPlan.locLon);
if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) {
differences.location = {
@@ -624,8 +616,18 @@ export default class NewActivityView extends Vue {
}
// Compare agent (issuer)
- const oldAgent = previousClaim.agent?.identifier;
- const newAgent = currentPlan.agentDid;
+ const oldAgent = didInfoOrNobody(
+ previousClaim.agent?.identifier,
+ this.activeDid,
+ this.allMyDids,
+ this.allContacts,
+ );
+ const newAgent = didInfoOrNobody(
+ currentPlan.agentDid,
+ this.activeDid,
+ this.allMyDids,
+ this.allContacts,
+ );
const normalizedOldAgent = this.normalizeValueForComparison(oldAgent);
const normalizedNewAgent = this.normalizeValueForComparison(newAgent);
if (!R.equals(normalizedOldAgent, normalizedNewAgent)) {
@@ -639,9 +641,9 @@ export default class NewActivityView extends Vue {
const oldStartTime = previousClaim.startTime;
const newStartTime = currentPlan.startTime;
const normalizedOldStartTime =
- this.normalizeValueForComparison(oldStartTime);
+ this.normalizeDateForComparison(oldStartTime);
const normalizedNewStartTime =
- this.normalizeValueForComparison(newStartTime);
+ this.normalizeDateForComparison(newStartTime);
if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) {
differences.startTime = {
old: oldStartTime,
@@ -652,8 +654,8 @@ export default class NewActivityView extends Vue {
// Compare end time
const oldEndTime = previousClaim.endTime;
const newEndTime = currentPlan.endTime;
- const normalizedOldEndTime = this.normalizeValueForComparison(oldEndTime);
- const normalizedNewEndTime = this.normalizeValueForComparison(newEndTime);
+ const normalizedOldEndTime = this.normalizeDateForComparison(oldEndTime);
+ const normalizedNewEndTime = this.normalizeDateForComparison(newEndTime);
if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) {
differences.endTime = {
old: oldEndTime,
@@ -705,6 +707,38 @@ export default class NewActivityView extends Vue {
);
}
+ /**
+ * 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: T | null | undefined): T | null {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+ return value;
+ }
+
+ /**
+ * Normalizes date values for comparison by converting strings to Date objects
+ * Returns null for null/undefined/empty values, Date objects for valid date strings
+ */
+ normalizeDateForComparison(value: unknown): Date | null {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+ if (typeof value === "string") {
+ const date = new Date(value);
+ // Check if the date is valid
+ return isNaN(date.getTime()) ? null : date;
+ }
+ if (value instanceof Date) {
+ return isNaN(value.getTime()) ? null : value;
+ }
+ return null;
+ }
+
/**
* Gets the differences for a specific plan by handle ID
*
@@ -817,11 +851,11 @@ export default class NewActivityView extends Vue {
* @returns A formatted location string
*/
formatLocationValue(
- latitude: number | undefined,
- longitude: number | undefined,
+ latitude: number | undefined | null,
+ longitude: number | undefined | null,
isOldValue: boolean = false,
): string {
- if (latitude === undefined && longitude === undefined) {
+ if (latitude == null && longitude == null) {
return "Not set";
}
// If there's any location data, show generic labels instead of coordinates
--
2.30.2
From 2bb733a9ea715db0b47a8dd3bbc5c545abb0bce3 Mon Sep 17 00:00:00 2001
From: Trent Larson
Date: Sun, 31 Aug 2025 07:46:10 -0600
Subject: [PATCH 5/9] feat: make each of the "new" buttons on the home page the
same size
---
src/views/HomeView.vue | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index ea45fdfa..f2f2e9bb 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()"
>
-
+
Date: Sun, 31 Aug 2025 08:21:25 -0600
Subject: [PATCH 6/9] feat: move the user profile up on page, reword "star" to
"favorite"
---
src/views/AccountViewView.vue | 4 ++--
src/views/HomeView.vue | 2 +-
src/views/NewActivityView.vue | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue
index bfcd3000..cb8860f0 100644
--- a/src/views/AccountViewView.vue
+++ b/src/views/AccountViewView.vue
@@ -150,8 +150,6 @@
-
-
+
+
- starred project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
+ favorite project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue
index af5c89be..951be001 100644
--- a/src/views/NewActivityView.vue
+++ b/src/views/NewActivityView.vue
@@ -156,7 +156,7 @@
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
Starred Project{{
+ >Favorite Project{{
newStarredProjectChanges.length === 1 ? "" : "s"
}}
With Changes
Date: Mon, 1 Sep 2025 14:53:39 -0600
Subject: [PATCH 7/9] fix: on project changes, truncate the description
properly (to avoid screen zooming in) and widen the table
---
src/views/NewActivityView.vue | 60 +++++++++++++++++++----------------
1 file changed, 32 insertions(+), 28 deletions(-)
diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue
index 951be001..f214a3ea 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()"
/>
@@ -67,7 +67,7 @@
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()"
/>
@@ -136,7 +136,7 @@
Click to keep all above as unread offers
@@ -167,7 +167,7 @@
showStarredProjectChangesDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
- @click="expandStarredProjectChangesAndMarkRead()"
+ @click.prevent="expandStarredProjectChangesAndMarkRead()"
/>
@@ -179,27 +179,31 @@
:key="projectChange.plan.handleId"
class="mt-4 relative group"
>
-
{{
- projectChange.plan.name || "Unnamed Project"
- }}
-
- - {{ projectChange.plan.description }}
-
-
-
-
+
+
+ {{
+ projectChange.plan.name || "Unnamed Project"
+ }}
+
+ {{ projectChange.plan.description }}
+
+
+
+
+
+
{{ getDisplayFieldName(field) }}
|
@@ -259,7 +263,7 @@
Date: Mon, 1 Sep 2025 18:40:35 -0600
Subject: [PATCH 8/9] feat: alloww markdown in the descriptions and render them
appropriately
---
package-lock.json | 79 +++++++++++++++++++++++++++++
package.json | 3 +-
src/assets/styles/tailwind.css | 20 ++++++++
src/components/ActivityListItem.vue | 15 +++++-
src/views/ClaimView.vue | 9 +++-
src/views/NewActivityView.vue | 25 ++++++---
src/views/ProjectViewView.vue | 16 ++++--
7 files changed, 152 insertions(+), 15 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b46584fc..04d2b408 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -91,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",
@@ -107,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",
@@ -10159,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",
@@ -10166,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",
@@ -32895,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 03290fe0..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",
@@ -221,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",
@@ -237,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/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/NewActivityView.vue b/src/views/NewActivityView.vue
index f214a3ea..459bc4aa 100644
--- a/src/views/NewActivityView.vue
+++ b/src/views/NewActivityView.vue
@@ -136,7 +136,9 @@
Click to keep all above as unread offers
@@ -245,14 +247,24 @@
{{ getDisplayFieldName(field) }}
- {{ formatFieldValue(difference.old) }}
+
+ {{ formatFieldValue(difference.old) }}
|
- {{ formatFieldValue(difference.new) }}
+
+ {{ formatFieldValue(difference.new) }}
|
@@ -280,6 +292,7 @@