Compare commits

...

23 Commits

Author SHA1 Message Date
Jose Olarte III d7db7731cf Merge branch 'master' into meeting-members-admission-dialog 4 days ago
Matthew Raymer 4d9435f257 fix(cursorrules): make system date requirement for documentation only 1 week ago
Matthew Raymer a353ed3c3e Merge branch 'master' into clean-db-disconnects 2 weeks ago
Trent Larson e6cc058935 test: remove a raw 3-second wait from test utils 2 weeks ago
Matthew Raymer 37cff0083f fix: resolve Playwright test timing issues with registration status 2 weeks ago
trentlarson 2049c9b6ec Merge pull request 'emojis' (#209) from emojis into master 2 weeks ago
Matthew Raymer f186e129db refactor(platforms): create BaseDatabaseService to eliminate code duplication 2 weeks ago
Matthew Raymer 455dfadb92 Merge branch 'master' into clean-db-disconnects 2 weeks ago
Trent Larson 637fc10e64 chore: remove emoji-mart-vue-fast that isn't used yet 2 weeks ago
Trent Larson 37d4dcc1a8 feat: add context for Emoji claims 2 weeks ago
Trent Larson c369c76c1a fix: linting 2 weeks ago
Trent Larson 86caf793aa feat: make spinner more standard, show emoji on claim-view page 2 weeks ago
Trent Larson 499fbd2cb3 feat: show a better emoji-confirmation message, hide all emoji stuff from unregistered on items without emojis 2 weeks ago
Trent Larson a4a9293bc2 feat: get the emojis to work with additions, removals, and multiple people 2 weeks ago
Trent Larson 9ac9f1d4a3 feat: add first cut at emojis in feed (incomplete because it doesn't detect user's emojis correctly) 2 weeks ago
Matthew Raymer fface30123 fix(platforms): include accountDid in settings retrieval for both platforms 4 weeks ago
trentlarson 97b382451a Merge branch 'master' into clean-db-disconnects 1 month ago
Matthew Raymer 7fd2c4e0c7 fix(AccountView): resolve stale registration status cache after identity creation 1 month ago
Matthew Raymer 20322789a2 fix(AccountView): resolve stale registration status cache after identity creation 1 month ago
Matthew Raymer 666bed0efd refactor(services): align Capacitor and Web platform services with active_identity architecture 1 month ago
Matthew Raymer 7432525f4c refactor(services): align Capacitor and Web platform services with active_identity architecture 1 month ago
Trent Larson 530cddfab0 fix: linting 1 month ago
Trent Larson 5340c00ae2 fix: remove the duplicate settings for user 0, remove other user-0-specific code, enhance errors 1 month ago
  1. 2
      .cursor/rules/development/development_guide.mdc
  2. 4
      package-lock.json
  3. 293
      src/components/ActivityListItem.vue
  4. 34
      src/db-sql/migration.ts
  5. 7
      src/interfaces/claims.ts
  6. 2
      src/interfaces/common.ts
  7. 39
      src/interfaces/index.ts
  8. 14
      src/interfaces/records.ts
  9. 19
      src/libs/endorserServer.ts
  10. 115
      src/libs/util.ts
  11. 297
      src/services/platforms/BaseDatabaseService.ts
  12. 86
      src/services/platforms/CapacitorPlatformService.ts
  13. 122
      src/services/platforms/WebPlatformService.ts
  14. 27
      src/views/AccountViewView.vue
  15. 15
      src/views/ClaimView.vue
  16. 6
      src/views/HomeView.vue
  17. 84
      test-playwright/testUtils.ts

2
.cursor/rules/development/development_guide.mdc

@ -2,7 +2,7 @@
globs: **/src/**/* globs: **/src/**/*
alwaysApply: false 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 time
✅ remove whitespace at the end of lines ✅ remove whitespace at the end of lines
✅ use npm run lint-fix to check for warnings ✅ use npm run lint-fix to check for warnings

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.0-beta", "version": "1.1.1-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.0-beta", "version": "1.1.1-beta",
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",

293
src/components/ActivityListItem.vue

@ -77,12 +77,86 @@
</a> </a>
</div> </div>
<!-- Description --> <!-- Emoji Section -->
<p class="font-medium overflow-hidden"> <div
<a v-if="hasEmojis || isRegistered"
class="block cursor-pointer overflow-hidden text-ellipsis" class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
@click="emitLoadClaim(record.jwtId)" >
<div class="flex items-center justify-between gap-1">
<!-- Existing Emojis Display -->
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
<button
v-for="(count, emoji) in record.emojiCount"
:key="emoji"
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
:class="{
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
'opacity-75 cursor-wait': loadingEmojis,
}"
:title="
loadingEmojis
? 'Loading...'
: !emojisOnActivity?.isResolved
? 'Click to load your emojis'
: isUserEmojiWithoutLoading(emoji)
? 'Click to remove your emoji'
: 'Click to add this emoji'
"
:disabled="!isRegistered"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-xs">
<font-awesome icon="spinner" class="fa-spin" />
</div>
<span v-else class="text-sm leading-none">{{ emoji }}</span>
<span class="text-xs text-slate-600 font-medium leading-none">{{
count
}}</span>
</button>
</div>
<!-- Add Emoji Button -->
<button
v-if="isRegistered"
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
@click="toggleEmojiPicker"
>
<span class="px-2 text-sm leading-none">{{
showEmojiPicker ? "x" : "😊"
}}</span>
</button>
</div>
<!-- Emoji Picker (placeholder for now) -->
<div
v-if="showEmojiPicker"
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
> >
<!-- Temporary emoji buttons for testing -->
<div class="flex flex-wrap gap-3 mt-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji"
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
:class="{
'opacity-75 cursor-wait': loadingEmojis,
}"
:disabled="loadingEmojis"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-sm"></div>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a class="block cursor-pointer" @click="emitLoadClaim(record.jwtId)">
<vue-markdown <vue-markdown
:source="truncatedDescription" :source="truncatedDescription"
class="markdown-content" class="markdown-content"
@ -91,7 +165,7 @@
</p> </p>
<div <div
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4" class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
> >
<!-- Source --> <!-- Source -->
<div <div
@ -254,17 +328,24 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator"; 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 EntityIcon from "./EntityIcon.vue";
import { isHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue"; import ProjectIcon from "./ProjectIcon.vue";
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify"; import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
import { import {
NOTIFY_PERSON_HIDDEN, NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON, NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { TIMEOUTS } from "@/utils/notify"; import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import VueMarkdown from "vue-markdown-render"; import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
@Component({ @Component({
components: { components: {
@ -274,15 +355,24 @@ import VueMarkdown from "vue-markdown-render";
}, },
}) })
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
@Prop() record!: GiveRecordWithContactInfo; @Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string; @Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean; @Prop() isRegistered!: boolean;
@Prop() activeDid!: string; @Prop() activeDid!: string;
@Prop() apiServer!: string;
isHiddenDid = isHiddenDid; isHiddenDid = isHiddenDid;
notify!: ReturnType<typeof createNotifyHelpers>; notify!: ReturnType<typeof createNotifyHelpers>;
$notify!: NotifyFunction; $notify!: NotifyFunction;
// Emoji-related data
showEmojiPicker = false;
loadingEmojis = false; // Track if emojis are currently loading
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
created() { created() {
this.notify = createNotifyHelpers(this.$notify); this.notify = createNotifyHelpers(this.$notify);
} }
@ -346,5 +436,186 @@ export default class ActivityListItem extends Vue {
day: "numeric", day: "numeric",
}); });
} }
// Emoji-related computed properties and methods
get hasEmojis(): boolean {
return Object.keys(this.record.emojiCount).length > 0;
}
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
if (!this.emojisOnActivity) {
const promise = new Promise<EmojiSummaryRecord[]>((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);
}
}
} }
</script> </script>

