diff --git a/.cursor/rules/development/development_guide.mdc b/.cursor/rules/development/development_guide.mdc index a8065a31..a15190ae 100644 --- a/.cursor/rules/development/development_guide.mdc +++ b/.cursor/rules/development/development_guide.mdc @@ -2,7 +2,7 @@ globs: **/src/**/* alwaysApply: false --- -✅ use system date command to timestamp all interactions with accurate date and +✅ use system date command to timestamp all documentation with accurate date and time ✅ remove whitespace at the end of lines ✅ use npm run lint-fix to check for warnings diff --git a/package-lock.json b/package-lock.json index 04d2b408..914004eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timesafari", - "version": "1.1.0-beta", + "version": "1.1.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timesafari", - "version": "1.1.0-beta", + "version": "1.1.1-beta", "dependencies": { "@capacitor-community/electron": "^5.0.1", "@capacitor-community/sqlite": "6.0.2", diff --git a/src/components/ActivityListItem.vue b/src/components/ActivityListItem.vue index 6f27be86..ebc081d8 100644 --- a/src/components/ActivityListItem.vue +++ b/src/components/ActivityListItem.vue @@ -77,12 +77,86 @@ - -

- +

+
+ +
+ +
+ + + +
+ + +
+ +
+ +
+
+
+ + +

+

