Compare commits

..

15 Commits

Author SHA1 Message Date
Matthew Raymer
944f5bc224 fix(api): prevent duplicate feed and search requests
Add concurrency guards to prevent duplicate API requests when
methods are called multiple times before completion.

- HomeView.updateAllFeed(): guard against concurrent calls with
  retry count limit to prevent infinite recursion
- DiscoverView.searchAll(): guard initial searches while allowing
  concurrent pagination
- DiscoverView.searchStarred(): guard against concurrent calls

These changes prevent duplicate network requests while preserving
legitimate concurrent pagination behavior.
2025-10-29 11:37:09 +00:00
Matthew Raymer
4d9435f257 fix(cursorrules): make system date requirement for documentation only 2025-10-27 03:06:52 +00:00
Matthew Raymer
a353ed3c3e Merge branch 'master' into clean-db-disconnects 2025-10-24 08:00:17 +00:00
e6cc058935 test: remove a raw 3-second wait from test utils 2025-10-23 18:04:05 -06:00
Matthew Raymer
37cff0083f fix: resolve Playwright test timing issues with registration status
- Fix async registration check timing in test utilities
- Resolve plus button visibility issues in InviteOneView
- Fix usage limits section loading timing in AccountViewView
- Ensure activeDid is properly set before component rendering

The root cause was timing mismatches between:
1. Async registration checks completing after UI components loaded
2. Usage limits API calls completing after tests expected content
3. ActiveDid initialization completing after conditional rendering

Changes:
- Enhanced waitForRegistrationStatusToSettle() in testUtils.ts
- Added comprehensive timing checks for registration status
- Added usage limits loading verification
- Added activeDid initialization waiting
- Improved error handling and timeout management

Impact:
- All 44 Playwright tests now passing (100% success rate)
- Resolves button click timeouts in invite, project, and offer tests
- Fixes usage limits visibility issues
- Works across both Chromium and Firefox browsers
- Maintains clean, production-ready code without debug logging

Fixes: Multiple test failures including:
- 05-invite.spec.ts: "Check User 0 can invite someone"
- 10-check-usage-limits.spec.ts: "Check usage limits"
- 20-create-project.spec.ts: "Create new project, then search for it"
- 25-create-project-x10.spec.ts: "Create 10 new projects"
- 30-record-gift.spec.ts: "Record something given"
- 37-record-gift-on-project.spec.ts: Project gift tests
- 50-record-offer.spec.ts: Offer tests
2025-10-23 04:17:30 +00:00
Matthew Raymer
f186e129db refactor(platforms): create BaseDatabaseService to eliminate code duplication
- Create abstract BaseDatabaseService class with common database operations
- Extract 7 duplicate methods from WebPlatformService and CapacitorPlatformService
- Ensure consistent database logic across all platform implementations
- Fix constructor inheritance issues with proper super() calls
- Improve maintainability by centralizing database operations

Methods consolidated:
- generateInsertStatement
- updateDefaultSettings
- updateActiveDid
- getActiveIdentity
- insertNewDidIntoSettings
- updateDidSpecificSettings
- retrieveSettingsForActiveAccount

Architecture:
- BaseDatabaseService (abstract base class)
- WebPlatformService extends BaseDatabaseService
- CapacitorPlatformService extends BaseDatabaseService
- ElectronPlatformService extends CapacitorPlatformService

Benefits:
- Eliminates ~200 lines of duplicate code
- Guarantees consistency across platforms
- Single point of maintenance for database operations
- Prevents platform-specific bugs in database logic

Author: Matthew Raymer
Timestamp: Wed Oct 22 07:26:38 AM UTC 2025
2025-10-22 07:26:38 +00:00
Matthew Raymer
455dfadb92 Merge branch 'master' into clean-db-disconnects
- Resolves merge conflicts from master branch integration
- Includes latest features and bug fixes from master
- Maintains clean-db-disconnects branch functionality