34
src/db-sql/migration.ts

@ -234,32 +234,20 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>, sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>, extractMigrationNames: (result: T) => Set<string>,
): Promise<void> { ): Promise<void> {
// Only log migration start in development logger.debug("[Migration] Starting database migrations");
const isDevelopment = process.env.VITE_PLATFORM === "development";
if (isDevelopment) {
logger.debug("[Migration] Starting database migrations");
}
for (const migration of MIGRATIONS) { for (const migration of MIGRATIONS) {
if (isDevelopment) { logger.debug("[Migration] Registering migration:", migration.name);
logger.debug("[Migration] Registering migration:", migration.name);
}
registerMigration(migration); registerMigration(migration);
} }
if (isDevelopment) { logger.debug("[Migration] Running migration service");
logger.debug("[Migration] Running migration service");
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); 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 // Bootstrapping: Ensure active account is selected after migrations
if (isDevelopment) { logger.debug("[Migration] Running bootstrapping hooks");
logger.debug("[Migration] Running bootstrapping hooks");
}
try { try {
// Check if we have accounts but no active selection // Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts"); const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
@ -274,18 +262,14 @@ export async function runMigrations<T>(
activeDid = (extractSingleValue(activeResult) as string) || null; activeDid = (extractSingleValue(activeResult) as string) || null;
} catch (error) { } catch (error) {
// Table doesn't exist - migration 004 may not have run yet // Table doesn't exist - migration 004 may not have run yet
if (isDevelopment) { logger.debug(
logger.debug( "[Migration] active_identity table not found - migration may not have run",
"[Migration] active_identity table not found - migration may not have run", );
);
}
activeDid = null; activeDid = null;
} }
if (accountsCount > 0 && (!activeDid || activeDid === "")) { 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( const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1", "SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
); );

7
src/interfaces/claims.ts

@ -14,6 +14,13 @@ export interface AgreeActionClaim extends ClaimObject {
object: Record<string, unknown>; object: Record<string, unknown>;
} }
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. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4 // https://endorser.ch/doc/html/transactions.html#id4
export interface GiveActionClaim extends ClaimObject { export interface GiveActionClaim extends ClaimObject {

2
src/interfaces/common.ts

@ -81,7 +81,9 @@ export interface UserInfo {
export interface CreateAndSubmitClaimResult { export interface CreateAndSubmitClaimResult {
success: boolean; success: boolean;
embeddedRecordError?: string;
error?: string; error?: string;
claimId?: string;
handleId?: string; handleId?: string;
} }

39
src/interfaces/index.ts

@ -1,37 +1,6 @@
export type { export * from "./claims";
// 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-result"; export * from "./claims-result";
export * from "./common";
export * from "./deepLinks";
export * from "./limits";
export * from "./records"; export * from "./records";

14
src/interfaces/records.ts

@ -1,14 +1,26 @@
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common"; 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 // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveActionClaim; [x: string]:
| PropertyKey
| undefined
| GiveActionClaim
| Record<string, number>;
type?: string; type?: string;
agentDid: string; agentDid: string;
amount: number; amount: number;
amountConfirmed: number; amountConfirmed: number;
description: string; description: string;
emojiCount: Record<string, number>; // Map of emoji character to count
fullClaim: GiveActionClaim; fullClaim: GiveActionClaim;
fulfillsHandleId: string; fulfillsHandleId: string;
fulfillsPlanHandleId?: string; fulfillsPlanHandleId?: string;

19
src/libs/endorserServer.ts

@ -630,11 +630,7 @@ async function performPlanRequest(
return cred; return cred;
} else { } else {
// Use debug level for development to reduce console noise logger.debug(
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log(
"[Plan Loading] ⚠️ Plan cache is empty for handle", "[Plan Loading] ⚠️ Plan cache is empty for handle",
handleId, handleId,
" Got data:", " Got data:",
@ -706,7 +702,7 @@ export function serverMessageForUser(error: unknown): string | undefined {
export function errorStringForLog(error: unknown) { export function errorStringForLog(error: unknown) {
let stringifiedError = "" + error; let stringifiedError = "" + error;
try { try {
stringifiedError = JSON.stringify(error); stringifiedError = safeStringify(error);
} catch (e) { } catch (e) {
// can happen with Dexie, eg: // can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON // TypeError: Converting circular structure to JSON
@ -718,7 +714,7 @@ export function errorStringForLog(error: unknown) {
if (error && typeof error === "object" && "response" in error) { if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse; 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) // 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)) { if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff // add error.response stuff
@ -728,7 +724,7 @@ export function errorStringForLog(error: unknown) {
R.equals(err.config, err.response.config) R.equals(err.config, err.response.config)
) { ) {
// but exclude "config" because it's already in there // but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify( const newErrorResponseText = safeStringify(
R.omit(["config"] as never[], err.response), R.omit(["config"] as never[], err.response),
); );
fullError += fullError +=
@ -1226,7 +1222,12 @@ export async function createAndSubmitClaim(
timestamp: new Date().toISOString(), 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) { } catch (error: unknown) {
// Enhanced error logging with comprehensive context // Enhanced error logging with comprehensive context
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

115
src/libs/util.ts

@ -988,11 +988,6 @@ export async function importFromMnemonic(
): Promise<void> { ): Promise<void> {
const mne: string = mnemonic.trim().toLowerCase(); 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 // Derive address and keys from mnemonic
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath); const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
@ -1007,90 +1002,6 @@ export async function importFromMnemonic(
// Save the new identity // Save the new identity
await saveNewIdentity(newId, mne, derivationPath); 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; return (existingAccount?.values?.length ?? 0) > 0;
} }
export class PromiseTracker<T> {
private _promise: Promise<T>;
private _resolved = false;
private _value: T | undefined;
constructor(promise: Promise<T>) {
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<T> {
return this._promise;
}
}

297
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<QueryExecResult> {
* // 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<string, unknown>,
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<string, unknown>,
): Promise<void> {
// 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<void> {
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<void> {
// 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<string, unknown>,
): Promise<void> {
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<Record<
string,
unknown
> | 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<string, unknown> = {};
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<unknown>;
/**
* 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<unknown>;
}

86
src/services/platforms/CapacitorPlatformService.ts

@ -22,6 +22,7 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
interface QueuedOperation { interface QueuedOperation {
type: "run" | "query" | "rawQuery"; type: "run" | "query" | "rawQuery";
@ -39,7 +40,10 @@ interface QueuedOperation {
* - Platform-specific features * - Platform-specific features
* - SQLite database operations * - SQLite database operations
*/ */
export class CapacitorPlatformService implements PlatformService { export class CapacitorPlatformService
extends BaseDatabaseService
implements PlatformService
{
/** Current camera direction */ /** Current camera direction */
private currentDirection: CameraDirection = CameraDirection.Rear; private currentDirection: CameraDirection = CameraDirection.Rear;
@ -52,6 +56,7 @@ export class CapacitorPlatformService implements PlatformService {
private isProcessingQueue: boolean = false; private isProcessingQueue: boolean = false;
constructor() { constructor() {
super();
this.sqlite = new SQLiteConnection(CapacitorSQLite); this.sqlite = new SQLiteConnection(CapacitorSQLite);
} }
@ -1328,79 +1333,8 @@ export class CapacitorPlatformService implements PlatformService {
// --- PWA/Web-only methods (no-op for Capacitor) --- // --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {} public registerServiceWorker(): void {}
// Database utility methods // Database utility methods - inherited from BaseDatabaseService
generateInsertStatement( // generateInsertStatement, updateDefaultSettings, updateActiveDid,
model: Record<string, unknown>, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
tableName: string, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
): { 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<string, unknown>,
): Promise<void> {
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<void> {
await this.dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
}
async insertNewDidIntoSettings(did: string): Promise<void> {
// 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<string, unknown>,
): Promise<void> {
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<Record<
string,
unknown
> | 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<string, unknown> = {};
columns.forEach((column, index) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
}
});
return settings;
}
return null;
}
} }

122
src/services/platforms/WebPlatformService.ts

@ -5,6 +5,7 @@ import {
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
import { BaseDatabaseService } from "./BaseDatabaseService";
// Dynamic import of initBackend to prevent worker context errors // Dynamic import of initBackend to prevent worker context errors
import type { import type {
WorkerRequest, WorkerRequest,
@ -29,7 +30,10 @@ import type {
* Note: File system operations are not available in the web platform * Note: File system operations are not available in the web platform
* due to browser security restrictions. These methods throw appropriate errors. * 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 static instanceCount = 0; // Debug counter
private worker: Worker | null = null; private worker: Worker | null = null;
private workerReady = false; private workerReady = false;
@ -46,17 +50,16 @@ export class WebPlatformService implements PlatformService {
private readonly messageTimeout = 30000; // 30 seconds private readonly messageTimeout = 30000; // 30 seconds
constructor() { constructor() {
super();
WebPlatformService.instanceCount++; WebPlatformService.instanceCount++;
// Use debug level logging for development mode to reduce console noise logger.debug("[WebPlatformService] Initializing web platform service");
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log("[WebPlatformService] Initializing web platform service");
// Only initialize SharedArrayBuffer setup for web platforms // Only initialize SharedArrayBuffer setup for web platforms
if (this.isWorker()) { if (this.isWorker()) {
log("[WebPlatformService] Skipping initBackend call in worker context"); logger.debug(
"[WebPlatformService] Skipping initBackend call in worker context",
);
return; return;
} }
@ -670,105 +673,8 @@ export class WebPlatformService implements PlatformService {
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
} }
// Database utility methods // Database utility methods - inherited from BaseDatabaseService
generateInsertStatement( // generateInsertStatement, updateDefaultSettings, updateActiveDid,
model: Record<string, unknown>, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
tableName: string, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
): { 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<string, unknown>,
): Promise<void> {
// 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<void> {
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<void> {
// 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<string, unknown>,
): Promise<void> {
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<Record<
string,
unknown
> | 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<string, unknown> = {};
columns.forEach((column, index) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
}
});
return settings;
}
return null;
}
} }

27
src/views/AccountViewView.vue

@ -1488,18 +1488,21 @@ export default class AccountViewView extends Vue {
status?: number; status?: number;
}; };
}; };
logger.error("[Server Limits] Error retrieving limits:", { logger.warn(
error: error instanceof Error ? error.message : String(error), "[Server Limits] Error retrieving limits, expected for unregistered users:",
did: did, {
apiServer: this.apiServer, error: error instanceof Error ? error.message : String(error),
imageServer: this.DEFAULT_IMAGE_API_SERVER, did: did,
partnerApiServer: this.partnerApiServer, apiServer: this.apiServer,
errorCode: axiosError?.response?.data?.error?.code, imageServer: this.DEFAULT_IMAGE_API_SERVER,
errorMessage: axiosError?.response?.data?.error?.message, partnerApiServer: this.partnerApiServer,
httpStatus: axiosError?.response?.status, errorCode: axiosError?.response?.data?.error?.code,
needsUserMigration: true, errorMessage: axiosError?.response?.data?.error?.message,
timestamp: new Date().toISOString(), httpStatus: axiosError?.response?.status,
}); needsUserMigration: true,
timestamp: new Date().toISOString(),
},
);
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD); // this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
} finally { } finally {

15
src/views/ClaimView.vue

@ -91,12 +91,15 @@
<div class="text-sm overflow-hidden"> <div class="text-sm overflow-hidden">
<div <div
data-testId="description" data-testId="description"
class="overflow-hidden text-ellipsis" class="flex items-start gap-2 overflow-hidden"
> >
<font-awesome icon="message" class="fa-fw text-slate-400" /> <font-awesome
icon="message"
class="fa-fw text-slate-400 flex-shrink-0 mt-1"
/>
<vue-markdown <vue-markdown
:source="claimDescription" :source="claimDescription"
class="markdown-content" class="markdown-content flex-1 min-w-0"
/> />
</div> </div>
<div class="overflow-hidden text-ellipsis"> <div class="overflow-hidden text-ellipsis">
@ -551,7 +554,7 @@ import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService"; import { copyToClipboard } from "../services/ClipboardService";
import { GenericVerifiableCredential } from "../interfaces"; import { EmojiClaim, GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
@ -667,6 +670,10 @@ export default class ClaimView extends Vue {
return giveClaim.description || ""; return giveClaim.description || "";
} }
if (this.veriClaim.claimType === "Emoji") {
return (claim as EmojiClaim).text || "";
}
// Fallback for other claim types // Fallback for other claim types
return (claim as { description?: string })?.description || ""; return (claim as { description?: string })?.description || "";
} }

6
src/views/HomeView.vue

@ -245,6 +245,7 @@ Raymer * @version 1.0.0 */
:last-viewed-claim-id="feedLastViewedClaimId" :last-viewed-claim-id="feedLastViewedClaimId"
:is-registered="isRegistered" :is-registered="isRegistered"
:active-did="activeDid" :active-did="activeDid"
:api-server="apiServer"
@load-claim="onClickLoadClaim" @load-claim="onClickLoadClaim"
@view-image="openImageViewer" @view-image="openImageViewer"
/> />
@ -705,7 +706,7 @@ export default class HomeView extends Vue {
}; };
logger.warn( logger.warn(
"[HomeView Settings Trace] ⚠️ Registration check failed", "[HomeView Settings Trace] ⚠️ Registration check failed, expected for unregistered users.",
{ {
error: errorMessage, error: errorMessage,
did: this.activeDid, did: this.activeDid,
@ -1264,6 +1265,7 @@ export default class HomeView extends Vue {
provider, provider,
fulfillsPlan, fulfillsPlan,
providedByPlan, providedByPlan,
record.emojiCount,
); );
} }
@ -1487,12 +1489,14 @@ export default class HomeView extends Vue {
provider: Provider | undefined, provider: Provider | undefined,
fulfillsPlan?: FulfillsPlan, fulfillsPlan?: FulfillsPlan,
providedByPlan?: ProvidedByPlan, providedByPlan?: ProvidedByPlan,
emojiCount?: Record<string, number>,
): GiveRecordWithContactInfo { ): GiveRecordWithContactInfo {
return { return {
...record, ...record,
jwtId: record.jwtId, jwtId: record.jwtId,
fullClaim: record.fullClaim, fullClaim: record.fullClaim,
description: record.description || "", description: record.description || "",
emojiCount: emojiCount || {},
handleId: record.handleId, handleId: record.handleId,
issuerDid: record.issuerDid, issuerDid: record.issuerDid,
fulfillsPlanHandleId: record.fulfillsPlanHandleId, fulfillsPlanHandleId: record.fulfillsPlanHandleId,

84
test-playwright/testUtils.ts

@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise<st
await page.getByRole("button", { name: "Import" }).click(); await page.getByRole("button", { name: "Import" }).click();
// PHASE 1 FIX: Wait for registration status to settle
// This ensures that components have the correct isRegistered status
await waitForRegistrationStatusToSettle(page);
return userZeroData.did; return userZeroData.did;
} }
@ -69,6 +73,11 @@ export async function importUser(page: Page, id?: string): Promise<string> {
await expect( await expect(
page.locator("#sectionUsageLimits").getByText("Checking") page.locator("#sectionUsageLimits").getByText("Checking")
).toBeHidden(); ).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; return did;
} }
@ -337,3 +346,78 @@ export function getElementWaitTimeout(): number {
export function getPageLoadTimeout(): number { export function getPageLoadTimeout(): number {
return getAdaptiveTimeout(30000, 1.4); 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<void> {
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
}
}

Loading…
Cancel
Save