diff --git a/package-lock.json b/package-lock.json index 693dac6f..04d2b408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@ethersproject/hdnode": "^5.7.0", "@ethersproject/wallet": "^5.8.0", "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.6", "@jlongster/sql.js": "^1.6.7", @@ -90,6 +91,7 @@ "vue": "3.5.13", "vue-axios": "^3.5.2", "vue-facing-decorator": "3.0.4", + "vue-markdown-render": "^2.2.1", "vue-picture-cropper": "^0.7.0", "vue-qrcode-reader": "^5.5.3", "vue-router": "^4.5.0", @@ -106,6 +108,7 @@ "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.8", "@types/luxon": "^3.4.2", + "@types/markdown-it": "^14.1.2", "@types/node": "^20.14.11", "@types/node-fetch": "^2.6.12", "@types/ramda": "^0.29.11", @@ -6786,6 +6789,17 @@ "node": ">=6" } }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz", + "integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@fortawesome/free-solid-svg-icons": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", @@ -10147,6 +10161,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, "node_modules/@types/luxon": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", @@ -10154,6 +10174,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -32883,6 +32919,61 @@ "vue": "^3.0.0" } }, + "node_modules/vue-markdown-render": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.2.1.tgz", + "integrity": "sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==", + "dependencies": { + "markdown-it": "^13.0.2" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, + "node_modules/vue-markdown-render/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/vue-markdown-render/node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/vue-markdown-render/node_modules/markdown-it": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz", + "integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/vue-markdown-render/node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, + "node_modules/vue-markdown-render/node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "node_modules/vue-picture-cropper": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz", diff --git a/package.json b/package.json index 247a8044..a9587886 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,6 @@ "*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true", "*.{md,markdown,mdc}": "markdownlint-cli2 --fix" }, - "dependencies": { "@capacitor-community/electron": "^5.0.1", "@capacitor-community/sqlite": "6.0.2", @@ -157,6 +156,7 @@ "@ethersproject/hdnode": "^5.7.0", "@ethersproject/wallet": "^5.8.0", "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.6", "@jlongster/sql.js": "^1.6.7", @@ -220,6 +220,7 @@ "vue": "3.5.13", "vue-axios": "^3.5.2", "vue-facing-decorator": "3.0.4", + "vue-markdown-render": "^2.2.1", "vue-picture-cropper": "^0.7.0", "vue-qrcode-reader": "^5.5.3", "vue-router": "^4.5.0", @@ -236,6 +237,7 @@ "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.8", "@types/luxon": "^3.4.2", + "@types/markdown-it": "^14.1.2", "@types/node": "^20.14.11", "@types/node-fetch": "^2.6.12", "@types/ramda": "^0.29.11", diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css index f6457ff3..7785f095 100644 --- a/src/assets/styles/tailwind.css +++ b/src/assets/styles/tailwind.css @@ -22,4 +22,24 @@ .dialog { @apply bg-white p-4 rounded-lg w-full max-w-lg; } + + /* Markdown content styling to restore list elements */ + .markdown-content ul { + @apply list-disc list-inside ml-4; + } + + .markdown-content ol { + @apply list-decimal list-inside ml-4; + } + + .markdown-content li { + @apply mb-1; + } + + .markdown-content ul ul, + .markdown-content ol ol, + .markdown-content ul ol, + .markdown-content ol ul { + @apply ml-4 mt-1; + } } \ No newline at end of file diff --git a/src/components/ActivityListItem.vue b/src/components/ActivityListItem.vue index 39dfcffa..33b50d02 100644 --- a/src/components/ActivityListItem.vue +++ b/src/components/ActivityListItem.vue @@ -80,7 +80,10 @@

- {{ description }} +