Files affected: Multiple components, views, and utilities
Timestamp: Wed Oct 22 07:26:21 AM UTC 2025
2025-10-22 07:26:21 +00:00
Matthew Raymer
fface30123 fix(platforms): include accountDid in settings retrieval for both platforms
- Remove accountDid exclusion from settings object construction in CapacitorPlatformService
- Remove accountDid exclusion from settings object construction in WebPlatformService
- Ensure accountDid is included in retrieved settings for proper DID-specific configuration handling

This change ensures that the accountDid field is properly included when retrieving
settings for the active account, allowing for proper DID-specific configuration
management across both Capacitor (mobile) and Web platforms.

Files modified:
- src/services/platforms/CapacitorPlatformService.ts
- src/services/platforms/WebPlatformService.ts

Timestamp: Wed Oct 8 03:05:45 PM UTC 2025
2025-10-08 15:06:16 +00:00
97b382451a Merge branch 'master' into clean-db-disconnects 2025-10-03 22:18:24 -04:00
Matthew Raymer
7fd2c4e0c7 fix(AccountView): resolve stale registration status cache after identity creation
- Add live registration verification to AccountView.initializeState()
- When settings show unregistered but user has activeDid, verify with server
- Use fetchEndorserRateLimits() matching HomeView's successful pattern
- Update database and UI state immediately upon server confirmation
- Eliminate need to navigate away/back to refresh registration status

Technical details:
- Condition: if (!this.isRegistered && this.activeDid)
- Server check: fetchEndorserRateLimits(this.apiServer, this.axios, this.activeDid)
- On success: $saveUserSettings({isRegistered: true}) + this.isRegistered = true
- Graceful handling for actually unregistered users (expected behavior)

Fixes issue where AccountView showed "Before you can publicly announce..."
message immediately after User Zero identity creation, despite server confirming
user was registered. Problem was Vue component state caching stale settings
while database contained updated registration status.

Resolves behavior reported in iOS testing: User had to navigate to HomeView
and back to AccountView for registration status to update properly.
2025-10-02 08:28:35 +00:00
Matthew Raymer
20322789a2 fix(AccountView): resolve stale registration status cache after identity creation
- Add live registration verification to AccountView.initializeState()
- When settings show unregistered but user has activeDid, verify with server
- Use fetchEndorserRateLimits() matching HomeView's successful pattern
- Update database and UI state immediately upon server confirmation
- Eliminate need to navigate away/back to refresh registration status

Technical details:
- Condition: if (!this.isRegistered && this.activeDid)
- Server check: fetchEndorserRateLimits(this.apiServer, this.axios, this.activeDid)
- On success: $saveUserSettings({isRegistered: true}) + this.isRegistered = true
- Graceful handling for actually unregistered users (expected behavior)

Fixes issue where AccountView showed "Before you can publicly announce..."
message immediately after User Zero identity creation, despite server confirming
user was registered. Problem was Vue component state caching stale settings
while database contained updated registration status.

Resolves behavior reported in iOS testing: User had to navigate to HomeView
and back to AccountView for registration status to update properly.
2025-10-02 08:27:56 +00:00
Matthew Raymer
666bed0efd refactor(services): align Capacitor and Web platform services with active_identity architecture
- Update CapacitorPlatformService.updateDefaultSettings() to use active_identity table instead of hard-coded id=1
- Update CapacitorPlatformService.retrieveSettingsForActiveAccount() to query by accountDid from active_identity
- Add getActiveIdentity() method to CapacitorPlatformService for consistency with WebPlatformService
- Update WebPlatformService.retrieveSettingsForActiveAccount() to match CapacitorPlatformService pattern
- Both services now consistently use active_identity table instead of legacy MASTER_SETTINGS_KEY approach
- Maintains backward compatibility with databaseUtil.ts for PWA migration support