import { Component, Prop, Vue, Emit } from "vue-facing-decorator"; -import { GiveRecordWithContactInfo } from "@/interfaces/give"; +import VueMarkdown from "vue-markdown-render"; + +import { logger } from "../utils/logger"; +import { + createAndSubmitClaim, + getHeaders, + isHiddenDid, +} from "../libs/endorserServer"; import EntityIcon from "./EntityIcon.vue"; -import { isHiddenDid } from "../libs/endorserServer"; import ProjectIcon from "./ProjectIcon.vue"; -import { createNotifyHelpers, NotifyFunction } from "@/utils/notify"; +import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify"; import { NOTIFY_PERSON_HIDDEN, NOTIFY_UNKNOWN_PERSON, } from "@/constants/notifications"; -import { TIMEOUTS } from "@/utils/notify"; -import VueMarkdown from "vue-markdown-render"; +import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces"; +import { GiveRecordWithContactInfo } from "@/interfaces/give"; +import { PromiseTracker } from "@/libs/util"; @Component({ components: { @@ -274,15 +355,24 @@ import VueMarkdown from "vue-markdown-render"; }, }) export default class ActivityListItem extends Vue { + readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"]; + @Prop() record!: GiveRecordWithContactInfo; @Prop() lastViewedClaimId?: string; @Prop() isRegistered!: boolean; @Prop() activeDid!: string; + @Prop() apiServer!: string; isHiddenDid = isHiddenDid; notify!: ReturnType; $notify!: NotifyFunction; + // Emoji-related data + showEmojiPicker = false; + loadingEmojis = false; // Track if emojis are currently loading + + emojisOnActivity: PromiseTracker | null = null; // load this only when needed + created() { this.notify = createNotifyHelpers(this.$notify); } @@ -346,5 +436,186 @@ export default class ActivityListItem extends Vue { day: "numeric", }); } + + // Emoji-related computed properties and methods + get hasEmojis(): boolean { + return Object.keys(this.record.emojiCount).length > 0; + } + + triggerUserEmojiLoad(): PromiseTracker { + if (!this.emojisOnActivity) { + const promise = new Promise((resolve) => { + (async () => { + this.axios + .get( + `${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`, + { headers: await getHeaders(this.activeDid) }, + ) + .then((response) => { + const userEmojiRecords = response.data.data.filter( + (e: EmojiSummaryRecord) => e.issuerDid === this.activeDid, + ); + resolve(userEmojiRecords); + }) + .catch((error) => { + logger.error("Error loading user emojis:", error); + resolve([]); + }); + })(); + }); + + this.emojisOnActivity = new PromiseTracker(promise); + } + return this.emojisOnActivity; + } + + /** + * + * @param emoji - The emoji to check. + * @returns True if the emoji is in the user's emojis, false otherwise. + * + * @note This method is quick and synchronous, and can check resolved emojis + * without triggering a server request. Returns false if emojis haven't been loaded yet. + */ + isUserEmojiWithoutLoading(emoji: string): boolean { + if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) { + return this.emojisOnActivity.value.some( + (record) => record.text === emoji, + ); + } + return false; + } + + async toggleEmojiPicker() { + this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete + this.showEmojiPicker = !this.showEmojiPicker; + } + + async toggleThisEmoji(emoji: string) { + // Start loading indicator + this.loadingEmojis = true; + this.showEmojiPicker = false; // always close the picker when an emoji is clicked + + try { + this.triggerUserEmojiLoad(); // trigger just in case + + const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen + + const userHasEmoji: boolean = userEmojiList.some( + (record) => record.text === emoji, + ); + + if (userHasEmoji) { + this.$notify( + { + group: "modal", + type: "confirm", + title: "Remove Emoji", + text: `Do you want to remove your ${emoji} ?`, + yesText: "Remove", + onYes: async () => { + await this.removeEmoji(emoji); + }, + }, + TIMEOUTS.MODAL, + ); + } else { + // User doesn't have this emoji, add it + await this.submitEmoji(emoji); + } + } finally { + // Remove loading indicator + this.loadingEmojis = false; + } + } + + async submitEmoji(emoji: string) { + try { + // Create an Emoji claim and send to the server + const emojiClaim: GenericVerifiableCredential = { + "@context": "https://endorser.ch", + "@type": "Emoji", + text: emoji, + parentItem: { lastClaimId: this.record.jwtId }, + }; + const claim = await createAndSubmitClaim( + emojiClaim, + this.activeDid, + this.apiServer, + this.axios, + ); + if (claim.success && !claim.embeddedRecordError) { + // Update emoji count + this.record.emojiCount[emoji] = + (this.record.emojiCount[emoji] || 0) + 1; + + // Create a new emoji record (we'll get the actual jwtId from the server response later) + const newEmojiRecord: EmojiSummaryRecord = { + issuerDid: this.activeDid, + jwtId: claim.claimId || "", + text: emoji, + parentHandleId: this.record.jwtId, + }; + + // Update user emojis list by creating a new promise with the updated data + // (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly) + this.triggerUserEmojiLoad(); + const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one + this.emojisOnActivity = new PromiseTracker( + Promise.resolve([...currentEmojis, newEmojiRecord]), + ); + } else { + this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD); + } + } catch (error) { + logger.error("Error submitting emoji:", error); + this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD); + } + } + + async removeEmoji(emoji: string) { + try { + // Create an Emoji claim and send to the server + const emojiClaim: GenericVerifiableCredential = { + "@context": "https://endorser.ch", + "@type": "Emoji", + text: emoji, + parentItem: { lastClaimId: this.record.jwtId }, + }; + const claim = await createAndSubmitClaim( + emojiClaim, + this.activeDid, + this.apiServer, + this.axios, + ); + if (claim.success && !claim.embeddedRecordError) { + // Update emoji count + const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1); + if (newCount === 0) { + delete this.record.emojiCount[emoji]; + } else { + this.record.emojiCount[emoji] = newCount; + } + + // Update user emojis list by creating a new promise with the updated data + // (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly) + this.triggerUserEmojiLoad(); + const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one + this.emojisOnActivity = new PromiseTracker( + Promise.resolve( + currentEmojis.filter( + (record) => + record.issuerDid === this.activeDid && record.text !== emoji, + ), + ), + ); + } else { + this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD); + } + } catch (error) { + logger.error("Error removing emoji:", error); + this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD); + } + } } diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index ca5dad14..4a177786 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -234,32 +234,20 @@ export async function runMigrations( sqlQuery: (sql: string, params?: unknown[]) => Promise, extractMigrationNames: (result: T) => Set, ): Promise { - // Only log migration start in development - const isDevelopment = process.env.VITE_PLATFORM === "development"; - if (isDevelopment) { - logger.debug("[Migration] Starting database migrations"); - } + logger.debug("[Migration] Starting database migrations"); for (const migration of MIGRATIONS) { - if (isDevelopment) { - logger.debug("[Migration] Registering migration:", migration.name); - } + logger.debug("[Migration] Registering migration:", migration.name); registerMigration(migration); } - if (isDevelopment) { - logger.debug("[Migration] Running migration service"); - } + logger.debug("[Migration] Running migration service"); await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); - if (isDevelopment) { - logger.debug("[Migration] Database migrations completed"); - } + logger.debug("[Migration] Database migrations completed"); // Bootstrapping: Ensure active account is selected after migrations - if (isDevelopment) { - logger.debug("[Migration] Running bootstrapping hooks"); - } + logger.debug("[Migration] Running bootstrapping hooks"); try { // Check if we have accounts but no active selection const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts"); @@ -274,18 +262,14 @@ export async function runMigrations( activeDid = (extractSingleValue(activeResult) as string) || null; } catch (error) { // Table doesn't exist - migration 004 may not have run yet - if (isDevelopment) { - logger.debug( - "[Migration] active_identity table not found - migration may not have run", - ); - } + logger.debug( + "[Migration] active_identity table not found - migration may not have run", + ); activeDid = null; } if (accountsCount > 0 && (!activeDid || activeDid === "")) { - if (isDevelopment) { - logger.debug("[Migration] Auto-selecting first account as active"); - } + logger.debug("[Migration] Auto-selecting first account as active"); const firstAccountResult = await sqlQuery( "SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1", ); diff --git a/src/interfaces/claims.ts b/src/interfaces/claims.ts index 1fc03529..49e2b4a8 100644 --- a/src/interfaces/claims.ts +++ b/src/interfaces/claims.ts @@ -14,6 +14,13 @@ export interface AgreeActionClaim extends ClaimObject { object: Record; } +export interface EmojiClaim extends ClaimObject { + // default context is "https://endorser.ch" + "@type": "Emoji"; + text: string; + parentItem: { lastClaimId: string }; +} + // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id4 export interface GiveActionClaim extends ClaimObject { diff --git a/src/interfaces/common.ts b/src/interfaces/common.ts index b2e68d1f..f1f172e2 100644 --- a/src/interfaces/common.ts +++ b/src/interfaces/common.ts @@ -81,7 +81,9 @@ export interface UserInfo { export interface CreateAndSubmitClaimResult { success: boolean; + embeddedRecordError?: string; error?: string; + claimId?: string; handleId?: string; } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 1cf99593..c4b12191 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,37 +1,6 @@ -export type { - // From common.ts - CreateAndSubmitClaimResult, - GenericCredWrapper, - GenericVerifiableCredential, - KeyMeta, - // Exclude types that are also exported from other files - // GiveVerifiableCredential, - // OfferVerifiableCredential, - // RegisterVerifiableCredential, - // PlanSummaryRecord, - // UserInfo, -} from "./common"; - -export type { - // From claims.ts - GiveActionClaim, - OfferClaim, - RegisterActionClaim, -} from "./claims"; - -export type { - // From records.ts - PlanSummaryRecord, -} from "./records"; - -export type { - // From user.ts - UserInfo, - MemberData, -} from "./user"; - -export * from "./limits"; -export * from "./deepLinks"; -export * from "./common"; +export * from "./claims"; export * from "./claims-result"; +export * from "./common"; +export * from "./deepLinks"; +export * from "./limits"; export * from "./records"; diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts index ca82624c..03627904 100644 --- a/src/interfaces/records.ts +++ b/src/interfaces/records.ts @@ -1,14 +1,26 @@ import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; import { GenericCredWrapper } from "./common"; +export interface EmojiSummaryRecord { + issuerDid: string; + jwtId: string; + text: string; + parentHandleId: string; +} + // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { - [x: string]: PropertyKey | undefined | GiveActionClaim; + [x: string]: + | PropertyKey + | undefined + | GiveActionClaim + | Record; type?: string; agentDid: string; amount: number; amountConfirmed: number; description: string; + emojiCount: Record; // Map of emoji character to count fullClaim: GiveActionClaim; fulfillsHandleId: string; fulfillsPlanHandleId?: string; diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index a0e2bf6c..2eded25f 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -630,11 +630,7 @@ async function performPlanRequest( return cred; } else { - // Use debug level for development to reduce console noise - const isDevelopment = process.env.VITE_PLATFORM === "development"; - const log = isDevelopment ? logger.debug : logger.log; - - log( + logger.debug( "[Plan Loading] ⚠️ Plan cache is empty for handle", handleId, " Got data:", @@ -706,7 +702,7 @@ export function serverMessageForUser(error: unknown): string | undefined { export function errorStringForLog(error: unknown) { let stringifiedError = "" + error; try { - stringifiedError = JSON.stringify(error); + stringifiedError = safeStringify(error); } catch (e) { // can happen with Dexie, eg: // TypeError: Converting circular structure to JSON @@ -718,7 +714,7 @@ export function errorStringForLog(error: unknown) { if (error && typeof error === "object" && "response" in error) { const err = error as AxiosErrorResponse; - const errorResponseText = JSON.stringify(err.response); + const errorResponseText = safeStringify(err.response); // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { // add error.response stuff @@ -728,7 +724,7 @@ export function errorStringForLog(error: unknown) { R.equals(err.config, err.response.config) ) { // but exclude "config" because it's already in there - const newErrorResponseText = JSON.stringify( + const newErrorResponseText = safeStringify( R.omit(["config"] as never[], err.response), ); fullError += @@ -1226,7 +1222,12 @@ export async function createAndSubmitClaim( timestamp: new Date().toISOString(), }); - return { success: true, handleId: response.data?.handleId }; + return { + success: true, + claimId: response.data?.claimId, + handleId: response.data?.handleId, + embeddedRecordError: response.data?.embeddedRecordError, + }; } catch (error: unknown) { // Enhanced error logging with comprehensive context const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; diff --git a/src/libs/util.ts b/src/libs/util.ts index 40d0fd3a..4790714d 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -988,11 +988,6 @@ export async function importFromMnemonic( ): Promise { const mne: string = mnemonic.trim().toLowerCase(); - // Check if this is Test User #0 - const TEST_USER_0_MNEMONIC = - "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage"; - const isTestUser0 = mne === TEST_USER_0_MNEMONIC; - // Derive address and keys from mnemonic const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath); @@ -1007,90 +1002,6 @@ export async function importFromMnemonic( // Save the new identity await saveNewIdentity(newId, mne, derivationPath); - - // Set up Test User #0 specific settings - if (isTestUser0) { - // Set up Test User #0 specific settings with enhanced error handling - const platformService = await getPlatformService(); - - try { - // First, ensure the DID-specific settings record exists - await platformService.insertNewDidIntoSettings(newId.did); - - // Then update with Test User #0 specific settings - await platformService.updateDidSpecificSettings(newId.did, { - firstName: "User Zero", - isRegistered: true, - }); - - // Verify the settings were saved correctly - const verificationResult = await platformService.dbQuery( - "SELECT firstName, isRegistered FROM settings WHERE accountDid = ?", - [newId.did], - ); - - if (verificationResult?.values?.length) { - const settings = verificationResult.values[0]; - const firstName = settings[0]; - const isRegistered = settings[1]; - - logger.debug( - "[importFromMnemonic] Test User #0 settings verification", - { - did: newId.did, - firstName, - isRegistered, - expectedFirstName: "User Zero", - expectedIsRegistered: true, - }, - ); - - // If settings weren't saved correctly, try individual updates - if (firstName !== "User Zero" || isRegistered !== 1) { - logger.warn( - "[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates", - ); - - await platformService.dbExec( - "UPDATE settings SET firstName = ? WHERE accountDid = ?", - ["User Zero", newId.did], - ); - - await platformService.dbExec( - "UPDATE settings SET isRegistered = ? WHERE accountDid = ?", - [1, newId.did], - ); - - // Verify again - const retryResult = await platformService.dbQuery( - "SELECT firstName, isRegistered FROM settings WHERE accountDid = ?", - [newId.did], - ); - - if (retryResult?.values?.length) { - const retrySettings = retryResult.values[0]; - logger.debug( - "[importFromMnemonic] Test User #0 settings after retry", - { - firstName: retrySettings[0], - isRegistered: retrySettings[1], - }, - ); - } - } - } else { - logger.error( - "[importFromMnemonic] Failed to verify Test User #0 settings - no record found", - ); - } - } catch (error) { - logger.error( - "[importFromMnemonic] Error setting up Test User #0 settings:", - error, - ); - // Don't throw - allow the import to continue even if settings fail - } - } } /** @@ -1147,3 +1058,29 @@ export async function checkForDuplicateAccount( return (existingAccount?.values?.length ?? 0) > 0; } + +export class PromiseTracker { + private _promise: Promise; + private _resolved = false; + private _value: T | undefined; + + constructor(promise: Promise) { + this._promise = promise.then((value) => { + this._resolved = true; + this._value = value; + return value; + }); + } + + get isResolved(): boolean { + return this._resolved; + } + + get value(): T | undefined { + return this._value; + } + + get promise(): Promise { + return this._promise; + } +} diff --git a/src/services/platforms/BaseDatabaseService.ts b/src/services/platforms/BaseDatabaseService.ts new file mode 100644 index 00000000..9f995c13 --- /dev/null +++ b/src/services/platforms/BaseDatabaseService.ts @@ -0,0 +1,297 @@ +/** + * @fileoverview Base Database Service for Platform Services + * @author Matthew Raymer + * + * This abstract base class provides common database operations that are + * identical across all platform implementations. It eliminates code + * duplication and ensures consistency in database operations. + * + * Key Features: + * - Common database utility methods + * - Consistent settings management + * - Active identity management + * - Abstract methods for platform-specific database operations + * + * Architecture: + * - Abstract base class with common implementations + * - Platform services extend this class + * - Platform-specific database operations remain abstract + * + * @since 1.1.1-beta + */ + +import { logger } from "../../utils/logger"; +import { QueryExecResult } from "@/interfaces/database"; + +/** + * Abstract base class for platform-specific database services. + * + * This class provides common database operations that are identical + * across all platform implementations (Web, Capacitor, Electron). + * Platform-specific services extend this class and implement the + * abstract database operation methods. + * + * Common Operations: + * - Settings management (update, retrieve, insert) + * - Active identity management + * - Database utility methods + * + * @abstract + * @example + * ```typescript + * export class WebPlatformService extends BaseDatabaseService { + * async dbQuery(sql: string, params?: unknown[]): Promise { + * // Web-specific implementation + * } + * } + * ``` + */ +export abstract class BaseDatabaseService { + /** + * Generate an INSERT statement for a model object. + * + * Creates a parameterized INSERT statement with placeholders for + * all properties in the model object. This ensures safe SQL + * execution and prevents SQL injection. + * + * @param model - Object containing the data to insert + * @param tableName - Name of the target table + * @returns Object containing the SQL statement and parameters + * + * @example + * ```typescript + * const { sql, params } = this.generateInsertStatement( + * { name: 'John', age: 30 }, + * 'users' + * ); + * // sql: "INSERT INTO users (name, age) VALUES (?, ?)" + * // params: ['John', 30] + * ``` + */ + generateInsertStatement( + model: Record, + tableName: string, + ): { sql: string; params: unknown[] } { + const keys = Object.keys(model); + const placeholders = keys.map(() => "?").join(", "); + const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; + const params = keys.map((key) => model[key]); + return { sql, params }; + } + + /** + * Update default settings for the currently active account. + * + * Retrieves the active DID from the active_identity table and updates + * the corresponding settings record. This ensures settings are always + * updated for the correct account. + * + * @param settings - Object containing the settings to update + * @returns Promise that resolves when settings are updated + * + * @throws {Error} If no active DID is found or database operation fails + * + * @example + * ```typescript + * await this.updateDefaultSettings({ + * theme: 'dark', + * notifications: true + * }); + * ``` + */ + async updateDefaultSettings( + settings: Record, + ): Promise { + // Get current active DID and update that identity's settings + const activeIdentity = await this.getActiveIdentity(); + const activeDid = activeIdentity.activeDid; + + if (!activeDid) { + logger.warn( + "[BaseDatabaseService] No active DID found, cannot update default settings", + ); + return; + } + + const keys = Object.keys(settings); + const setClause = keys.map((key) => `${key} = ?`).join(", "); + const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; + const params = [...keys.map((key) => settings[key]), activeDid]; + await this.dbExec(sql, params); + } + + /** + * Update the active DID in the active_identity table. + * + * Sets the active DID and updates the lastUpdated timestamp. + * This is used when switching between different accounts/identities. + * + * @param did - The DID to set as active + * @returns Promise that resolves when the update is complete + * + * @example + * ```typescript + * await this.updateActiveDid('did:example:123'); + * ``` + */ + async updateActiveDid(did: string): Promise { + await this.dbExec( + "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", + [did], + ); + } + + /** + * Get the currently active DID from the active_identity table. + * + * Retrieves the active DID that represents the currently selected + * account/identity. This is used throughout the application to + * ensure operations are performed on the correct account. + * + * @returns Promise resolving to object containing the active DID + * + * @example + * ```typescript + * const { activeDid } = await this.getActiveIdentity(); + * console.log('Current active DID:', activeDid); + * ``` + */ + async getActiveIdentity(): Promise<{ activeDid: string }> { + const result = (await this.dbQuery( + "SELECT activeDid FROM active_identity WHERE id = 1", + )) as QueryExecResult; + return { + activeDid: (result?.values?.[0]?.[0] as string) || "", + }; + } + + /** + * Insert a new DID into the settings table with default values. + * + * Creates a new settings record for a DID with default configuration + * values. Uses INSERT OR REPLACE to handle cases where settings + * already exist for the DID. + * + * @param did - The DID to create settings for + * @returns Promise that resolves when settings are created + * + * @example + * ```typescript + * await this.insertNewDidIntoSettings('did:example:123'); + * ``` + */ + async insertNewDidIntoSettings(did: string): Promise { + // Import constants dynamically to avoid circular dependencies + const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = + await import("@/constants/app"); + + // Use INSERT OR REPLACE to handle case where settings already exist for this DID + // This prevents duplicate accountDid entries and ensures data integrity + await this.dbExec( + "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", + [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], + ); + } + + /** + * Update settings for a specific DID. + * + * Updates settings for a particular DID rather than the active one. + * This is useful for bulk operations or when managing multiple accounts. + * + * @param did - The DID to update settings for + * @param settings - Object containing the settings to update + * @returns Promise that resolves when settings are updated + * + * @example + * ```typescript + * await this.updateDidSpecificSettings('did:example:123', { + * theme: 'light', + * notifications: false + * }); + * ``` + */ + async updateDidSpecificSettings( + did: string, + settings: Record, + ): Promise { + const keys = Object.keys(settings); + const setClause = keys.map((key) => `${key} = ?`).join(", "); + const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; + const params = [...keys.map((key) => settings[key]), did]; + await this.dbExec(sql, params); + } + + /** + * Retrieve settings for the currently active account. + * + * Gets the active DID and retrieves all settings for that account. + * Excludes the 'id' column from the returned settings object. + * + * @returns Promise resolving to settings object or null if no active DID + * + * @example + * ```typescript + * const settings = await this.retrieveSettingsForActiveAccount(); + * if (settings) { + * console.log('Theme:', settings.theme); + * console.log('Notifications:', settings.notifications); + * } + * ``` + */ + async retrieveSettingsForActiveAccount(): Promise | null> { + // Get current active DID from active_identity table + const activeIdentity = await this.getActiveIdentity(); + const activeDid = activeIdentity.activeDid; + + if (!activeDid) { + return null; + } + + const result = (await this.dbQuery( + "SELECT * FROM settings WHERE accountDid = ?", + [activeDid], + )) as QueryExecResult; + if (result?.values?.[0]) { + // Convert the row to an object + const row = result.values[0]; + const columns = result.columns || []; + const settings: Record = {}; + + columns.forEach((column: string, index: number) => { + if (column !== "id") { + // Exclude the id column + settings[column] = row[index]; + } + }); + + return settings; + } + return null; + } + + // Abstract methods that must be implemented by platform-specific services + + /** + * Execute a database query (SELECT operations). + * + * @abstract + * @param sql - SQL query string + * @param params - Optional parameters for prepared statements + * @returns Promise resolving to query results + */ + abstract dbQuery(sql: string, params?: unknown[]): Promise; + + /** + * Execute a database statement (INSERT, UPDATE, DELETE operations). + * + * @abstract + * @param sql - SQL statement string + * @param params - Optional parameters for prepared statements + * @returns Promise resolving to execution results + */ + abstract dbExec(sql: string, params?: unknown[]): Promise; +} diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 2db74656..51fb9ce5 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -22,6 +22,7 @@ import { PlatformCapabilities, } from "../PlatformService"; import { logger } from "../../utils/logger"; +import { BaseDatabaseService } from "./BaseDatabaseService"; interface QueuedOperation { type: "run" | "query" | "rawQuery"; @@ -39,7 +40,10 @@ interface QueuedOperation { * - Platform-specific features * - SQLite database operations */ -export class CapacitorPlatformService implements PlatformService { +export class CapacitorPlatformService + extends BaseDatabaseService + implements PlatformService +{ /** Current camera direction */ private currentDirection: CameraDirection = CameraDirection.Rear; @@ -52,6 +56,7 @@ export class CapacitorPlatformService implements PlatformService { private isProcessingQueue: boolean = false; constructor() { + super(); this.sqlite = new SQLiteConnection(CapacitorSQLite); } @@ -1328,79 +1333,8 @@ export class CapacitorPlatformService implements PlatformService { // --- PWA/Web-only methods (no-op for Capacitor) --- public registerServiceWorker(): void {} - // Database utility methods - generateInsertStatement( - model: Record, - tableName: string, - ): { sql: string; params: unknown[] } { - const keys = Object.keys(model); - const placeholders = keys.map(() => "?").join(", "); - const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; - const params = keys.map((key) => model[key]); - return { sql, params }; - } - - async updateDefaultSettings( - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE id = 1`; - const params = keys.map((key) => settings[key]); - await this.dbExec(sql, params); - } - - async updateActiveDid(did: string): Promise { - await this.dbExec( - "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", - [did], - ); - } - - async insertNewDidIntoSettings(did: string): Promise { - // Import constants dynamically to avoid circular dependencies - const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = - await import("@/constants/app"); - - // Use INSERT OR REPLACE to handle case where settings already exist for this DID - // This prevents duplicate accountDid entries and ensures data integrity - await this.dbExec( - "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", - [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], - ); - } - - async updateDidSpecificSettings( - did: string, - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), did]; - await this.dbExec(sql, params); - } - - async retrieveSettingsForActiveAccount(): Promise | null> { - const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1"); - if (result?.values?.[0]) { - // Convert the row to an object - const row = result.values[0]; - const columns = result.columns || []; - const settings: Record = {}; - - columns.forEach((column, index) => { - if (column !== "id") { - // Exclude the id column - settings[column] = row[index]; - } - }); - - return settings; - } - return null; - } + // Database utility methods - inherited from BaseDatabaseService + // generateInsertStatement, updateDefaultSettings, updateActiveDid, + // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, + // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index 3d8248f5..da573837 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -5,6 +5,7 @@ import { } from "../PlatformService"; import { logger } from "../../utils/logger"; import { QueryExecResult } from "@/interfaces/database"; +import { BaseDatabaseService } from "./BaseDatabaseService"; // Dynamic import of initBackend to prevent worker context errors import type { WorkerRequest, @@ -29,7 +30,10 @@ import type { * Note: File system operations are not available in the web platform * due to browser security restrictions. These methods throw appropriate errors. */ -export class WebPlatformService implements PlatformService { +export class WebPlatformService + extends BaseDatabaseService + implements PlatformService +{ private static instanceCount = 0; // Debug counter private worker: Worker | null = null; private workerReady = false; @@ -46,17 +50,16 @@ export class WebPlatformService implements PlatformService { private readonly messageTimeout = 30000; // 30 seconds constructor() { + super(); WebPlatformService.instanceCount++; - // Use debug level logging for development mode to reduce console noise - const isDevelopment = process.env.VITE_PLATFORM === "development"; - const log = isDevelopment ? logger.debug : logger.log; - - log("[WebPlatformService] Initializing web platform service"); + logger.debug("[WebPlatformService] Initializing web platform service"); // Only initialize SharedArrayBuffer setup for web platforms if (this.isWorker()) { - log("[WebPlatformService] Skipping initBackend call in worker context"); + logger.debug( + "[WebPlatformService] Skipping initBackend call in worker context", + ); return; } @@ -670,105 +673,8 @@ export class WebPlatformService implements PlatformService { // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker } - // Database utility methods - generateInsertStatement( - model: Record, - tableName: string, - ): { sql: string; params: unknown[] } { - const keys = Object.keys(model); - const placeholders = keys.map(() => "?").join(", "); - const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; - const params = keys.map((key) => model[key]); - return { sql, params }; - } - - async updateDefaultSettings( - settings: Record, - ): Promise { - // Get current active DID and update that identity's settings - const activeIdentity = await this.getActiveIdentity(); - const activeDid = activeIdentity.activeDid; - - if (!activeDid) { - logger.warn( - "[WebPlatformService] No active DID found, cannot update default settings", - ); - return; - } - - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), activeDid]; - await this.dbExec(sql, params); - } - - async updateActiveDid(did: string): Promise { - await this.dbExec( - "INSERT OR REPLACE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, ?, ?)", - [did, new Date().toISOString()], - ); - } - - async getActiveIdentity(): Promise<{ activeDid: string }> { - const result = await this.dbQuery( - "SELECT activeDid FROM active_identity WHERE id = 1", - ); - return { - activeDid: (result?.values?.[0]?.[0] as string) || "", - }; - } - - async insertNewDidIntoSettings(did: string): Promise { - // Import constants dynamically to avoid circular dependencies - const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = - await import("@/constants/app"); - - // Use INSERT OR REPLACE to handle case where settings already exist for this DID - // This prevents duplicate accountDid entries and ensures data integrity - await this.dbExec( - "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", - [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], - ); - } - - async updateDidSpecificSettings( - did: string, - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), did]; - // Log update operation for debugging - logger.debug( - "[WebPlatformService] updateDidSpecificSettings", - sql, - JSON.stringify(params, null, 2), - ); - await this.dbExec(sql, params); - } - - async retrieveSettingsForActiveAccount(): Promise | null> { - const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1"); - if (result?.values?.[0]) { - // Convert the row to an object - const row = result.values[0]; - const columns = result.columns || []; - const settings: Record = {}; - - columns.forEach((column, index) => { - if (column !== "id") { - // Exclude the id column - settings[column] = row[index]; - } - }); - - return settings; - } - return null; - } + // Database utility methods - inherited from BaseDatabaseService + // generateInsertStatement, updateDefaultSettings, updateActiveDid, + // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, + // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService } diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index e7f698a3..9b7efd3e 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -1488,18 +1488,21 @@ export default class AccountViewView extends Vue { status?: number; }; }; - logger.error("[Server Limits] Error retrieving limits:", { - error: error instanceof Error ? error.message : String(error), - did: did, - apiServer: this.apiServer, - imageServer: this.DEFAULT_IMAGE_API_SERVER, - partnerApiServer: this.partnerApiServer, - errorCode: axiosError?.response?.data?.error?.code, - errorMessage: axiosError?.response?.data?.error?.message, - httpStatus: axiosError?.response?.status, - needsUserMigration: true, - timestamp: new Date().toISOString(), - }); + logger.warn( + "[Server Limits] Error retrieving limits, expected for unregistered users:", + { + error: error instanceof Error ? error.message : String(error), + did: did, + apiServer: this.apiServer, + imageServer: this.DEFAULT_IMAGE_API_SERVER, + partnerApiServer: this.partnerApiServer, + errorCode: axiosError?.response?.data?.error?.code, + errorMessage: axiosError?.response?.data?.error?.message, + httpStatus: axiosError?.response?.status, + needsUserMigration: true, + timestamp: new Date().toISOString(), + }, + ); // this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD); } finally { diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index 3d09ac42..9671e801 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -91,12 +91,15 @@
- +
@@ -551,7 +554,7 @@ import VueMarkdown from "vue-markdown-render"; import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { copyToClipboard } from "../services/ClipboardService"; -import { GenericVerifiableCredential } from "../interfaces"; +import { EmojiClaim, GenericVerifiableCredential } from "../interfaces"; import GiftedDialog from "../components/GiftedDialog.vue"; import QuickNav from "../components/QuickNav.vue"; import { NotificationIface } from "../constants/app"; @@ -667,6 +670,10 @@ export default class ClaimView extends Vue { return giveClaim.description || ""; } + if (this.veriClaim.claimType === "Emoji") { + return (claim as EmojiClaim).text || ""; + } + // Fallback for other claim types return (claim as { description?: string })?.description || ""; } diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 3e73cda4..718a731f 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -245,6 +245,7 @@ Raymer * @version 1.0.0 */ :last-viewed-claim-id="feedLastViewedClaimId" :is-registered="isRegistered" :active-did="activeDid" + :api-server="apiServer" @load-claim="onClickLoadClaim" @view-image="openImageViewer" /> @@ -705,7 +706,7 @@ export default class HomeView extends Vue { }; logger.warn( - "[HomeView Settings Trace] ⚠️ Registration check failed", + "[HomeView Settings Trace] ⚠️ Registration check failed, expected for unregistered users.", { error: errorMessage, did: this.activeDid, @@ -1264,6 +1265,7 @@ export default class HomeView extends Vue { provider, fulfillsPlan, providedByPlan, + record.emojiCount, ); } @@ -1487,12 +1489,14 @@ export default class HomeView extends Vue { provider: Provider | undefined, fulfillsPlan?: FulfillsPlan, providedByPlan?: ProvidedByPlan, + emojiCount?: Record, ): GiveRecordWithContactInfo { return { ...record, jwtId: record.jwtId, fullClaim: record.fullClaim, description: record.description || "", + emojiCount: emojiCount || {}, handleId: record.handleId, issuerDid: record.issuerDid, fulfillsPlanHandleId: record.fulfillsPlanHandleId, diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index 67923ba1..321f9343 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise { await expect( page.locator("#sectionUsageLimits").getByText("Checking") ).toBeHidden(); + + // PHASE 1 FIX: Wait for registration check to complete and update UI elements + // This ensures that components like InviteOneView have the correct isRegistered status + await waitForRegistrationStatusToSettle(page); + return did; } @@ -337,3 +346,78 @@ export function getElementWaitTimeout(): number { export function getPageLoadTimeout(): number { return getAdaptiveTimeout(30000, 1.4); } + +/** + * PHASE 1 FIX: Wait for registration status to settle + * + * This function addresses the timing issue where: + * 1. User imports identity → Database shows isRegistered: false + * 2. HomeView loads → Starts async registration check + * 3. Other views load → Use cached isRegistered: false + * 4. Async check completes → Updates database to isRegistered: true + * 5. But other views don't re-check → Plus buttons don't appear + * + * This function waits for the async registration check to complete + * without interfering with test navigation. + */ +export async function waitForRegistrationStatusToSettle(page: Page): Promise { + try { + // Wait for the initial registration check to complete + // This is indicated by the "Checking" text disappearing from usage limits + await expect( + page.locator("#sectionUsageLimits").getByText("Checking") + ).toBeHidden({ timeout: 15000 }); + + // Before navigating back to the page, we'll trigger a registration check + // by navigating to home and waiting for the registration process to complete + + const currentUrl = page.url(); + + // Navigate to home to trigger the registration check + await page.goto('./'); + await page.waitForLoadState('networkidle'); + + // Wait for the registration check to complete by monitoring the usage limits section + // This ensures the async registration check has finished + await page.waitForFunction(() => { + const usageLimits = document.querySelector('#sectionUsageLimits'); + if (!usageLimits) return true; // No usage limits section, assume ready + + // Check if the "Checking..." spinner is gone + const checkingSpinner = usageLimits.querySelector('.fa-spin'); + if (checkingSpinner) return false; // Still loading + + // Check if we have actual content (not just the spinner) + const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button'); + return hasContent !== null; // Has actual content, not just spinner + }, { timeout: 10000 }); + + // Also navigate to account page to ensure activeDid is set and usage limits are loaded + await page.goto('./account'); + await page.waitForLoadState('networkidle'); + + // Wait for the usage limits section to be visible and loaded + await page.waitForFunction(() => { + const usageLimits = document.querySelector('#sectionUsageLimits'); + if (!usageLimits) return false; // Section should exist on account page + + // Check if the "Checking..." spinner is gone + const checkingSpinner = usageLimits.querySelector('.fa-spin'); + if (checkingSpinner) return false; // Still loading + + // Check if we have actual content (not just the spinner) + const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button'); + return hasContent !== null; // Has actual content, not just spinner + }, { timeout: 15000 }); + + // Navigate back to the original page if it wasn't home + if (!currentUrl.includes('/')) { + await page.goto(currentUrl); + await page.waitForLoadState('networkidle'); + } + + } catch (error) { + // Registration status check timed out, continuing anyway + // This may indicate the user is not registered or there's a server issue + } +}