Compare commits

..

2 Commits

67 changed files with 2285 additions and 2507 deletions

View File

@@ -1,267 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# wa-sqlite Usage Guide
## Table of Contents
- [1. Overview](#1-overview)
- [2. Installation](#2-installation)
- [3. Basic Setup](#3-basic-setup)
- [3.1 Import and Initialize](#31-import-and-initialize)
- [3.2 Basic Database Operations](#32-basic-database-operations)
- [4. Virtual File Systems (VFS)](#4-virtual-file-systems-vfs)
- [4.1 Available VFS Options](#41-available-vfs-options)
- [4.2 Using a VFS](#42-using-a-vfs)
- [5. Best Practices](#5-best-practices)
- [5.1 Error Handling](#51-error-handling)
- [5.2 Transaction Management](#52-transaction-management)
- [5.3 Prepared Statements](#53-prepared-statements)
- [6. Performance Considerations](#6-performance-considerations)
- [7. Common Issues and Solutions](#7-common-issues-and-solutions)
- [8. TypeScript Support](#8-typescript-support)
## 1. Overview
wa-sqlite is a WebAssembly build of SQLite that enables SQLite database operations in web browsers and JavaScript environments. It provides both synchronous and asynchronous builds, with support for custom virtual file systems (VFS) for persistent storage.
## 2. Installation
```bash
npm install wa-sqlite
# or
yarn add wa-sqlite
```
## 3. Basic Setup
### 3.1 Import and Initialize
```javascript
// Choose one of these imports based on your needs:
// - wa-sqlite.mjs: Synchronous build
// - wa-sqlite-async.mjs: Asynchronous build (required for async VFS)
// - wa-sqlite-jspi.mjs: JSPI-based async build (experimental, Chromium only)
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
import * as SQLite from 'wa-sqlite';
async function initDatabase() {
// Initialize SQLite module
const module = await SQLiteESMFactory();
const sqlite3 = SQLite.Factory(module);
// Open database (returns a Promise)
const db = await sqlite3.open_v2('myDatabase');
return { sqlite3, db };
}
```
### 3.2 Basic Database Operations
```javascript
async function basicOperations() {
const { sqlite3, db } = await initDatabase();
try {
// Create a table
await sqlite3.exec(db, `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
// Insert data
await sqlite3.exec(db, `
INSERT INTO users (name, email)
VALUES ('John Doe', 'john@example.com')
`);
// Query data
const results = [];
await sqlite3.exec(db, 'SELECT * FROM users', (row, columns) => {
results.push({ row, columns });
});
return results;
} finally {
// Always close the database when done
await sqlite3.close(db);
}
}
```
## 4. Virtual File Systems (VFS)
### 4.1 Available VFS Options
wa-sqlite provides several VFS implementations for persistent storage:
1. **IDBBatchAtomicVFS** (Recommended for general use)
- Uses IndexedDB with batch atomic writes
- Works in all contexts (Window, Worker, Service Worker)
- Supports WAL mode
- Best performance with `PRAGMA synchronous=normal`
2. **IDBMirrorVFS**
- Keeps files in memory, persists to IndexedDB
- Works in all contexts
- Good for smaller databases
3. **OPFS-based VFS** (Origin Private File System)
- Various implementations available:
- AccessHandlePoolVFS
- OPFSAdaptiveVFS
- OPFSCoopSyncVFS
- OPFSPermutedVFS
- Better performance but limited to Worker contexts
### 4.2 Using a VFS
```javascript
import { IDBBatchAtomicVFS } from 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js';
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs';
import * as SQLite from 'wa-sqlite';
async function initDatabaseWithVFS() {
const module = await SQLiteESMFactory();
const sqlite3 = SQLite.Factory(module);
// Register VFS
const vfs = await IDBBatchAtomicVFS.create('myApp', module);
sqlite3.vfs_register(vfs, true);
// Open database with VFS
const db = await sqlite3.open_v2('myDatabase');
// Configure for better performance
await sqlite3.exec(db, 'PRAGMA synchronous = normal');
await sqlite3.exec(db, 'PRAGMA journal_mode = WAL');
return { sqlite3, db };
}
```
## 5. Best Practices
### 5.1 Error Handling
```javascript
async function safeDatabaseOperation() {
const { sqlite3, db } = await initDatabase();
try {
await sqlite3.exec(db, 'SELECT * FROM non_existent_table');
} catch (error) {
if (error.code === SQLite.SQLITE_ERROR) {
console.error('SQL error:', error.message);
} else {
console.error('Database error:', error);
}
} finally {
await sqlite3.close(db);
}
}
```
### 5.2 Transaction Management
```javascript
async function transactionExample() {
const { sqlite3, db } = await initDatabase();
try {
await sqlite3.exec(db, 'BEGIN TRANSACTION');
// Perform multiple operations
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Alice']);
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Bob']);
await sqlite3.exec(db, 'COMMIT');
} catch (error) {
await sqlite3.exec(db, 'ROLLBACK');
throw error;
} finally {
await sqlite3.close(db);
}
}
```
### 5.3 Prepared Statements
```javascript
async function preparedStatementExample() {
const { sqlite3, db } = await initDatabase();
try {
// Prepare statement
const stmt = await sqlite3.prepare(db, 'SELECT * FROM users WHERE id = ?');
// Execute with different parameters
await sqlite3.bind(stmt, 1, 1);
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) {
const row = sqlite3.row(stmt);
console.log(row);
}
// Reset and reuse
await sqlite3.reset(stmt);
await sqlite3.bind(stmt, 1, 2);
// ... execute again
await sqlite3.finalize(stmt);
} finally {
await sqlite3.close(db);
}
}
```
## 6. Performance Considerations
1. **VFS Selection**
- Use IDBBatchAtomicVFS for general-purpose applications
- Consider OPFS-based VFS for better performance in Worker contexts
- Use MemoryVFS for temporary databases
2. **Configuration**
- Set appropriate page size (default is usually fine)
- Use WAL mode for better concurrency
- Consider `PRAGMA synchronous=normal` for better performance
- Adjust cache size based on your needs
3. **Concurrency**
- Use transactions for multiple operations
- Be aware of VFS-specific concurrency limitations
- Consider using Web Workers for heavy database operations
## 7. Common Issues and Solutions
1. **Database Locking**
- Use appropriate transaction isolation levels
- Implement retry logic for busy errors
- Consider using WAL mode
2. **Storage Limitations**
- Be aware of browser storage quotas
- Implement cleanup strategies
- Monitor database size
3. **Cross-Context Access**
- Use appropriate VFS for your context
- Consider message passing for cross-context communication
- Be aware of storage access limitations
## 8. TypeScript Support
wa-sqlite includes TypeScript definitions. The main types are:
```typescript
type SQLiteCompatibleType = number | string | Uint8Array | Array<number> | bigint | null;
interface SQLiteAPI {
open_v2(filename: string, flags?: number, zVfs?: string): Promise<number>;
exec(db: number, sql: string, callback?: (row: any[], columns: string[]) => void): Promise<number>;
close(db: number): Promise<number>;
// ... other methods
}
```
## Additional Resources
- [Official GitHub Repository](https://github.com/rhashimoto/wa-sqlite)
- [Online Demo](https://rhashimoto.github.io/wa-sqlite/demo/)
- [API Reference](https://rhashimoto.github.io/wa-sqlite/docs/)
- [FAQ](https://github.com/rhashimoto/wa-sqlite/issues?q=is%3Aissue+label%3Afaq+)
- [Discussion Forums](https://github.com/rhashimoto/wa-sqlite/discussions)

5
.gitignore vendored
View File

@@ -51,7 +51,6 @@ vendor/
# Build logs
build_logs/
# PWA icon files generated by capacitor-assets
icons
android/app/src/main/assets/public
android/app/src/main/res

View File

@@ -343,11 +343,7 @@ Prerequisites: macOS with Xcode installed
3. Copy the assets:
```bash
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
npx capacitor-assets generate --ios
```

View File

@@ -1,5 +1,5 @@
{
"appId": "app.timesafari.app",
"appId": "app.timesafari",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icon-only.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,554 +0,0 @@
# Migration Guide: Dexie to wa-sqlite
## Overview
This document outlines the migration process from Dexie.js to wa-sqlite for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
## Migration Goals
1. **Data Integrity**
- Preserve all existing data
- Maintain data relationships
- Ensure data consistency
2. **Performance**
- Improve query performance
- Reduce storage overhead
- Optimize for platform-specific features
3. **Security**
- Maintain or improve encryption
- Preserve access controls
- Enhance data protection
4. **User Experience**
- Zero data loss
- Minimal downtime
- Automatic migration where possible
## Prerequisites
1. **Backup Requirements**
```typescript
interface MigrationBackup {
timestamp: number;
accounts: Account[];
settings: Setting[];
contacts: Contact[];
metadata: {
version: string;
platform: string;
dexieVersion: string;
};
}
```
2. **Storage Requirements**
- Sufficient IndexedDB quota
- Available disk space for SQLite
- Backup storage space
3. **Platform Support**
- Web: Modern browser with IndexedDB support
- iOS: iOS 13+ with SQLite support
- Android: Android 5+ with SQLite support
- Electron: Latest version with SQLite support
## Migration Process
### 1. Preparation
```typescript
// src/services/storage/migration/MigrationService.ts
export class MigrationService {
private static instance: MigrationService;
private backup: MigrationBackup | null = null;
async prepare(): Promise<void> {
try {
// 1. Check prerequisites
await this.checkPrerequisites();
// 2. Create backup
this.backup = await this.createBackup();
// 3. Verify backup integrity
await this.verifyBackup();
// 4. Initialize wa-sqlite
await this.initializeWaSqlite();
} catch (error) {
throw new StorageError(
'Migration preparation failed',
StorageErrorCodes.MIGRATION_FAILED,
error
);
}
}
private async checkPrerequisites(): Promise<void> {
// Check IndexedDB availability
if (!window.indexedDB) {
throw new StorageError(
'IndexedDB not available',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
// Check storage quota
const quota = await navigator.storage.estimate();
if (quota.quota && quota.usage && quota.usage > quota.quota * 0.9) {
throw new StorageError(
'Insufficient storage space',
StorageErrorCodes.STORAGE_FULL
);
}
// Check platform support
const capabilities = await PlatformDetection.getCapabilities();
if (!capabilities.hasFileSystem) {
throw new StorageError(
'Platform does not support required features',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
}
private async createBackup(): Promise<MigrationBackup> {
const dexieDB = new Dexie('TimeSafariDB');
return {
timestamp: Date.now(),
accounts: await dexieDB.accounts.toArray(),
settings: await dexieDB.settings.toArray(),
contacts: await dexieDB.contacts.toArray(),
metadata: {
version: '1.0.0',
platform: await PlatformDetection.getPlatform(),
dexieVersion: Dexie.version
}
};
}
}
```
### 2. Data Migration
```typescript
// src/services/storage/migration/DataMigration.ts
export class DataMigration {
async migrate(backup: MigrationBackup): Promise<void> {
try {
// 1. Create new database schema
await this.createSchema();
// 2. Migrate accounts
await this.migrateAccounts(backup.accounts);
// 3. Migrate settings
await this.migrateSettings(backup.settings);
// 4. Migrate contacts
await this.migrateContacts(backup.contacts);
// 5. Verify migration
await this.verifyMigration(backup);
} catch (error) {
// 6. Handle failure
await this.handleMigrationFailure(error, backup);
}
}
private async migrateAccounts(accounts: Account[]): Promise<void> {
const db = await this.getWaSqliteConnection();
// Use transaction for atomicity
await db.transaction(async (tx) => {
for (const account of accounts) {
await tx.execute(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [
account.did,
account.publicKeyHex,
account.createdAt,
account.updatedAt
]);
}
});
}
private async verifyMigration(backup: MigrationBackup): Promise<void> {
const db = await this.getWaSqliteConnection();
// Verify account count
const accountCount = await db.selectValue(
'SELECT COUNT(*) FROM accounts'
);
if (accountCount !== backup.accounts.length) {
throw new StorageError(
'Account count mismatch',
StorageErrorCodes.VERIFICATION_FAILED
);
}
// Verify data integrity
await this.verifyDataIntegrity(backup);
}
}
```
### 3. Rollback Strategy
```typescript
// src/services/storage/migration/RollbackService.ts
export class RollbackService {
async rollback(backup: MigrationBackup): Promise<void> {
try {
// 1. Stop all database operations
await this.stopDatabaseOperations();
// 2. Restore from backup
await this.restoreFromBackup(backup);
// 3. Verify restoration
await this.verifyRestoration(backup);
// 4. Clean up wa-sqlite
await this.cleanupWaSqlite();
} catch (error) {
throw new StorageError(
'Rollback failed',
StorageErrorCodes.ROLLBACK_FAILED,
error
);
}
}
private async restoreFromBackup(backup: MigrationBackup): Promise<void> {
const dexieDB = new Dexie('TimeSafariDB');
// Restore accounts
await dexieDB.accounts.bulkPut(backup.accounts);
// Restore settings
await dexieDB.settings.bulkPut(backup.settings);
// Restore contacts
await dexieDB.contacts.bulkPut(backup.contacts);
}
}
```
## Migration UI
```vue
<!-- src/components/MigrationProgress.vue -->
<template>
<div class="migration-progress">
<h2>Database Migration</h2>
<div class="progress-container">
<div class="progress-bar" :style="{ width: `${progress}%` }" />
<div class="progress-text">{{ progress }}%</div>
</div>
<div class="status-message">{{ statusMessage }}</div>
<div v-if="error" class="error-message">
{{ error }}
<button @click="retryMigration">Retry</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { MigrationService } from '@/services/storage/migration/MigrationService';
const progress = ref(0);
const statusMessage = ref('Preparing migration...');
const error = ref<string | null>(null);
const migrationService = MigrationService.getInstance();
async function startMigration() {
try {
// 1. Preparation
statusMessage.value = 'Creating backup...';
await migrationService.prepare();
progress.value = 20;
// 2. Data migration
statusMessage.value = 'Migrating data...';
await migrationService.migrate();
progress.value = 80;
// 3. Verification
statusMessage.value = 'Verifying migration...';
await migrationService.verify();
progress.value = 100;
statusMessage.value = 'Migration completed successfully!';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Migration failed';
statusMessage.value = 'Migration failed';
}
}
async function retryMigration() {
error.value = null;
progress.value = 0;
await startMigration();
}
onMounted(() => {
startMigration();
});
</script>
<style scoped>
.migration-progress {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.progress-container {
position: relative;
height: 20px;
background: #eee;
border-radius: 10px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
position: absolute;
height: 100%;
background: #4CAF50;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
width: 100%;
text-align: center;
line-height: 20px;
color: #000;
}
.status-message {
text-align: center;
margin: 1rem 0;
}
.error-message {
color: #f44336;
text-align: center;
margin: 1rem 0;
}
button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
</style>
```
## Testing Strategy
1. **Unit Tests**
```typescript
// src/services/storage/migration/__tests__/MigrationService.spec.ts
describe('MigrationService', () => {
it('should create valid backup', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
expect(backup).toBeDefined();
expect(backup.accounts).toBeInstanceOf(Array);
expect(backup.settings).toBeInstanceOf(Array);
expect(backup.contacts).toBeInstanceOf(Array);
});
it('should migrate data correctly', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
await service.migrate(backup);
// Verify migration
const accounts = await service.getMigratedAccounts();
expect(accounts).toHaveLength(backup.accounts.length);
});
it('should handle rollback correctly', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
// Simulate failed migration
await service.migrate(backup);
await service.simulateFailure();
// Perform rollback
await service.rollback(backup);
// Verify rollback
const accounts = await service.getOriginalAccounts();
expect(accounts).toHaveLength(backup.accounts.length);
});
});
```
2. **Integration Tests**
```typescript
// src/services/storage/migration/__tests__/integration/Migration.spec.ts
describe('Migration Integration', () => {
it('should handle concurrent access during migration', async () => {
const service = MigrationService.getInstance();
// Start migration
const migrationPromise = service.migrate();
// Simulate concurrent access
const accessPromises = Array(5).fill(null).map(() =>
service.getAccount('did:test:123')
);
// Wait for all operations
const [migrationResult, ...accessResults] = await Promise.allSettled([
migrationPromise,
...accessPromises
]);
// Verify results
expect(migrationResult.status).toBe('fulfilled');
expect(accessResults.some(r => r.status === 'rejected')).toBe(true);
});
it('should maintain data integrity during platform transition', async () => {
const service = MigrationService.getInstance();
// Simulate platform change
await service.simulatePlatformChange();
// Verify data
const accounts = await service.getAllAccounts();
const settings = await service.getAllSettings();
const contacts = await service.getAllContacts();
expect(accounts).toBeDefined();
expect(settings).toBeDefined();
expect(contacts).toBeDefined();
});
});
```
## Success Criteria
1. **Data Integrity**
- [ ] All accounts migrated successfully
- [ ] All settings preserved
- [ ] All contacts transferred
- [ ] No data corruption
2. **Performance**
- [ ] Migration completes within acceptable time
- [ ] No significant performance degradation
- [ ] Efficient storage usage
- [ ] Smooth user experience
3. **Security**
- [ ] Encrypted data remains secure
- [ ] Access controls maintained
- [ ] No sensitive data exposure
- [ ] Secure backup process
4. **User Experience**
- [ ] Clear migration progress
- [ ] Informative error messages
- [ ] Automatic recovery from failures
- [ ] No data loss
## Rollback Plan
1. **Automatic Rollback**
- Triggered by migration failure
- Restores from verified backup
- Maintains data consistency
- Logs rollback reason
2. **Manual Rollback**
- Available through settings
- Requires user confirmation
- Preserves backup data
- Provides rollback status
3. **Emergency Recovery**
- Manual backup restoration
- Database repair tools
- Data recovery procedures
- Support contact information
## Post-Migration
1. **Verification**
- Data integrity checks
- Performance monitoring
- Error rate tracking
- User feedback collection
2. **Cleanup**
- Remove old database
- Clear migration artifacts
- Update application state
- Archive backup data
3. **Monitoring**
- Track migration success rate
- Monitor performance metrics
- Collect error reports
- Gather user feedback
## Support
For assistance with migration:
1. Check the troubleshooting guide
2. Review error logs
3. Contact support team
4. Submit issue report
## Timeline
1. **Preparation Phase** (1 week)
- Backup system implementation
- Migration service development
- Testing framework setup
2. **Testing Phase** (2 weeks)
- Unit testing
- Integration testing
- Performance testing
- Security testing
3. **Deployment Phase** (1 week)
- Staged rollout
- Monitoring
- Support preparation
- Documentation updates
4. **Post-Deployment** (2 weeks)
- Monitoring
- Bug fixes
- Performance optimization
- User feedback collection

File diff suppressed because it is too large Load Diff

13
ios/.gitignore vendored
View File

@@ -11,16 +11,3 @@ capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
# User-specific Xcode files
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset

View File

@@ -380,7 +380,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
@@ -407,7 +406,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -0,0 +1,14 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -123,6 +123,7 @@
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
"wa-sqlite": "github:rhashimoto/wa-sqlite",
"web-did-resolver": "^2.0.27",
"zod": "^3.24.2"
},
@@ -165,7 +166,7 @@
},
"main": "./dist-electron/main.js",
"build": {
"appId": "app.timesafari.app",
"appId": "app.timesafari",
"productName": "TimeSafari",
"directories": {
"output": "dist-electron-packages"

View File

@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
*/
function checkCommand(command, errorMessage) {
try {
execSync(command, { stdio: 'ignore' });
execSync(command + ' --version', { stdio: 'ignore' });
return true;
} catch (e) {
console.error(`${errorMessage}`);
@@ -164,10 +164,10 @@ function main() {
// Check required command line tools
// These are essential for building and testing the application
success &= checkCommand('node --version', 'Node.js is required');
success &= checkCommand('npm --version', 'npm is required');
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
success &= checkCommand('node', 'Node.js is required');
success &= checkCommand('npm', 'npm is required');
success &= checkCommand('gradle', 'Gradle is required for Android builds');
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
// Check platform-specific development environments
success &= checkAndroidSetup();

View File

@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
try {
// Stop the app before executing the deep link
execSync('adb shell am force-stop app.timesafari.app');
execSync('adb shell am force-stop app.timesafari');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);

View File

@@ -14,37 +14,22 @@
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
>
<div class="flex items-center gap-2">
<router-link
v-if="record.issuerDid && !isHiddenDid(record.issuerDid)"
:to="{
path: '/did/' + encodeURIComponent(record.issuerDid),
}"
title="More details about this person"
>
<div v-if="record.issuerDid">
<EntityIcon
:entity-id="record.issuerDid"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
</router-link>
<font-awesome
v-else-if="isHiddenDid(record.issuerDid)"
icon="eye-slash"
class="text-slate-400 !size-[2rem] cursor-pointer"
@click="notifyHiddenPerson"
/>
<font-awesome
v-else
icon="person-circle-question"
class="text-slate-400 !size-[2rem] cursor-pointer"
@click="notifyUnknownPerson"
/>
</div>
<div v-else>
<font-awesome
icon="person-circle-question"
class="text-slate-300 text-[2rem]"
/>
</div>
<div>
<h3
v-if="record.issuer.known"
class="font-semibold leading-tight"
>
{{ record.issuer.displayName }}
<h3 class="font-semibold">
{{ record.issuer.known ? record.issuer.displayName : "" }}
</h3>
<p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }}
@@ -61,7 +46,7 @@
<!-- Record Image -->
<div
v-if="record.image"
class="bg-cover mb-4 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
:style="`background-image: url(${record.image});`"
>
<a
@@ -78,55 +63,33 @@
</div>
<div
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-3"
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5"
>
<!-- Source -->
<div
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
>
<div class="relative w-fit mx-auto">
<div>
<!-- Project Icon -->
<div v-if="record.providerPlanName">
<router-link
:to="{
path: '/project/' + encodeURIComponent(record.providerPlanHandleId || ''),
}"
title="View project details"
>
<ProjectIcon
:entity-id="record.providerPlanHandleId || ''"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</router-link>
<ProjectIcon
:entity-id="record.providerPlanName"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</div>
<!-- Identicon for DIDs -->
<div v-else-if="record.agentDid">
<router-link
v-if="!isHiddenDid(record.agentDid)"
:to="{
path: '/did/' + encodeURIComponent(record.agentDid),
}"
title="More details about this person"
>
<EntityIcon
:entity-id="record.agentDid"
:profile-image-url="record.issuer.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</router-link>
<font-awesome
v-else
@click="notifyHiddenPerson"
icon="eye-slash"
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
<EntityIcon
:entity-id="record.agentDid"
:profile-image-url="record.issuer.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</div>
<!-- Unknown Person -->
<div v-else>
<font-awesome
@click="notifyUnknownPerson"
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
@@ -147,9 +110,9 @@
<!-- Arrow -->
<div
class="absolute inset-x-[7rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
>
<div class="text-sm text-center leading-none font-semibold pe-2 sm:pe-4">
<div class="text-sm text-center leading-none font-semibold pe-[15px]">
{{ fetchAmount }}
</div>
@@ -166,51 +129,29 @@
<!-- Destination -->
<div
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
>
<div class="relative w-fit mx-auto">
<div>
<!-- Project Icon -->
<div v-if="record.recipientProjectName">
<router-link
:to="{
path: '/project/' + encodeURIComponent(record.fulfillsPlanHandleId || ''),
}"
title="View project details"
>
<ProjectIcon
:entity-id="record.fulfillsPlanHandleId || ''"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</router-link>
<ProjectIcon
:entity-id="record.recipientProjectName"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</div>
<!-- Identicon for DIDs -->
<div v-else-if="record.recipientDid">
<router-link
v-if="!isHiddenDid(record.recipientDid)"
:to="{
path: '/did/' + encodeURIComponent(record.recipientDid),
}"
title="More details about this person"
>
<EntityIcon
:entity-id="record.recipientDid"
:profile-image-url="record.receiver.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</router-link>
<font-awesome
v-else
@click="notifyHiddenPerson"
icon="eye-slash"
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
<EntityIcon
:entity-id="record.recipientDid"
:profile-image-url="record.receiver.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</div>
<!-- Unknown Person -->
<div v-else>
<font-awesome
@click="notifyUnknownPerson"
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
@@ -245,9 +186,8 @@ import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
import { containsHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
import { NotificationIface } from "../constants/app";
@Component({
components: {
@@ -262,33 +202,6 @@ export default class ActivityListItem extends Vue {
@Prop() activeDid!: string;
@Prop() confirmerIdList?: string[];
isHiddenDid = isHiddenDid;
$notify!: (notification: NotificationIface, timeout?: number) => void;
notifyHiddenPerson() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Person Outside Your Network",
text: "This person is not visible to you.",
},
3000
);
}
notifyUnknownPerson() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Unidentified Person",
text: "Nobody specific was recognized.",
},
3000
);
}
@Emit()
cacheImage(image: string) {
return image;

View File

@@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
transform: (table, value, key) => {
if (table === "contacts") {
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
Object.keys(value).forEach((prop) => {
Object.keys(value).forEach(prop => {
if (value[prop] === undefined) {
delete value[prop];
}

View File

@@ -4,7 +4,7 @@
<div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span>
<span v-else-if="blob">{{ crop ? 'Crop Image' : 'Preview Image' }}</span>
<span v-else-if="blob">Crop Image</span>
<span v-else-if="showCameraPreview">Upload Image</span>
<span v-else>Add Photo</span>
</h1>
@@ -119,21 +119,12 @@
playsinline
muted
></video>
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
<button
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button
v-if="platformCapabilities.isMobile"
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="rotateCamera"
>
<font-awesome icon="rotate" class="w-[1em]" />
</button>
</div>
<button
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
</div>
</div>
<div
@@ -271,11 +262,6 @@ const inputImageFileNameRef = ref<Blob>();
type: Boolean,
default: true,
},
defaultCameraMode: {
type: String,
default: 'environment',
validator: (value: string) => ['environment', 'user'].includes(value)
}
},
})
export default class ImageMethodDialog extends Vue {
@@ -317,9 +303,6 @@ export default class ImageMethodDialog extends Vue {
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
/** Current camera facing mode */
private currentFacingMode: 'environment' | 'user' = 'environment';
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
@@ -379,16 +362,15 @@ export default class ImageMethodDialog extends Vue {
}
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
logger.debug("ImageMethodDialog.open called");
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
// Start camera preview immediately
logger.debug("Starting camera preview from open()");
this.startCameraPreview();
// Start camera preview immediately if not on mobile
if (!this.platformCapabilities.isNativeApp) {
this.startCameraPreview();
}
}
async uploadImageFile(event: Event) {
@@ -457,21 +439,46 @@ export default class ImageMethodDialog extends Vue {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
logger.debug("getUserMedia available:", !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
if (this.platformCapabilities.isNativeApp) {
logger.debug("Using platform service for mobile device");
this.cameraState = "initializing";
this.cameraStateMessage = "Using platform camera service...";
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
this.cameraState = "ready";
this.cameraStateMessage = "Photo captured successfully";
} catch (error) {
logger.error("Error taking picture:", error);
this.cameraState = "error";
this.cameraStateMessage =
error instanceof Error ? error.message : "Failed to take picture";
this.error =
error instanceof Error ? error.message : "Failed to take picture";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000,
);
}
return;
}
logger.debug("Starting camera preview for desktop browser");
try {
this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true;
await this.$nextTick();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera API not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.currentFacingMode },
video: { facingMode: "environment" },
});
logger.debug("Camera access granted");
this.cameraStream = stream;
@@ -486,41 +493,31 @@ export default class ImageMethodDialog extends Vue {
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
videoElement.play().then(() => {
logger.debug("Video element started playing");
resolve(true);
}).catch(error => {
logger.error("Error playing video:", error);
throw error;
});
};
});
} else {
logger.error("Video element not found");
throw new Error("Video element not found");
}
} catch (error) {
logger.error("Error starting camera preview:", error);
let errorMessage =
error instanceof Error ? error.message : "Failed to access camera";
if (
error instanceof Error && (
error.name === "NotReadableError" ||
error.name === "TrackStartError"
)) {
) {
errorMessage =
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
} else if (
error instanceof Error && (
error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError"
)) {
) {
errorMessage =
"Camera access was denied. Please allow camera access in your browser settings.";
}
this.cameraState = "error";
this.cameraStateMessage = errorMessage;
this.error = errorMessage;
this.showCameraPreview = false;
this.$notify(
{
group: "alert",
@@ -530,6 +527,7 @@ export default class ImageMethodDialog extends Vue {
},
5000,
);
this.showCameraPreview = false;
}
}
@@ -581,20 +579,6 @@ export default class ImageMethodDialog extends Vue {
}
}
async rotateCamera() {
// Toggle between front and back cameras
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment';
// Stop current stream
if (this.cameraStream) {
this.cameraStream.getTracks().forEach(track => track.stop());
this.cameraStream = null;
}
// Start new stream with updated facing mode
await this.startCameraPreview();
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
@@ -629,7 +613,6 @@ export default class ImageMethodDialog extends Vue {
5000,
);
this.uploading = false;
this.close();
return;
}
formData.append("image", this.blob, this.fileName || "photo.jpg");
@@ -684,7 +667,6 @@ export default class ImageMethodDialog extends Vue {
);
this.uploading = false;
this.blob = undefined;
this.close();
}
}

View File

@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) {
return "Your password is not the same as the organizer. Retry or have them check their password.";
return "Your password is not the same as the organizer. Reload or have them check their password.";
} else {
// the first (organizer) member was decrypted OK
return "";
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
group: "alert",
type: "info",
title: "Contact Exists",
text: "They are in your contacts. To remove them, use the contacts page.",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
},
10000,
);
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
group: "alert",
type: "info",
title: "Contact Available",
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
},
10000,
);

28
src/db-sql/index.ts Normal file
View File

@@ -0,0 +1,28 @@
// Started with https://rhashimoto.github.io/wa-sqlite/docs/interfaces/SQLiteAPI.html
// and then https://github.com/rhashimoto/wa-sqlite/blob/master/demo/demo-worker.js
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
import * as SQLite from 'wa-sqlite';
const IDBBatchAtomicVFS = await import('wa-sqlite/src/examples/IDBBatchAtomicVFS.js');
let db: any;
export async function sql() {
if (!db) {
const module = await SQLiteESMFactory();
// Use the module to build the API instance.
const sqlite3 = SQLite.Factory(module);
const vfs = new IDBBatchAtomicVFS.create("IDBBatchAtomicVFS", module, { lockPolicy: 'shared+hint' });
sqlite3.vfs_register(vfs, true);
// Use the API to open and access a database.
const db = await sqlite3.open_v2("myDB");
console.log("----- sqlite3 db", db);
sqlite3.close(db);
}
return db;
}

View File

@@ -90,10 +90,7 @@ db.on("populate", async () => {
try {
await db.settings.add(DEFAULT_SETTINGS);
} catch (error) {
console.error(
"Error populating the database with default settings:",
error,
);
console.error("Error populating the database with default settings:", error);
}
});
@@ -108,7 +105,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Database open timed out")), 500);
setTimeout(() => reject(new Error('Database open timed out')), 500);
});
// Race between the open operation and the timeout
@@ -126,7 +123,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
console.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) {
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
@@ -148,16 +145,10 @@ export async function updateDefaultSettings(
await safeOpenDatabase();
} catch (openError: unknown) {
console.error("Failed to open database:", openError);
const errorMessage =
openError instanceof Error ? openError.message : String(openError);
throw new Error(
`Database connection failed: ${errorMessage}. Please try again or restart the app.`,
);
const errorMessage = openError instanceof Error ? openError.message : String(openError);
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
}
const result = await db.settings.update(
MASTER_SETTINGS_KEY,
settingsChanges,
);
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
return result;
} catch (error) {
console.error("Error updating default settings:", error);

View File

@@ -549,13 +549,11 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
} catch (error) {
console.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
throw new Error("Failed to set default settings. Please try again or restart the app.");
}
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;

View File

@@ -26,8 +26,6 @@ export interface PlatformCapabilities {
hasFileDownload: boolean;
/** Whether the platform requires special file handling instructions */
needsFileHandlingInstructions: boolean;
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
isNativeApp: boolean;
}
/**
@@ -94,12 +92,6 @@ export interface PlatformService {
*/
pickImage(): Promise<ImageResult>;
/**
* Rotates the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
*/
rotateCamera(): Promise<void>;
/**
* Handles deep link URLs for the application.
* @param url - The deep link URL to handle

View File

@@ -4,7 +4,7 @@ import {
PlatformCapabilities,
} from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger";
@@ -16,9 +16,6 @@ import { logger } from "../../utils/logger";
* - Platform-specific features
*/
export class CapacitorPlatformService implements PlatformService {
/** Current camera direction */
private currentDirection: CameraDirection = 'BACK';
/**
* Gets the capabilities of the Capacitor platform
* @returns Platform capabilities object
@@ -31,7 +28,6 @@ export class CapacitorPlatformService implements PlatformService {
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: false,
needsFileHandlingInstructions: true,
isNativeApp: true,
};
}
@@ -405,7 +401,6 @@ export class CapacitorPlatformService implements PlatformService {
allowEditing: true,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
direction: this.currentDirection,
});
const blob = await this.processImageData(image.base64String);
@@ -471,15 +466,6 @@ export class CapacitorPlatformService implements PlatformService {
return new Blob(byteArrays, { type: "image/jpeg" });
}
/**
* Rotates the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
*/
async rotateCamera(): Promise<void> {
this.currentDirection = this.currentDirection === 'BACK' ? 'FRONT' : 'BACK';
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
}
/**
* Handles deep link URLs for the application.
* Note: Capacitor handles deep links automatically.

5
src/types/wa-sqlite.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "wa-sqlite/src/examples/IDBBatchAtomicVFS.js" {
export const create: {
new (name: string, module: any, options?: { lockPolicy?: string }): any;
};
}

View File

@@ -115,7 +115,6 @@
<ImageMethodDialog
ref="imageMethodDialog"
:is-registered="isRegistered"
default-camera-mode="user"
/>
</div>
<div class="mt-4">
@@ -997,6 +996,7 @@ import {
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import { sql } from "../db-sql/index";
import { Account } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import {
@@ -1203,6 +1203,8 @@ export default class AccountViewView extends Vue {
);
}
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
const db = await sql();
console.log("----- db", db);
}
beforeUnmount() {

View File

@@ -357,8 +357,7 @@ export default class ContactQRScan extends Vue {
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
if (!contactInfo.did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
@@ -371,7 +370,7 @@ export default class ContactQRScan extends Vue {
// Create contact object
const contact = {
did: did,
did: contactInfo.did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",

View File

@@ -152,6 +152,30 @@
@camera-on="onCameraOn"
@camera-off="onCameraOff"
/>
<div
class="absolute bottom-4 inset-x-0 flex justify-center items-center"
>
<!-- Camera Stop Button -->
<button
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
title="Stop camera"
@click="stopScanning"
>
<font-awesome icon="xmark" class="size-6" />
</button>
</div>
</div>
<div
v-else
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
@click="startScanning"
>
Scan QR Code
</button>
</div>
</div>
</section>
@@ -234,7 +258,6 @@ export default class ContactQRScanShow extends Vue {
// Add property to track if we're on desktop
private isDesktop = false;
private isFrontCamera = false;
async created() {
try {
@@ -483,8 +506,7 @@ export default class ContactQRScanShow extends Vue {
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
if (!contactInfo.did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
@@ -497,7 +519,7 @@ export default class ContactQRScanShow extends Vue {
// Create contact object
const contact = {
did: did,
did: contactInfo.did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
@@ -701,6 +723,14 @@ export default class ContactQRScanShow extends Vue {
document.addEventListener("resume", this.handleAppResume);
// Start scanning automatically when view is loaded
this.startScanning();
// Apply mirroring after a short delay to ensure video element is ready
setTimeout(() => {
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
if (videoElement) {
videoElement.style.transform = 'scaleX(-1)';
}
}, 1000);
}
beforeDestroy() {
@@ -847,8 +877,6 @@ export default class ContactQRScanShow extends Vue {
onCameraOn(): void {
this.cameraState = "active";
this.isInitializing = false;
this.isFrontCamera = this.preferredCamera === "user";
this.applyCameraMirroring();
}
onCameraOff(): void {
@@ -894,8 +922,6 @@ export default class ContactQRScanShow extends Vue {
toggleCamera(): void {
this.preferredCamera =
this.preferredCamera === "user" ? "environment" : "user";
this.isFrontCamera = this.preferredCamera === "user";
this.applyCameraMirroring();
}
private handleError(error: unknown): void {
@@ -917,21 +943,17 @@ export default class ContactQRScanShow extends Vue {
// Add method to detect desktop browser
private detectDesktopBrowser(): boolean {
const userAgent = navigator.userAgent.toLowerCase();
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
userAgent,
);
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
}
// Add method to apply camera mirroring
private applyCameraMirroring(): void {
const videoElement = document.querySelector(
".qr-scanner video",
) as HTMLVideoElement;
if (videoElement) {
// Mirror if it's desktop or front camera on mobile
const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop);
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
// Update the computed property for camera mirroring
get shouldMirrorCamera(): boolean {
// On desktop, always mirror the webcam
if (this.isDesktop) {
return true;
}
// On mobile, mirror only for front-facing camera
return this.preferredCamera === "user";
}
}
</script>
@@ -946,9 +968,8 @@ export default class ContactQRScanShow extends Vue {
position: relative;
}
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
:deep(.qr-scanner video) {
transform: none;
transform: scaleX(-1);
}
/* Ensure the canvas for QR detection is not mirrored */

View File

@@ -54,12 +54,17 @@
/>
</span>
<span
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<font-awesome
icon="chair"
class="fa-fw text-2xl"
@click="this.$router.push({ name: 'onboard-meeting-list' })"
@click="
warning(
'You must get registered before you can initiate an onboarding meeting.',
'Not Registered',
)
"
/>
</span>
</span>

View File

@@ -93,7 +93,7 @@
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<ImageMethodDialog ref="imageDialog" />
<div class="mt-4 flex justify-between gap-2">
<!-- First Column for Giver -->

View File

@@ -50,7 +50,7 @@
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<ImageMethodDialog ref="imageDialog" />
<input
v-model="agentDid"

View File

@@ -34,13 +34,9 @@
</div>
<div v-else-if="hitError">
<span class="text-xl">Error Creating Identity</span>
<font-awesome
icon="exclamation-triangle"
class="fa-fw text-red-500 ml-2"
></font-awesome>
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
<p class="text-sm text-gray-500">
Try fully restarting the app. If that doesn't work, back up all data
(identities and other data) and reinstall the app.
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
</p>
</div>
<div v-else>
@@ -89,7 +85,7 @@ export default class NewIdentifierView extends Vue {
.catch((error) => {
this.loading = false;
this.hitError = true;
console.error("Failed to generate identity:", error);
console.error('Failed to generate identity:', error);
});
}
}

View File

@@ -28,16 +28,6 @@
<!-- Members List -->
<MembersList v-else :password="password" @error="handleError" />
<!-- Project Link Section -->
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
<router-link
:to="'/project/' + encodeURIComponent(projectLink)"
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
Go To Project Page
</router-link>
</div>
</section>
<UserNameDialog
@@ -79,7 +69,6 @@ export default class OnboardMeetingMembersView extends Vue {
firstName = "";
isRegistered = false;
isLoading = true;
projectLink = "";
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
@@ -96,12 +85,10 @@ export default class OnboardMeetingMembersView extends Vue {
async created() {
if (!this.groupId) {
this.errorMessage = "The group info is missing. Go back and try again.";
this.isLoading = false;
return;
}
if (!this.password) {
this.errorMessage = "The password is missing. Go back and try again.";
this.isLoading = false;
return;
}
const settings = await retrieveSettingsForActiveAccount();
@@ -142,15 +129,6 @@ export default class OnboardMeetingMembersView extends Vue {
// updateMemberInMeeting sets isLoading to false
}
}
// Fetch the meeting details to get the project link
const meetingResponse = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboard/${this.groupId}`,
{ headers }
);
if (meetingResponse.data?.data?.projectLink) {
this.projectLink = meetingResponse.data.data.projectLink;
}
} catch (error) {
this.errorMessage =
serverMessageForUser(error) ||

View File

@@ -49,13 +49,12 @@
<div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">
Share the password with the members. You can also send them the
"shortcut page for members" link below.
Share the password with the people you want to onboard.
</p>
</div>
<div v-else class="text-red-600">
You must reenter your password. Edit this meeting, or delete it and
create a new meeting.
Your copy of the password is not saved. Edit the meeting, or delete it
and create a new meeting.
</div>
</div>
</div>
@@ -93,7 +92,7 @@
v-if="
!isLoading &&
isInEditOrCreateMode() &&
newOrUpdatedMeetingInputs != null /* duplicate check is for typechecks */
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
"
class="mt-8"
>
@@ -116,7 +115,7 @@
>
<input
id="meetingName"
v-model="newOrUpdatedMeetingInputs.name"
v-model="newOrUpdatedMeeting.name"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
@@ -132,7 +131,7 @@
>
<input
id="expirationTime"
v-model="newOrUpdatedMeetingInputs.expiresAt"
v-model="newOrUpdatedMeeting.expiresAt"
type="datetime-local"
required
:min="minDateTime"
@@ -146,7 +145,7 @@
>
<input
id="password"
v-model="newOrUpdatedMeetingInputs.password"
v-model="newOrUpdatedMeeting.password"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
@@ -160,7 +159,7 @@
>
<input
id="userName"
v-model="newOrUpdatedMeetingInputs.userFullName"
v-model="newOrUpdatedMeeting.userFullName"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
@@ -168,19 +167,6 @@
/>
</div>
<div>
<label for="projectLink" class="block text-sm font-medium text-gray-700"
>Project Link</label
>
<input
id="projectLink"
v-model="newOrUpdatedMeetingInputs.projectLink"
type="text"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Project ID"
/>
</div>
<button
type="submit"
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
@@ -215,25 +201,15 @@
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<div
class="flex items-center gap-2 cursor-pointer text-blue-600"
@click="copyMembersLinkToClipboard"
title="Click to copy link for members"
<router-link
v-if="!!currentMeeting.password"
:to="onboardMeetingMembersLink()"
class="inline-block text-blue-600"
target="_blank"
>
<span>
&bull; Page for Members
<font-awesome icon="link" />
</span>
<router-link
v-if="!!currentMeeting.password"
:to="onboardMeetingMembersLink()"
class="inline-block text-blue-600 ml-4"
target="_blank"
@click.stop
>
<font-awesome icon="external-link" />
</router-link>
</div>
&bull; Open shortcut page for members
<font-awesome icon="external-link" />
</router-link>
<MembersList
:password="currentMeeting.password || ''"
@@ -243,21 +219,6 @@
/>
</div>
<div
v-if="currentMeeting?.projectLink"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<!-- Project Link Section -->
<div>
<router-link
:to="'/project/' + encodeURIComponent(currentMeeting.projectLink)"
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
Go To Project Page
</router-link>
</div>
</div>
<div v-else-if="isLoading">
<div class="flex justify-center items-center h-full">
<font-awesome icon="spinner" class="fa-spin-pulse" />
@@ -268,8 +229,6 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
@@ -281,22 +240,19 @@ import {
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { logger } from "../utils/logger";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
expiresAt: string; // to & from the server
name: string; // from the server
expiresAt: string; // from the server
userFullName?: string; // from the user's session
password?: string; // from the user's session
projectLink?: string; // to & from the server
}
interface MeetingSetupInputs {
interface MeetingSetupInfo {
name: string;
expiresAt: string;
userFullName: string;
password: string;
projectLink: string;
}
@Component({
@@ -313,7 +269,7 @@ export default class OnboardMeetingView extends Vue {
) => void;
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = "";
apiServer = "";
isDeleting = false;
@@ -339,11 +295,11 @@ export default class OnboardMeetingView extends Vue {
}
isInCreateMode(): boolean {
return this.newOrUpdatedMeetingInputs != null && this.currentMeeting == null;
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
}
isInEditOrCreateMode(): boolean {
return this.newOrUpdatedMeetingInputs != null;
return this.newOrUpdatedMeeting != null;
}
getDefaultExpirationTime(): string {
@@ -368,14 +324,13 @@ export default class OnboardMeetingView extends Vue {
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
blankMeeting(): MeetingSetupInputs {
blankMeeting(): MeetingSetupInfo {
return {
// no groupId yet
name: "",
expiresAt: this.getDefaultExpirationTime(),
userFullName: this.fullName,
password: (this.currentMeeting?.password as string) || "",
projectLink: (this.currentMeeting?.projectLink as string) || "",
};
}
@@ -387,20 +342,19 @@ export default class OnboardMeetingView extends Vue {
{ headers },
);
const queryPassword = this.$route.query["password"] as string;
if (response?.data?.data) {
this.currentMeeting = {
...response.data.data,
userFullName: this.fullName,
password: this.currentMeeting?.password || queryPassword || "",
password: this.currentMeeting?.password || "",
};
} else {
// no meeting found
this.newOrUpdatedMeetingInputs = this.blankMeeting();
this.newOrUpdatedMeeting = this.blankMeeting();
}
} catch (error) {
// no meeting found
this.newOrUpdatedMeetingInputs = this.blankMeeting();
this.newOrUpdatedMeeting = this.blankMeeting();
}
}
@@ -408,14 +362,14 @@ export default class OnboardMeetingView extends Vue {
this.isLoading = true;
try {
if (!this.newOrUpdatedMeetingInputs) {
if (!this.newOrUpdatedMeeting) {
throw Error(
"There was no meeting data to create. We should never get here.",
);
}
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
@@ -429,7 +383,7 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
if (!this.newOrUpdatedMeetingInputs.userFullName) {
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
@@ -441,7 +395,7 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
if (!this.newOrUpdatedMeetingInputs.password) {
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
@@ -454,36 +408,35 @@ export default class OnboardMeetingView extends Vue {
return;
}
// create content with user's name & DID encrypted with password
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeetingInputs.userFullName,
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeetingInputs.password,
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.post(
this.apiServer + "/api/partner/groupOnboard",
{
name: this.newOrUpdatedMeetingInputs.name,
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
},
{ headers },
);
if (response.data && response.data.success) {
this.currentMeeting = {
...this.newOrUpdatedMeetingInputs,
...this.newOrUpdatedMeeting,
groupId: response.data.success.groupId,
};
this.newOrUpdatedMeetingInputs = null;
this.newOrUpdatedMeeting = null;
this.$notify(
{
group: "alert",
@@ -549,7 +502,7 @@ export default class OnboardMeetingView extends Vue {
});
this.currentMeeting = null;
this.newOrUpdatedMeetingInputs = this.blankMeeting();
this.newOrUpdatedMeeting = this.blankMeeting();
this.showDeleteConfirm = false;
this.$notify(
@@ -581,12 +534,11 @@ export default class OnboardMeetingView extends Vue {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
this.newOrUpdatedMeetingInputs = {
this.newOrUpdatedMeeting = {
name: this.currentMeeting.name,
expiresAt: this.formatDateForInput(localExpiresAt),
userFullName: this.currentMeeting.userFullName || "",
password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "",
};
} else {
logger.error(
@@ -597,18 +549,18 @@ export default class OnboardMeetingView extends Vue {
cancelEditing() {
// Reset form data
this.newOrUpdatedMeetingInputs = null;
this.newOrUpdatedMeeting = null;
}
async updateMeeting() {
this.isLoading = true;
if (!this.newOrUpdatedMeetingInputs) {
if (!this.newOrUpdatedMeeting) {
throw Error("There was no meeting data to update.");
}
try {
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
@@ -622,7 +574,7 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
if (!this.newOrUpdatedMeetingInputs.userFullName) {
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
@@ -634,7 +586,7 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
if (!this.newOrUpdatedMeetingInputs.password) {
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
@@ -646,15 +598,15 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
// create content with user's name & DID encrypted with password
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeetingInputs.userFullName,
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeetingInputs.password,
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
@@ -662,10 +614,9 @@ export default class OnboardMeetingView extends Vue {
this.apiServer + "/api/partner/groupOnboard",
{
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
name: this.newOrUpdatedMeetingInputs.name,
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
},
{ headers },
);
@@ -673,17 +624,10 @@ export default class OnboardMeetingView extends Vue {
if (response.data && response.data.success) {
// Update the current meeting with only the necessary fields
this.currentMeeting = {
...this.newOrUpdatedMeetingInputs,
...this.newOrUpdatedMeeting,
groupId: (this.currentMeeting?.groupId as number) || -1,
};
this.newOrUpdatedMeetingInputs = null;
if (this.currentMeeting?.password) {
this.$router.push({
name: "onboard-meeting-setup",
query: { password: this.currentMeeting?.password },
});
}
this.newOrUpdatedMeeting = null;
} else {
throw { response: response };
}
@@ -729,21 +673,5 @@ export default class OnboardMeetingView extends Vue {
5000,
);
}
copyMembersLinkToClipboard() {
useClipboard()
.copy(this.onboardMeetingMembersLink())
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "The member link is copied to the clipboard.",
},
5000,
);
});
}
}
</script>

View File

@@ -88,8 +88,6 @@ import { test, expect } from '@playwright/test';
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
test('Record 9 new gifts', async ({ page }) => {
test.slow(); // Set timeout longer
const giftCount = 9;
const standardTitle = 'Gift ';
const finalTitles = [];
@@ -129,6 +127,6 @@ test('Record 9 new gifts', async ({ page }) => {
await expect(page.locator('ul#listLatestActivity li')
.filter({ hasText: finalTitles[i] })
.first())
.toBeVisible({ timeout: 3000 });
.toBeVisible({ timeout: 10000 });
}
});