Technical details:
- CapacitorPlatformService: Fixed hard-coded WHERE id = 1 → WHERE accountDid = ?
- WebPlatformService: Fixed retrieval pattern to match new architecture
- Platform services now aligned with migration 004 active_identity table schema
- databaseUtil.ts remains unchanged for PWA-to-SQLite migration bridge
2025-10-02 06:31:03 +00:00
Matthew Raymer
7432525f4c refactor(services): align Capacitor and Web platform services with active_identity architecture
- Update CapacitorPlatformService.updateDefaultSettings() to use active_identity table instead of hard-coded id=1
- Update CapacitorPlatformService.retrieveSettingsForActiveAccount() to query by accountDid from active_identity
- Add getActiveIdentity() method to CapacitorPlatformService for consistency with WebPlatformService
- Update WebPlatformService.retrieveSettingsForActiveAccount() to match CapacitorPlatformService pattern
- Both services now consistently use active_identity table instead of legacy MASTER_SETTINGS_KEY approach
- Maintains backward compatibility with databaseUtil.ts for PWA migration support

Technical details:
- CapacitorPlatformService: Fixed hard-coded WHERE id = 1 → WHERE accountDid = ?
- WebPlatformService: Fixed retrieval pattern to match new architecture
- Platform services now aligned with migration 004 active_identity table schema
- databaseUtil.ts remains unchanged for PWA-to-SQLite migration bridge
2025-10-02 06:29:56 +00:00
530cddfab0 fix: linting 2025-09-29 08:07:54 -06:00
5340c00ae2 fix: remove the duplicate settings for user 0, remove other user-0-specific code, enhance errors 2025-09-28 20:24:49 -06:00
20 changed files with 739 additions and 701 deletions

View File

@@ -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

View File

@@ -38,7 +38,7 @@
}
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* Markdown content styling to restore list elements */

View File