@@ -258,11 +261,13 @@ import { NOTIFY_UNKNOWN_PERSON, } from "@/constants/notifications"; import { TIMEOUTS } from "@/utils/notify"; +import VueMarkdown from "vue-markdown-render"; @Component({ components: { EntityIcon, ProjectIcon, + VueMarkdown, }, }) export default class ActivityListItem extends Vue { @@ -303,6 +308,14 @@ export default class ActivityListItem extends Vue { return `${claim?.description || ""}`; } + get truncatedDescription(): string { + const desc = this.description; + if (desc.length <= 300) { + return desc; + } + return desc.substring(0, 300) + "..."; + } + private displayAmount(code: string, amt: number) { return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`; } diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 17524e79..737f8f2e 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -18,7 +18,7 @@ messages * - Conditional UI based on platform capabilities * * @component * > @@ -108,7 +108,7 @@ export default class DataExportSection extends Vue { * Flag indicating if the user has backed up their seed phrase * Used to control the visibility of the notification dot */ - hasBackedUpSeed = false; + showRedNotificationDot = false; /** * Notification helper for consistent notification patterns @@ -240,11 +240,12 @@ export default class DataExportSection extends Vue { private async loadSeedBackupStatus(): Promise { try { const settings = await this.$accountSettings(); - this.hasBackedUpSeed = !!settings.hasBackedUpSeed; + this.showRedNotificationDot = + !!settings.isRegistered && !settings.hasBackedUpSeed; } catch (err: unknown) { logger.error("Failed to load seed backup status:", err); // Default to false (show notification dot) if we can't load the setting - this.hasBackedUpSeed = false; + this.showRedNotificationDot = false; } } } diff --git a/src/components/TopMessage.vue b/src/components/TopMessage.vue index f73ad85c..ebfd4f5a 100644 --- a/src/components/TopMessage.vue +++ b/src/components/TopMessage.vue @@ -20,7 +20,7 @@ import { logger } from "../utils/logger"; }) export default class TopMessage extends Vue { // Enhanced PlatformServiceMixin v4.0 provides: - // - Cached database operations: this.$contacts(), this.$settings(), this.$accountSettings() + // - Cached database operations: this.$contacts(), this.$accountSettings() // - Settings shortcuts: this.$saveSettings() // - Cache management: this.$refreshSettings(), this.$clearAllCaches() // - Ultra-concise database methods: this.$db(), this.$exec(), this.$query() diff --git a/src/components/UsageLimitsSection.vue b/src/components/UsageLimitsSection.vue index ed53393d..1d3d9d94 100644 --- a/src/components/UsageLimitsSection.vue +++ b/src/components/UsageLimitsSection.vue @@ -8,7 +8,7 @@
@@ -19,7 +19,10 @@ aria-hidden="true" >
-
+
{{ limitsMessage }}
diff --git a/src/constants/accountView.ts b/src/constants/accountView.ts index d74c9404..3953b264 100644 --- a/src/constants/accountView.ts +++ b/src/constants/accountView.ts @@ -86,7 +86,7 @@ export const ACCOUNT_VIEW_CONSTANTS = { CANNOT_UPLOAD_IMAGES: "You cannot upload images.", BAD_SERVER_RESPONSE: "Bad server response.", ERROR_RETRIEVING_LIMITS: - "No limits were found, so no actions are allowed. You will need to get registered.", + "No limits were found, so no actions are allowed. You need to get registered.", }, // Project assignment errors diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index f686d155..ca5dad14 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -192,6 +192,13 @@ const MIGRATIONS = [ name: "004_active_identity_management", sql: MIG_004_SQL, }, + { + name: "005_add_starredPlanHandleIds_to_settings", + sql: ` + ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string + ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT; + `, + }, ]; /** diff --git a/src/db/databaseUtil.ts b/src/db/databaseUtil.ts index 85b7192f..487742c9 100644 --- a/src/db/databaseUtil.ts +++ b/src/db/databaseUtil.ts @@ -9,34 +9,6 @@ import { logger } from "@/utils/logger"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { QueryExecResult } from "@/interfaces/database"; -export async function updateDefaultSettings( - settingsChanges: Settings, -): Promise { - delete settingsChanges.accountDid; // just in case - // ensure there is no "id" that would override the key - delete settingsChanges.id; - try { - const platformService = PlatformServiceFactory.getInstance(); - const { sql, params } = generateUpdateStatement( - settingsChanges, - "settings", - "id = ?", - [MASTER_SETTINGS_KEY], - ); - const result = await platformService.dbExec(sql, params); - return result.changes === 1; - } catch (error) { - logger.error("Error updating default settings:", error); - if (error instanceof Error) { - throw error; // Re-throw if it's already an Error with a message - } else { - throw new Error( - `Failed to update settings. We recommend you try again or restart the app.`, - ); - } - } -} - export async function insertDidSpecificSettings( did: string, settings: Partial = {}, @@ -91,6 +63,7 @@ export async function updateDidSpecificSettings( ? mapColumnsToValues(postUpdateResult.columns, postUpdateResult.values)[0] : null; + // Note that we want to eliminate this check (and fix the above if it doesn't work). // Check if any of the target fields were actually changed let actuallyUpdated = false; if (currentRecord && updatedRecord) { @@ -157,10 +130,11 @@ 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.starredPlanHandleIds = parseJsonField( + settings.starredPlanHandleIds, + [], + ); return settings; } } @@ -226,10 +200,11 @@ export async function retrieveSettingsForActiveAccount(): Promise { ); } - // Handle searchBoxes parsing - if (settings.searchBoxes) { - settings.searchBoxes = parseJsonField(settings.searchBoxes, []); - } + settings.searchBoxes = parseJsonField(settings.searchBoxes, []); + settings.starredPlanHandleIds = parseJsonField( + settings.starredPlanHandleIds, + [], + ); return settings; } catch (error) { diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 4c00b46e..493e4596 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -43,6 +43,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 + lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan 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; @@ -67,15 +68,18 @@ 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 + + starredPlanHandleIds?: string[]; // Array of starred plan handle IDs 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 webPushServer?: string; // Web Push server URL }; -// type of settings where the searchBoxes are JSON strings instead of objects +// type of settings where the values are JSON strings instead of objects export type SettingsWithJsonStrings = Settings & { searchBoxes: string; + starredPlanHandleIds: string; }; export function checkIsAnyFeedFilterOn(settings: Settings): boolean { @@ -92,6 +96,11 @@ export const SettingsSchema = { /** * Constants. */ + +/** + * This is deprecated. + * It only remains for those with a PWA who have not migrated, but we'll soon remove it. + */ export const MASTER_SETTINGS_KEY = "1"; export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; diff --git a/src/interfaces/claims.ts b/src/interfaces/claims.ts index c028858b..1fc03529 100644 --- a/src/interfaces/claims.ts +++ b/src/interfaces/claims.ts @@ -72,11 +72,15 @@ export interface PlanActionClaim extends ClaimObject { name: string; agent?: { identifier: string }; description?: string; + endTime?: string; identifier?: string; + image?: string; lastClaimId?: string; location?: { geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; }; + startTime?: string; + url?: string; } // AKA Registration & RegisterAction diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts index 7a884f0c..ca82624c 100644 --- a/src/interfaces/records.ts +++ b/src/interfaces/records.ts @@ -1,4 +1,5 @@ -import { GiveActionClaim, OfferClaim } from "./claims"; +import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; +import { GenericCredWrapper } from "./common"; // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { @@ -61,6 +62,11 @@ export interface PlanSummaryRecord { jwtId?: string; } +export interface PlanSummaryAndPreviousClaim { + plan: PlanSummaryRecord; + wrappedClaimBefore: GenericCredWrapper; +} + /** * Represents data about a project * @@ -87,7 +93,10 @@ export interface PlanData { name: string; /** * The identifier of the project record -- different from jwtId - * (Maybe we should use the jwtId to iterate through the records instead.) + * + * This has been used to iterate through plan records, because jwtId ordering doesn't match + * chronological create ordering, though it does match most recent edit order (in reverse order). + * (It may be worthwhile to order by jwtId instead. It is an indexed field.) **/ rowId?: string; } diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 30bb7316..a0e2bf6c 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -56,7 +56,12 @@ import { KeyMetaWithPrivate, KeyMetaMaybeWithPrivate, } from "../interfaces/common"; -import { PlanSummaryRecord } from "../interfaces/records"; +import { + OfferSummaryRecord, + OfferToPlanSummaryRecord, + PlanSummaryAndPreviousClaim, + PlanSummaryRecord, +} from "../interfaces/records"; import { logger } from "../utils/logger"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { APP_SERVER } from "@/constants/app"; @@ -362,6 +367,22 @@ export function didInfo( return didInfoForContact(did, activeDid, contact, allMyDids).displayName; } +/** + * In some contexts (eg. agent), a blank really is nobody. + */ +export function didInfoOrNobody( + did: string | undefined, + activeDid: string | undefined, + allMyDids: string[], + contacts: Contact[], +): string { + if (did == null) { + return "Nobody"; + } else { + return didInfo(did, activeDid, allMyDids, contacts); + } +} + /** * return text description without any references to "you" as user */ @@ -730,7 +751,7 @@ export async function getNewOffersToUser( activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string, -) { +): Promise<{ data: Array; hitLimit: boolean }> { let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`; if (afterOfferJwtId) { url += "&afterId=" + afterOfferJwtId; @@ -752,7 +773,7 @@ export async function getNewOffersToUserProjects( activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string, -) { +): Promise<{ data: Array; hitLimit: boolean }> { let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`; if (afterOfferJwtId) { url += "?afterId=" + afterOfferJwtId; @@ -766,6 +787,46 @@ 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 starredPlanHandleIds - array of starred project handle IDs + * @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId) + * @returns { data: Array, hitLimit: boolean } + */ +export async function getStarredProjectsWithChanges( + axios: Axios, + apiServer: string, + activeDid: string, + starredPlanHandleIds: string[], + afterId?: string, +): Promise<{ data: Array; hitLimit: boolean }> { + if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) { + return { data: [], hitLimit: false }; + } + + if (!afterId) { + // This doesn't make sense: there should always be some previous one they've seen. + // We'll just return blank. + 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: starredPlanHandleIds, + afterId: afterId, + }; + + const response = await axios.post(url, requestBody, { headers }); + return response.data; +} + /** * Construct GiveAction VC for submission to server * @@ -1697,49 +1758,19 @@ export async function fetchEndorserRateLimits( timestamp: new Date().toISOString(), }); - try { - const response = await axios.get(url, { headers } as AxiosRequestConfig); + // not wrapped in a 'try' because the error returned is self-explanatory + const response = await axios.get(url, { headers } as AxiosRequestConfig); - // Log successful registration check - logger.debug("[User Registration] User registration check successful:", { - did: issuerDid, - server: apiServer, - status: response.status, - isRegistered: true, - timestamp: new Date().toISOString(), - }); - - return response; - } catch (error) { - // Enhanced error logging with user registration context - const axiosError = error as { - response?: { - data?: { error?: { code?: string; message?: string } }; - status?: number; - }; - }; - const errorCode = axiosError.response?.data?.error?.code; - const errorMessage = axiosError.response?.data?.error?.message; - const httpStatus = axiosError.response?.status; - - logger.warn("[User Registration] User not registered on server:", { - did: issuerDid, - server: apiServer, - errorCode: errorCode, - errorMessage: errorMessage, - httpStatus: httpStatus, - needsRegistration: true, - timestamp: new Date().toISOString(), - }); - - // Log the original error for debugging - logger.error( - `[fetchEndorserRateLimits] Error for DID ${issuerDid}:`, - errorStringForLog(error), - ); + // Log successful registration check + logger.debug("[User Registration] User registration check successful:", { + did: issuerDid, + server: apiServer, + status: response.status, + isRegistered: true, + timestamp: new Date().toISOString(), + }); - throw error; - } + return response; } /** @@ -1788,14 +1819,17 @@ export async function fetchImageRateLimits( }; }; - logger.error("[Image Server] Image rate limits check failed:", { - did: issuerDid, - server: server, - errorCode: axiosError.response?.data?.error?.code, - errorMessage: axiosError.response?.data?.error?.message, - httpStatus: axiosError.response?.status, - timestamp: new Date().toISOString(), - }); + logger.warn( + "[Image Server] Image rate limits check failed, which is expected for users not registered on test server (eg. when only registered on local server).", + { + did: issuerDid, + server: server, + errorCode: axiosError.response?.data?.error?.code, + errorMessage: axiosError.response?.data?.error?.message, + httpStatus: axiosError.response?.status, + timestamp: new Date().toISOString(), + }, + ); return null; } } 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/router/index.ts b/src/router/index.ts index 584f7403..4660de52 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -285,6 +285,16 @@ const routes: Array = [ name: "user-profile", component: () => import("../views/UserProfileView.vue"), }, + // Catch-all route for 404 errors - must be last + { + path: "/:pathMatch(.*)*", + name: "not-found", + component: () => import("../views/NotFoundView.vue"), + meta: { + title: "Page Not Found", + requiresAuth: false, + }, + }, ]; const isElectron = window.location.protocol === "file:"; diff --git a/src/services/api.ts b/src/services/api.ts index d7b67beb..e983f2a1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -19,7 +19,6 @@ import { logger, safeStringify } from "../utils/logger"; * @remarks * Special handling includes: * - Enhanced logging for Capacitor platform - * - Rate limit detection and handling * - Detailed error information logging including: * - Error message * - HTTP status @@ -50,11 +49,5 @@ export const handleApiError = (error: AxiosError, endpoint: string) => { }); } - // Specific handling for rate limits - if (error.response?.status === 400) { - logger.warn(`[Rate Limit] ${endpoint}`); - return null; - } - throw error; }; diff --git a/src/test/PlatformServiceMixinTest.vue b/src/test/PlatformServiceMixinTest.vue index 219c72cf..98f5325c 100644 --- a/src/test/PlatformServiceMixinTest.vue +++ b/src/test/PlatformServiceMixinTest.vue @@ -85,7 +85,6 @@ diff --git a/src/views/NotFoundView.vue b/src/views/NotFoundView.vue new file mode 100644 index 00000000..30eff651 --- /dev/null +++ b/src/views/NotFoundView.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index f9d37793..6c1ba647 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -38,9 +38,20 @@ > - +
@@ -65,13 +76,13 @@ icon="user" class="fa-fw text-slate-400" > - + {{ issuerInfoObject?.displayName }}
- {{ truncatedDesc }} + ... Read More
- {{ description }} + - Read Less
- + @@ -298,10 +313,7 @@ {{ offer.objectDescription }}
- + import { AxiosError } from "axios"; import { Component, Vue } from "vue-facing-decorator"; +import VueMarkdown from "vue-markdown-render"; import { Router } from "vue-router"; + import { GenericVerifiableCredential, GenericCredWrapper, @@ -610,25 +624,25 @@ import { PlanSummaryRecord, } from "../interfaces"; import GiftedDialog from "../components/GiftedDialog.vue"; +import HiddenDidDialog from "../components/HiddenDidDialog.vue"; import OfferDialog from "../components/OfferDialog.vue"; import TopMessage from "../components/TopMessage.vue"; import QuickNav from "../components/QuickNav.vue"; import EntityIcon from "../components/EntityIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue"; -import { NotificationIface } from "../constants/app"; -// Removed legacy logging import - migrated to PlatformServiceMixin +import { APP_SERVER, NotificationIface } from "../constants/app"; +import { UNNAMED_PROJECT } from "../constants/entities"; +import { NOTIFY_CONFIRM_CLAIM } from "../constants/notifications"; +import * as databaseUtil from "../db/databaseUtil"; import { Contact } from "../db/tables/contacts"; import * as libsUtil from "../libs/util"; import * as serverUtil from "../libs/endorserServer"; import { retrieveAccountDids } from "../libs/util"; -import HiddenDidDialog from "../components/HiddenDidDialog.vue"; -import { logger } from "../utils/logger"; import { copyToClipboard } from "../services/ClipboardService"; +import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; -import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications"; -import { APP_SERVER } from "@/constants/app"; -import { UNNAMED_PROJECT } from "@/constants/entities"; + /** * Project View Component * @author Matthew Raymer @@ -670,6 +684,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities"; ProjectIcon, QuickNav, TopMessage, + VueMarkdown, }, mixins: [PlatformServiceMixin], }) @@ -725,6 +740,8 @@ export default class ProjectViewView extends Vue { givesProvidedByHitLimit = false; givesTotalsByUnit: Array<{ unit: string; amount: number }> = []; imageUrl = ""; + /** Whether this project is starred by the user */ + isStarred = false; /** Project issuer DID */ issuer = ""; /** Cached issuer information */ @@ -735,6 +752,8 @@ export default class ProjectViewView extends Vue { } | null = null; /** DIDs that can see issuer information */ issuerVisibleToDids: Array = []; + /** Project JWT ID */ + jwtId = ""; /** Project location data */ latitude = 0; loadingTotals = false; @@ -763,7 +782,7 @@ export default class ProjectViewView extends Vue { totalsExpanded = false; truncatedDesc = ""; /** Truncation length */ - truncateLength = 40; + truncateLength = 200; // Utility References libsUtil = libsUtil; @@ -817,6 +836,12 @@ 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.starredPlanHandleIds) { + const starredIds = settings.starredPlanHandleIds || []; + this.isStarred = starredIds.includes(this.projectId); + } } /** @@ -893,8 +918,9 @@ export default class ProjectViewView extends Vue { this.allContacts, ); this.issuerVisibleToDids = resp.data.issuerVisibleToDids || []; + this.jwtId = resp.data.id; this.name = resp.data.claim?.name || "(no name)"; - this.description = resp.data.claim?.description || "(no description)"; + this.description = resp.data.claim?.description || ""; this.truncatedDesc = this.description.slice(0, this.truncateLength); this.latitude = resp.data.claim?.location?.geo?.latitude || 0; this.longitude = resp.data.claim?.location?.geo?.longitude || 0; @@ -1484,5 +1510,67 @@ 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 { + const settings = await this.$accountSettings(); + const starredIds = settings.starredPlanHandleIds || []; + if (!this.isStarred) { + // Add to starred projects + 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 + { starredPlanHandleIds: newIdsParam }, + ); + if (result) { + this.isStarred = true; + } else { + // eslint-disable-next-line no-console + logger.error("Got a bad result from SQL update to star a project."); + } + } + if (!settings.lastAckedStarredPlanChangesJwtId) { + await databaseUtil.updateDidSpecificSettings(this.activeDid, { + lastAckedStarredPlanChangesJwtId: this.jwtId, + }); + } + } else { + // Remove from starred projects + + 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 + { starredPlanHandleIds: newIdsParam }, + ); + if (result) { + this.isStarred = false; + } else { + // eslint-disable-next-line no-console + logger.error("Got a bad result from SQL update to unstar a project."); + } + } + } 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, + ); + } + } } diff --git a/test-playwright/20-create-project.spec.ts b/test-playwright/20-create-project.spec.ts index f868f951..303e22c8 100644 --- a/test-playwright/20-create-project.spec.ts +++ b/test-playwright/20-create-project.spec.ts @@ -100,7 +100,10 @@ test('Create new project, then search for it', async ({ page }) => { const finalTitle = standardTitle + finalRandomString; const finalDescription = standardDescription + finalRandomString; const editedTitle = finalTitle + standardEdit; - const editedDescription = finalDescription + standardEdit; + const editedDescription = + finalDescription + + standardEdit + + " ... and enough text to overflow into the 'Read More' section."; // Import user 00 await importUser(page, '00'); diff --git a/test-playwright/60-new-activity.spec.ts b/test-playwright/60-new-activity.spec.ts index 59015497..ed03c07b 100644 --- a/test-playwright/60-new-activity.spec.ts +++ b/test-playwright/60-new-activity.spec.ts @@ -109,7 +109,7 @@ test('New offers for another user', async ({ page }) => { await expect(page.getByText('New Offers To You', { exact: true })).toBeVisible(); await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click(); - await expect(page.getByText('The offers are marked as viewed')).toBeVisible(); + await expect(page.getByText('The offers are marked as read')).toBeVisible(); await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert await page.waitForTimeout(1000); @@ -140,7 +140,7 @@ test('New offers for another user', async ({ page }) => { await expect(page.getByText('New Offer To You', { exact: true })).toBeVisible(); await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click(); - await expect(page.getByText('The offers are marked as viewed')).toBeVisible(); + await expect(page.getByText('The offers are marked as read')).toBeVisible(); await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert // now see that no offers are shown as new diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index 9c5779b3..67923ba1 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -145,9 +145,10 @@ export async function generateNewEthrUser(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); } @@ -156,7 +157,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);