@@ -2,55 +2,12 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<ul :class="gridClasses">
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()"
v-if="showYouEntity"
entity-type="you"
label="You"
icon="hand"
@@ -64,7 +21,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -82,60 +38,16 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<template v-else-if="entityType === 'projects'">
@@ -151,27 +63,28 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
@@ -180,6 +93,7 @@ const RECENT_CONTACTS_COUNT = 3;
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -190,6 +104,7 @@ const RECENT_CONTACTS_COUNT = 3;
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
})
export default class EntityGrid extends Vue {
@@ -197,21 +112,14 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
@@ -232,14 +140,18 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -248,31 +160,42 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
* are displayed in the grid, enabling advanced filtering, sorting, and
* display logic beyond the default simple slice behavior.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -283,60 +206,33 @@ export default class EntityGrid extends Vue {
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
* Computed CSS classes for the grid layout
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
*/
get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the 3 most recently added contacts (when showing contacts and not searching)
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
}
/**
@@ -350,6 +246,15 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -423,144 +328,6 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -573,33 +340,6 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>

View File

@@ -3,9 +3,10 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -15,14 +16,18 @@ properties * * @author Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -63,6 +68,7 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -148,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -212,6 +222,59 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/

View File

@@ -29,6 +29,7 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -116,6 +117,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -231,7 +233,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contactsByDateAdded();
this.allContacts = await this.$contacts();
this.allMyDids = await retrieveAccountDids();

View File

@@ -3,25 +3,30 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div>
<div class="relative w-fit mx-auto">
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1 shrink-0"
class="text-slate-400 text-5xl mb-1"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
</li>
</template>
@@ -76,32 +81,29 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return "opacity-50 cursor-not-allowed";
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return "cursor-pointer hover:bg-slate-50";
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
return `${baseClasses} text-slate-400`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseNameClasses} italic text-slate-500`;
return `${baseClasses} italic text-slate-500`;
}
return baseNameClasses;
return baseClasses;
}
/**

View File

@@ -2,26 +2,25 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
</div>
</li>
</template>

View File

@@ -0,0 +1,66 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

View File

@@ -63,24 +63,23 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card
* Computed CSS classes for the card container
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
const baseClasses = "block";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return `${baseClasses} cursor-not-allowed opacity-50`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return `${baseClasses} cursor-pointer`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-[2rem]";
const baseClasses = "text-5xl mb-1";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -102,7 +101,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;

View File

@@ -702,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
@@ -714,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
@@ -724,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 +=

View File

@@ -988,11 +988,6 @@ export async function importFromMnemonic(
): Promise<void> {
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
}
}
}
/**

View File

@@ -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>;
}

View File

@@ -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<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 };
}
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;
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
}

View File

@@ -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,6 +50,7 @@ export class WebPlatformService implements PlatformService {
private readonly messageTimeout = 30000; // 30 seconds
constructor() {
super();
WebPlatformService.instanceCount++;
logger.debug("[WebPlatformService] Initializing web platform service");
@@ -668,105 +673,8 @@ export class WebPlatformService implements PlatformService {
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
}
// Database utility methods
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 };
}
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;
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
}

View File

@@ -19,6 +19,7 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -38,6 +39,7 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -150,8 +152,11 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities.filter((person) => person.profileImageUrl);
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
};
/**
@@ -160,6 +165,7 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -194,16 +200,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people").length;
return this.customPeopleFunction(this.people, "people", 5).length;
}
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
return Math.min(5, this.people.length);
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects").length;
return this.customProjectsFunction(this.projects, "projects", 3).length;
}
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
return Math.min(7, this.projects.length);
}
}
</script>

View File

@@ -970,20 +970,6 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -2071,7 +2057,6 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -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 {

View File

@@ -536,6 +536,14 @@ export default class DiscoverView extends Vue {
}
public async searchAll(beforeId?: string) {
// Guard against concurrent calls (allow pagination concurrent calls)
if (this.isLoading && !beforeId) {
logger.debug(
"[DiscoverView] ⚠️ searchAll() already in progress, skipping",
);
return;
}
this.resetCounts();
if (!beforeId) {
@@ -601,6 +609,14 @@ export default class DiscoverView extends Vue {
}
public async searchStarred() {
// Guard against concurrent calls
if (this.isLoading) {
logger.debug(
"[DiscoverView] ⚠️ searchStarred() already in progress, skipping",
);
return;
}
this.resetCounts();
// Clear any previous results

View File

@@ -706,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,
@@ -1091,17 +1091,27 @@ export default class HomeView extends Vue {
* - this.feedData (via processFeedResults)
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
*/
async updateAllFeed() {
async updateAllFeed(retryCount: number = 0) {
// Guard against concurrent calls (but allow retries)
if (this.isFeedLoading && retryCount === 0) {
logger.debug(
"[HomeView] ⚠️ updateAllFeed() already in progress, skipping",
);
return;
}
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
isFeedLoading: this.isFeedLoading,
currentFeedDataLength: this.feedData.length,
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
retryCount,
});
this.isFeedLoading = true;
let endOfResults = true;
const MAX_RETRIES = 5; // Prevent infinite recursion
try {
const results = await this.retrieveGives(
@@ -1127,11 +1137,24 @@ export default class HomeView extends Vue {
} catch (e) {
logger.error("[HomeView] ❌ Error in updateAllFeed:", e);
this.handleFeedError(e);
// Don't retry on error
endOfResults = true;
}
if (this.feedData.length === 0 && !endOfResults) {
logger.debug("[HomeView] 🔄 No results after filtering, retrying...");
await this.updateAllFeed();
// Fixed recursive retry with guard and retry count
if (
this.feedData.length === 0 &&
!endOfResults &&
retryCount < MAX_RETRIES
) {
logger.debug("[HomeView] 🔄 No results after filtering, retrying...", {
retryCount: retryCount + 1,
maxRetries: MAX_RETRIES,
});
// Temporarily clear loading flag for recursive call
this.isFeedLoading = false;
await this.updateAllFeed(retryCount + 1);
return; // Exit after recursive call
}
this.isFeedLoading = false;

View File

@@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise<st
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;
}
@@ -69,6 +73,11 @@ export async function importUser(page: Page, id?: string): Promise<string> {
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<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
}
}